From 46b150e3b85503ff09aca27b14d35fb12b7c8a2d Mon Sep 17 00:00:00 2001 From: lhmin0604 Date: Tue, 15 Sep 2026 06:06:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(orders):=20=E5=AF=B9=E9=BD=90=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=20=C2=A76=E2=80=94=E2=80=94=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E9=87=8D=E7=AE=97=E9=87=91=E9=A2=9D/=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=BF=AB=E7=85=A7/requestId=20=E5=B9=82=E7=AD=89/?= =?UTF-8?q?=E5=88=86=E9=A1=B5=20+=20confirm/cancel=20=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../migration.sql | 3 + prisma/schema.prisma | 2 + src/orders/orders.service.ts | 107 ++++++++++++++---- 3 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 prisma/migrations/20260915000000_add_order_request_id/migration.sql diff --git a/prisma/migrations/20260915000000_add_order_request_id/migration.sql b/prisma/migrations/20260915000000_add_order_request_id/migration.sql new file mode 100644 index 0000000..8258314 --- /dev/null +++ b/prisma/migrations/20260915000000_add_order_request_id/migration.sql @@ -0,0 +1,3 @@ +-- 订单幂等键(api-contract-v1 §6:requestId 防重复下单,重复请求返回已创建订单) +ALTER TABLE "Order" ADD COLUMN "requestId" TEXT; +CREATE UNIQUE INDEX "Order_requestId_key" ON "Order"("requestId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba1a358..85bb1e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -145,6 +145,8 @@ model Order { paidAt DateTime? // 关联的设计清单(R4 下单后 WCD 派单据此读取 designData) designListId String? + // 客户端幂等键(api-contract-v1 §6:requestId 重复请求返回已创建订单) + requestId String? @unique user User @relation(fields: [userId], references: [id]) designList DesignList? @relation(fields: [designListId], references: [id]) items OrderItem[] diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index 1482ac3..0f5039a 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -1,12 +1,11 @@ -import { ConflictException, ForbiddenException, Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { OrderStatus, Prisma } from '@prisma/client'; +import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { DesignListStatus, OrderStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateOrderDto } from './dto/create-order.dto'; import { OrderQueryDto } from './dto/order-query.dto'; const PAYMENT_TIMEOUT_MS = 30 * 60 * 1000; -// 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用) function generateOrderNo(): string { const now = new Date(); const ymd = @@ -40,7 +39,13 @@ export class OrdersService { await this.expirePendingOrders({ userId }); const where = { userId, ...(query.status ? { status: query.status } : {}) }; 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 }), ]); 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) { 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); } + const address = await this.prisma.address.findUnique({ where: { id: dto.addressId } }); if (!address) throw new NotFoundException('地址不存在'); 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 (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 total = dto.items.reduce((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 total = dto.items.reduce( + (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); + try { const created = await this.prisma.$transaction(async (tx) => { let orderNo = generateOrderNo(); @@ -79,7 +109,8 @@ export class OrdersService { if (!collision) break; orderNo = generateOrderNo(); } - return tx.order.create({ + + const order = await tx.order.create({ data: { orderNo, user: { connect: { id: userId } }, @@ -88,16 +119,39 @@ export class OrdersService { paymentExpiresAt, addressSnapshot: addressSnapshot as Prisma.InputJsonValue, 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' } }, }, 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); } catch (error) { 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); throw new ConflictException('订单号冲突,请重试'); } @@ -108,13 +162,32 @@ export class OrdersService { async cancel(userId: string, id: string) { const order = await this.getOwned(userId, id); 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) { const order = await this.getOwned(userId, id); 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) { @@ -125,10 +198,6 @@ export class OrdersService { return order; } - /** - * 过期状态由服务端收口,而不是只依赖小程序倒计时。这样用户切后台、换设备或 - * 直接调用支付接口时,订单仍无法绕过 30 分钟限制。 - */ private async expirePendingOrders(where: Prisma.OrderWhereInput) { await this.prisma.order.updateMany({ where: {