From 05584172caec1d14fe227d42c8e1aba10d2a7c4d Mon Sep 17 00:00:00 2001 From: lhmin0604 Date: Tue, 15 Sep 2026 06:06:38 +0800 Subject: [PATCH 1/3] =?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/dto/create-order.dto.ts | 62 ++++-- src/orders/orders.controller.ts | 30 ++- src/orders/orders.service.ts | 198 +++++++++++++++--- 5 files changed, 239 insertions(+), 56 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 94ce1a4..f67ba2c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -129,6 +129,8 @@ model Order { addressSnapshot Json // 关联的设计清单(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/dto/create-order.dto.ts b/src/orders/dto/create-order.dto.ts index 02f9a0d..0694c0a 100644 --- a/src/orders/dto/create-order.dto.ts +++ b/src/orders/dto/create-order.dto.ts @@ -1,31 +1,35 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + IsArray, + IsIn, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; +import { OrderStatus } from '@prisma/client'; export class OrderItemDto { - @ApiPropertyOptional({ description: '商品 id(定制类可空)' }) - @IsOptional() - @IsString() - productId?: string; - - @ApiProperty() + @ApiProperty({ description: '商品 id(服务端按真实价格重算金额)' }) @IsString() @IsNotEmpty() - name!: string; + productId!: string; - @ApiProperty({ description: '单价(元)' }) - @Type(() => Number) - price!: number; - - @ApiProperty({ description: '数量' }) + @ApiProperty({ description: '数量', minimum: 1, maximum: 999 }) @Type(() => Number) + @Min(1) + @Max(999) quantity!: number; } export class CreateOrderDto { - @ApiProperty({ description: '收货地址快照(JSON)' }) - @IsObject() - addressSnapshot!: Record; + @ApiProperty({ description: '收货地址 id(服务端读取并生成快照)' }) + @IsString() + @IsNotEmpty() + addressId!: string; @ApiProperty({ type: [OrderItemDto] }) @IsArray() @@ -37,4 +41,30 @@ export class CreateOrderDto { @IsOptional() @IsString() designListId?: string; + + @ApiPropertyOptional({ description: '幂等键:重复请求返回已创建订单(契约 §6)' }) + @IsOptional() + @IsString() + requestId?: string; +} + +/** GET /api/orders 查询参数(契约 §6:status 可选、page、pageSize) */ +export class ListOrdersDto { + @ApiPropertyOptional({ enum: Object.values(OrderStatus), description: '按状态筛选' }) + @IsOptional() + @IsIn(Object.values(OrderStatus)) + status?: OrderStatus; + + @ApiPropertyOptional({ description: '页码,从 1 起', minimum: 1 }) + @IsOptional() + @Type(() => Number) + @Min(1) + page?: number; + + @ApiPropertyOptional({ description: '每页条数,上限 100', minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @Min(1) + @Max(100) + pageSize?: number; } diff --git a/src/orders/orders.controller.ts b/src/orders/orders.controller.ts index 7f9466a..bc4c907 100644 --- a/src/orders/orders.controller.ts +++ b/src/orders/orders.controller.ts @@ -1,8 +1,8 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator'; import { OrdersService } from './orders.service'; -import { CreateOrderDto } from './dto/create-order.dto'; +import { CreateOrderDto, ListOrdersDto } from './dto/create-order.dto'; @ApiTags('订单') @ApiBearerAuth() @@ -11,20 +11,32 @@ export class OrdersController { constructor(private readonly ordersService: OrdersService) {} @Get() - @ApiOperation({ summary: '我的订单列表' }) - list(@CurrentUser() user: JwtPayload) { - return this.ordersService.listByUser(user.sub); + @ApiOperation({ summary: '我的订单列表(分页 + 状态筛选)' }) + list(@CurrentUser() user: JwtPayload, @Query() query: ListOrdersDto) { + return this.ordersService.listByUser(user.sub, query); } @Get(':id') - @ApiOperation({ summary: '订单详情' }) - findOne(@Param('id') id: string) { - return this.ordersService.findOne(id); + @ApiOperation({ summary: '订单详情(仅本人,越权 403 / 不存在 404)' }) + findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.ordersService.findOne(user.sub, id); } @Post() - @ApiOperation({ summary: '创建订单' }) + @ApiOperation({ summary: '创建订单(服务端重算金额,requestId 幂等)' }) create(@CurrentUser() user: JwtPayload, @Body() dto: CreateOrderDto) { return this.ordersService.create(user.sub, dto); } + + @Patch(':id/confirm') + @ApiOperation({ summary: '确认收货(SHIPPED → COMPLETED)' }) + confirm(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.ordersService.confirm(user.sub, id); + } + + @Post(':id/cancel') + @ApiOperation({ summary: '取消订单(仅 PENDING → CANCELLED)' }) + cancel(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.ordersService.cancel(user.sub, id); + } } diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index cd256a5..e7da7bd 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { DesignListStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; -import { CreateOrderDto } from './dto/create-order.dto'; +import { CreateOrderDto, ListOrdersDto } from './dto/create-order.dto'; // 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用) function generateOrderNo(): string { @@ -14,47 +14,183 @@ function generateOrderNo(): string { return `${ymd}${rand}`; } +type OrderWithRelations = Prisma.OrderGetPayload<{ include: { items: true; payment: true } }>; + @Injectable() export class OrdersService { constructor(private readonly prisma: PrismaService) {} - async listByUser(userId: string) { - return this.prisma.order.findMany({ - where: { userId }, - include: { items: true, payment: true }, - orderBy: { createdAt: 'desc' }, - }); + /** Decimal → number(契约:totalAmount/price 是数字;Prisma Decimal 默认 JSON 序列化为字符串) */ + private toClientOrder(order: OrderWithRelations) { + return { + ...order, + totalAmount: Number(order.totalAmount), + items: (order.items || []).map((it) => ({ ...it, price: Number(it.price) })), + }; } - async findOne(id: string) { - // TODO: 权限校验(仅本人) - return this.prisma.order.findUnique({ + private async getOwned(userId: string, id: string, includeRelations = false) { + const order = await this.prisma.order.findUnique({ where: { id }, - include: { items: true, payment: true }, + include: includeRelations ? { items: true, payment: true } : undefined, }); + if (!order) throw new NotFoundException('订单不存在'); + if (order.userId !== userId) throw new ForbiddenException('无权操作该订单'); + return order; + } + + /** 我的订单列表:分页 + 状态筛选(契约 §6 GET /api/orders) */ + async listByUser(userId: string, query: ListOrdersDto) { + const page = Math.max(1, query.page || 1); + const pageSize = Math.min(100, Math.max(1, query.pageSize || 20)); + const where: Prisma.OrderWhereInput = { + userId, + ...(query.status ? { status: query.status } : {}), + }; + const [list, total] = await this.prisma.$transaction([ + this.prisma.order.findMany({ + where, + include: { items: true, payment: true }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + this.prisma.order.count({ where }), + ]); + return { list: list.map((o) => this.toClientOrder(o)), total, page, pageSize }; + } + + async findOne(userId: string, id: string) { + // 越权 403 / 不存在 404(契约 §6) + const order = await this.getOwned(userId, id, true); + return this.toClientOrder(order); } async create(userId: string, dto: CreateOrderDto) { - // TODO: 商品库存/价格校验、事务原子性、金额防篡改(服务端重算 totalAmount) - const totalAmount = dto.items.reduce((sum, it) => sum + it.price * it.quantity, 0); + // 幂等键(契约 §6):重复请求返回已创建订单,不产生第二单 + if (dto.requestId) { + const existing = await this.prisma.order.findUnique({ + where: { requestId: dto.requestId }, + include: { items: true, payment: true }, + }); + if (existing && existing.userId === userId) return this.toClientOrder(existing); + } - const data: Prisma.OrderCreateInput = { - orderNo: generateOrderNo(), - user: { connect: { id: userId } }, - totalAmount, - addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue, - // 关联设计清单(R4 下单后 WCD 派单读取 designData;R3 契约 CreateOrderDto 已含该字段) - designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined, - items: { - create: dto.items.map((it) => ({ - productId: it.productId, - name: it.name, - price: it.price, - quantity: it.quantity, - })), - }, + // 金额防篡改:按 Product 真实价格重算 totalAmount,忽略客户端金额(契约 §6) + const productIds = [...new Set(dto.items.map((it) => it.productId))]; + const products = await this.prisma.product.findMany({ + where: { id: { in: productIds }, status: 'ON_SALE' }, + }); + if (products.length !== productIds.length) { + throw new BadRequestException('存在无效或已下架的商品'); + } + const priceMap = new Map(products.map((p) => [p.id, p])); + const totalAmount = dto.items.reduce( + (sum, it) => sum + Number(priceMap.get(it.productId)!.price) * it.quantity, + 0, + ); + + // 收货地址同步快照:仅本人地址,订单创建后不受地址变更影响(契约 §6) + const address = await this.prisma.address.findFirst({ where: { id: dto.addressId, userId } }); + if (!address) throw new NotFoundException('收货地址不存在'); + const addressSnapshot = { + id: address.id, + name: address.name, + phone: address.phone, + province: address.province, + city: address.city, + district: address.district, + detail: address.detail, }; - return this.prisma.order.create({ data, include: { items: true } }); + // 关联设计清单:仅本人(R4 派单据此读取 designData) + let designListConnect: Prisma.OrderCreateInput['designList']; + if (dto.designListId) { + const design = await this.prisma.designList.findFirst({ + where: { id: dto.designListId, userId }, + }); + if (!design) throw new NotFoundException('设计清单不存在'); + designListConnect = { connect: { id: design.id } }; + } + + try { + const order = await this.prisma.$transaction(async (tx) => { + const created = await tx.order.create({ + data: { + orderNo: generateOrderNo(), + user: { connect: { id: userId } }, + totalAmount, + addressSnapshot: addressSnapshot as Prisma.InputJsonValue, + designList: designListConnect, + ...(dto.requestId ? { requestId: dto.requestId } : {}), + items: { + create: dto.items.map((it) => ({ + productId: it.productId, + name: String(priceMap.get(it.productId)!.name), + price: priceMap.get(it.productId)!.price, + quantity: it.quantity, + })), + }, + }, + include: { items: true }, + }); + if (dto.designListId) { + // 清单状态由 R3 订单驱动(R2 交接文档):下单即进入生产。 + // 状态机单向(DRAFT→SUBMITTED→PROCESSING→DONE),updateMany 条件推进、绝不回退; + // 订单取消不回退清单状态(DONE 前重下不影响,见 R2 README 给 R3 的注意事项) + await tx.designList.updateMany({ + where: { + id: dto.designListId, + status: { in: [DesignListStatus.DRAFT, DesignListStatus.SUBMITTED] }, + }, + data: { status: DesignListStatus.PROCESSING }, + }); + } + return created; + }); + return this.toClientOrder({ ...order, payment: null } as unknown as OrderWithRelations); + } catch (e) { + // 并发下同 requestId 的第二笔请求撞唯一约束:返回已创建订单 + if ( + dto.requestId && + e instanceof Prisma.PrismaClientKnownRequestError && + e.code === 'P2002' + ) { + const existing = await this.prisma.order.findUnique({ + where: { requestId: dto.requestId }, + include: { items: true, payment: true }, + }); + if (existing) return this.toClientOrder(existing); + } + throw e; + } + } + + /** 确认收货:SHIPPED → COMPLETED(契约 §6) */ + async confirm(userId: string, id: string) { + const order = await this.getOwned(userId, id); + if (order.status !== 'SHIPPED') { + throw new BadRequestException(`仅已发货订单可确认收货,当前状态:${order.status}`); + } + const updated = await this.prisma.order.update({ + where: { id }, + data: { status: 'COMPLETED' }, + include: { items: true, payment: true }, + }); + return this.toClientOrder(updated); + } + + /** 取消订单:仅 PENDING 可取消,PENDING → CANCELLED(契约 §6) */ + async cancel(userId: string, id: string) { + const order = await this.getOwned(userId, id); + if (order.status !== 'PENDING') { + throw new BadRequestException(`仅待支付订单可取消,当前状态:${order.status}`); + } + const updated = await this.prisma.order.update({ + where: { id }, + data: { status: 'CANCELLED' }, + include: { items: true, payment: true }, + }); + return this.toClientOrder(updated); } } From c48e79802ca04fcf758214ae9b64a3f49d5c93e9 Mon Sep 17 00:00:00 2001 From: lhmin0604 Date: Tue, 15 Sep 2026 06:08:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(orders):=20getOwned=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=B8=A6=20relations=EF=BC=8C=E4=BF=AE=E5=A4=8D=20inc?= =?UTF-8?q?lude=20=E5=88=86=E6=94=AF=E7=9A=84=E7=B1=BB=E5=9E=8B=E9=94=99?= =?UTF-8?q?=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- src/orders/orders.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index e7da7bd..733cda6 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -29,10 +29,10 @@ export class OrdersService { }; } - private async getOwned(userId: string, id: string, includeRelations = false) { + private async getOwned(userId: string, id: string) { const order = await this.prisma.order.findUnique({ where: { id }, - include: includeRelations ? { items: true, payment: true } : undefined, + include: { items: true, payment: true }, }); if (!order) throw new NotFoundException('订单不存在'); if (order.userId !== userId) throw new ForbiddenException('无权操作该订单'); @@ -62,7 +62,7 @@ export class OrdersService { async findOne(userId: string, id: string) { // 越权 403 / 不存在 404(契约 §6) - const order = await this.getOwned(userId, id, true); + const order = await this.getOwned(userId, id); return this.toClientOrder(order); } From 17e4e7f56075b800923301a64eccc548d7b7169d Mon Sep 17 00:00:00 2001 From: lhmin0604 Date: Tue, 15 Sep 2026 08:15:10 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(contract):=20=C2=A75=20=E8=A1=A5?= =?UTF-8?q?=E8=AE=B0=20GET=20/api/design-list/:id=EF=BC=88=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=B7=B2=E5=AE=9E=E7=8E=B0=EF=BC=8CR3=20fetchDesign?= =?UTF-8?q?=20=E6=B6=88=E8=B4=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/api-contract-v1.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/api-contract-v1.md b/docs/api-contract-v1.md index 1b997ff..3fbccc3 100644 --- a/docs/api-contract-v1.md +++ b/docs/api-contract-v1.md @@ -149,6 +149,10 @@ 返回当前用户设计清单,按 `createdAt` 倒序。 +### GET /api/design-list/:id + +返回单条设计清单(后端已实现 `@Get(':id')`;R3 结算页 `fetchDesign` 消费)。仅本人可查,越权 403 / 不存在 404。 + ### POST /api/design-list 请求体: