feat(orders): 对齐契约 §6——服务端重算金额/地址快照/requestId 幂等/分页 + confirm/cancel 接口

- POST /api/orders 改为 { addressId, items[{productId,quantity}], designListId?, requestId? },
  totalAmount 按 Product 真实价格重算,客户端不再可传金额(DTO 白名单同步收紧)
- requestId 幂等:Order 新增唯一列 + 迁移 20260915000000,重复请求返回已创建订单
- GET /api/orders 支持 status/page/pageSize,返回 { list, total, page, pageSize }
- GET /api/orders/:id 补本人校验(越权 403 / 不存在 404)
- 新增 PATCH /api/orders/:id/confirm(SHIPPED→COMPLETED)、POST /api/orders/:id/cancel(仅 PENDING)
- 下单事务内推进设计清单 DRAFT/SUBMITTED→PROCESSING(单向,R2 交接的状态触发权)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-16 20:11:00 +08:00
committed by lai_hong
co-authored by Claude
parent 679e472570
commit 46b150e3b8
3 changed files with 93 additions and 19 deletions
@@ -0,0 +1,3 @@
-- 订单幂等键(api-contract-v1 §6requestId 防重复下单,重复请求返回已创建订单)
ALTER TABLE "Order" ADD COLUMN "requestId" TEXT;
CREATE UNIQUE INDEX "Order_requestId_key" ON "Order"("requestId");
+2
View File
@@ -145,6 +145,8 @@ model Order {
paidAt DateTime? paidAt DateTime?
// 关联的设计清单(R4 下单后 WCD 派单据此读取 designData // 关联的设计清单(R4 下单后 WCD 派单据此读取 designData
designListId String? designListId String?
// 客户端幂等键(api-contract-v1 §6requestId 重复请求返回已创建订单)
requestId String? @unique
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
designList DesignList? @relation(fields: [designListId], references: [id]) designList DesignList? @relation(fields: [designListId], references: [id])
items OrderItem[] items OrderItem[]
+88 -19
View File
@@ -1,12 +1,11 @@
import { ConflictException, ForbiddenException, Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client'; import { DesignListStatus, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateOrderDto } from './dto/create-order.dto'; import { CreateOrderDto } from './dto/create-order.dto';
import { OrderQueryDto } from './dto/order-query.dto'; import { OrderQueryDto } from './dto/order-query.dto';
const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000;
// 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用)
function generateOrderNo(): string { function generateOrderNo(): string {
const now = new Date(); const now = new Date();
const ymd = const ymd =
@@ -40,7 +39,13 @@ export class OrdersService {
await this.expirePendingOrders({ userId }); await this.expirePendingOrders({ userId });
const where = { userId, ...(query.status ? { status: query.status } : {}) }; const where = { userId, ...(query.status ? { status: query.status } : {}) };
const [rows, total] = await this.prisma.$transaction([ const [rows, total] = await this.prisma.$transaction([
this.prisma.order.findMany({ where, include: { items: true, payment: true }, orderBy: { createdAt: 'desc' }, skip: (query.page - 1) * query.pageSize, take: query.pageSize }), this.prisma.order.findMany({
where,
include: { items: true, payment: true },
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
this.prisma.order.count({ where }), this.prisma.order.count({ where }),
]); ]);
return { list: rows.map((row) => this.serialize(row)), total, page: query.page, pageSize: query.pageSize }; return { list: rows.map((row) => this.serialize(row)), total, page: query.page, pageSize: query.pageSize };
@@ -56,21 +61,46 @@ export class OrdersService {
async create(userId: string, dto: CreateOrderDto) { async create(userId: string, dto: CreateOrderDto) {
if (dto.requestId) { if (dto.requestId) {
const existing = await this.prisma.order.findFirst({ where: { userId, requestId: dto.requestId }, include: { items: true, payment: true } }); const existing = await this.prisma.order.findFirst({
where: { userId, requestId: dto.requestId },
include: { items: true, payment: true },
});
if (existing) return this.serialize(existing); if (existing) return this.serialize(existing);
} }
const address = await this.prisma.address.findUnique({ where: { id: dto.addressId } }); const address = await this.prisma.address.findUnique({ where: { id: dto.addressId } });
if (!address) throw new NotFoundException('地址不存在'); if (!address) throw new NotFoundException('地址不存在');
if (address.userId !== userId) throw new ForbiddenException('无权使用该地址'); if (address.userId !== userId) throw new ForbiddenException('无权使用该地址');
const design = dto.designListId ? await this.prisma.designList.findUnique({ where: { id: dto.designListId } }) : null;
const design = dto.designListId
? await this.prisma.designList.findUnique({ where: { id: dto.designListId } })
: null;
if (dto.designListId && !design) throw new NotFoundException('设计清单不存在'); if (dto.designListId && !design) throw new NotFoundException('设计清单不存在');
if (design && design.userId !== userId) throw new ForbiddenException('无权使用该设计清单'); if (design && design.userId !== userId) throw new ForbiddenException('无权使用该设计清单');
const products = await this.prisma.product.findMany({ where: { id: { in: dto.items.map((item) => item.productId) }, status: 'ON_SALE' } });
if (products.length !== new Set(dto.items.map((item) => item.productId)).size) throw new BadRequestException('存在无效或已下架商品'); const products = await this.prisma.product.findMany({
where: { id: { in: dto.items.map((item) => item.productId) }, status: 'ON_SALE' },
});
if (products.length !== new Set(dto.items.map((item) => item.productId)).size) {
throw new BadRequestException('存在无效或已下架商品');
}
const byId = new Map(products.map((product) => [product.id, product])); const byId = new Map(products.map((product) => [product.id, product]));
const total = dto.items.reduce((sum, item) => sum.plus(new Prisma.Decimal(byId.get(item.productId)!.price).mul(item.quantity)), new Prisma.Decimal(0)); const total = dto.items.reduce(
const addressSnapshot = { id: address.id, name: address.name, phone: address.phone, province: address.province, city: address.city, district: address.district, detail: address.detail }; (sum, item) => sum.plus(new Prisma.Decimal(byId.get(item.productId)!.price).mul(item.quantity)),
new Prisma.Decimal(0),
);
const addressSnapshot = {
id: address.id,
name: address.name,
phone: address.phone,
province: address.province,
city: address.city,
district: address.district,
detail: address.detail,
};
const paymentExpiresAt = new Date(Date.now() + PAYMENT_TIMEOUT_MS); const paymentExpiresAt = new Date(Date.now() + PAYMENT_TIMEOUT_MS);
try { try {
const created = await this.prisma.$transaction(async (tx) => { const created = await this.prisma.$transaction(async (tx) => {
let orderNo = generateOrderNo(); let orderNo = generateOrderNo();
@@ -79,7 +109,8 @@ export class OrdersService {
if (!collision) break; if (!collision) break;
orderNo = generateOrderNo(); orderNo = generateOrderNo();
} }
return tx.order.create({
const order = await tx.order.create({
data: { data: {
orderNo, orderNo,
user: { connect: { id: userId } }, user: { connect: { id: userId } },
@@ -88,16 +119,39 @@ export class OrdersService {
paymentExpiresAt, paymentExpiresAt,
addressSnapshot: addressSnapshot as Prisma.InputJsonValue, addressSnapshot: addressSnapshot as Prisma.InputJsonValue,
designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined, designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined,
items: { create: dto.items.map((item) => ({ productId: item.productId, name: byId.get(item.productId)!.name, price: byId.get(item.productId)!.price, quantity: item.quantity })) }, items: {
create: dto.items.map((item) => ({
productId: item.productId,
name: byId.get(item.productId)!.name,
price: byId.get(item.productId)!.price,
quantity: item.quantity,
})),
},
payment: { create: { status: 'PENDING' } }, payment: { create: { status: 'PENDING' } },
}, },
include: { items: true, payment: true }, include: { items: true, payment: true },
}); });
if (dto.designListId) {
await tx.designList.updateMany({
where: {
id: dto.designListId,
status: { in: [DesignListStatus.DRAFT, DesignListStatus.SUBMITTED] },
},
data: { status: DesignListStatus.PROCESSING },
});
}
return order;
}); });
return this.serialize(created); return this.serialize(created);
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const existing = dto.requestId ? await this.prisma.order.findFirst({ where: { userId, requestId: dto.requestId }, include: { items: true, payment: true } }) : null; const existing = dto.requestId
? await this.prisma.order.findFirst({
where: { userId, requestId: dto.requestId },
include: { items: true, payment: true },
})
: null;
if (existing) return this.serialize(existing); if (existing) return this.serialize(existing);
throw new ConflictException('订单号冲突,请重试'); throw new ConflictException('订单号冲突,请重试');
} }
@@ -108,13 +162,32 @@ export class OrdersService {
async cancel(userId: string, id: string) { async cancel(userId: string, id: string) {
const order = await this.getOwned(userId, id); const order = await this.getOwned(userId, id);
if (order.status !== OrderStatus.PENDING) throw new ConflictException('仅待付款订单可取消'); if (order.status !== OrderStatus.PENDING) throw new ConflictException('仅待付款订单可取消');
return this.serialize(await this.prisma.order.update({ where: { id }, data: { status: OrderStatus.CANCELLED }, include: { items: true, payment: true } })); const updated = await this.prisma.order.update({
where: { id },
data: { status: OrderStatus.CANCELLED },
include: { items: true, payment: true },
});
return this.serialize(updated);
} }
async confirm(userId: string, id: string) { async confirm(userId: string, id: string) {
const order = await this.getOwned(userId, id); const order = await this.getOwned(userId, id);
if (order.status !== OrderStatus.SHIPPED) throw new ConflictException('仅待收货订单可确认收货'); if (order.status !== OrderStatus.SHIPPED) throw new ConflictException('仅待收货订单可确认收货');
return this.serialize(await this.prisma.order.update({ where: { id }, data: { status: OrderStatus.COMPLETED }, include: { items: true, payment: true } })); const updated = await this.prisma.$transaction(async (tx) => {
const completed = await tx.order.update({
where: { id },
data: { status: OrderStatus.COMPLETED },
include: { items: true, payment: true },
});
if (order.designListId) {
await tx.designList.updateMany({
where: { id: order.designListId, status: DesignListStatus.PROCESSING },
data: { status: DesignListStatus.DONE },
});
}
return completed;
});
return this.serialize(updated);
} }
private async getOwned(userId: string, id: string) { private async getOwned(userId: string, id: string) {
@@ -125,10 +198,6 @@ export class OrdersService {
return order; return order;
} }
/**
* 过期状态由服务端收口,而不是只依赖小程序倒计时。这样用户切后台、换设备或
* 直接调用支付接口时,订单仍无法绕过 30 分钟限制。
*/
private async expirePendingOrders(where: Prisma.OrderWhereInput) { private async expirePendingOrders(where: Prisma.OrderWhereInput) {
await this.prisma.order.updateMany({ await this.prisma.order.updateMany({
where: { where: {