Compare commits

...
3 Commits
Author SHA1 Message Date
lhmin0604andClaude 17e4e7f560 docs(contract): §5 补记 GET /api/design-list/:id(后端已实现,R3 fetchDesign 消费)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-15 08:15:10 +08:00
lhmin0604andClaude c48e79802c fix(orders): getOwned 统一带 relations,修复 include 分支的类型错误
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-15 06:08:14 +08:00
lhmin0604andClaude 05584172ca 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>
2026-09-15 06:06:38 +08:00
6 changed files with 242 additions and 55 deletions
+4
View File
@@ -149,6 +149,10 @@
返回当前用户设计清单,按 `createdAt` 倒序。
### GET /api/design-list/:id
返回单条设计清单(后端已实现 `@Get(':id')`R3 结算页 `fetchDesign` 消费)。仅本人可查,越权 403 / 不存在 404。
### POST /api/design-list
请求体:
@@ -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
@@ -129,6 +129,8 @@ model Order {
addressSnapshot Json
// 关联的设计清单(R4 下单后 WCD 派单据此读取 designData
designListId String?
// 客户端幂等键(api-contract-v1 §6requestId 重复请求返回已创建订单)
requestId String? @unique
user User @relation(fields: [userId], references: [id])
designList DesignList? @relation(fields: [designListId], references: [id])
items OrderItem[]
+46 -16
View File
@@ -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<string, unknown>;
@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 查询参数(契约 §6status 可选、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;
}
+21 -9
View File
@@ -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);
}
}
+158 -22
View File
@@ -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) {
const order = await this.prisma.order.findUnique({
where: { id },
include: { items: true, payment: true },
});
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);
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 = {
// 金额防篡改:按 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,
};
// 关联设计清单:仅本人(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: dto.addressSnapshot as Prisma.InputJsonValue,
// 关联设计清单(R4 下单后 WCD 派单读取 designDataR3 契约 CreateOrderDto 已含该字段)
designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined,
addressSnapshot: addressSnapshot as Prisma.InputJsonValue,
designList: designListConnect,
...(dto.requestId ? { requestId: dto.requestId } : {}),
items: {
create: dto.items.map((it) => ({
productId: it.productId,
name: it.name,
price: it.price,
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;
}
}
return this.prisma.order.create({ data, include: { items: true } });
/** 确认收货: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);
}
}