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); } }