Compare commits
3
Commits
2445aef666
...
17e4e7f560
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17e4e7f560 | ||
|
|
c48e79802c | ||
|
|
05584172ca |
@@ -149,6 +149,10 @@
|
|||||||
|
|
||||||
返回当前用户设计清单,按 `createdAt` 倒序。
|
返回当前用户设计清单,按 `createdAt` 倒序。
|
||||||
|
|
||||||
|
### GET /api/design-list/:id
|
||||||
|
|
||||||
|
返回单条设计清单(后端已实现 `@Get(':id')`;R3 结算页 `fetchDesign` 消费)。仅本人可查,越权 403 / 不存在 404。
|
||||||
|
|
||||||
### POST /api/design-list
|
### POST /api/design-list
|
||||||
|
|
||||||
请求体:
|
请求体:
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -129,6 +129,8 @@ model Order {
|
|||||||
addressSnapshot Json
|
addressSnapshot Json
|
||||||
// 关联的设计清单(R4 下单后 WCD 派单据此读取 designData)
|
// 关联的设计清单(R4 下单后 WCD 派单据此读取 designData)
|
||||||
designListId String?
|
designListId String?
|
||||||
|
// 客户端幂等键(api-contract-v1 §6:requestId 重复请求返回已创建订单)
|
||||||
|
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[]
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
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 { Type } from 'class-transformer';
|
||||||
|
import { OrderStatus } from '@prisma/client';
|
||||||
|
|
||||||
export class OrderItemDto {
|
export class OrderItemDto {
|
||||||
@ApiPropertyOptional({ description: '商品 id(定制类可空)' })
|
@ApiProperty({ description: '商品 id(服务端按真实价格重算金额)' })
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
productId?: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
name!: string;
|
productId!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '单价(元)' })
|
@ApiProperty({ description: '数量', minimum: 1, maximum: 999 })
|
||||||
@Type(() => Number)
|
|
||||||
price!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: '数量' })
|
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
@Min(1)
|
||||||
|
@Max(999)
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateOrderDto {
|
export class CreateOrderDto {
|
||||||
@ApiProperty({ description: '收货地址快照(JSON)' })
|
@ApiProperty({ description: '收货地址 id(服务端读取并生成快照)' })
|
||||||
@IsObject()
|
@IsString()
|
||||||
addressSnapshot!: Record<string, unknown>;
|
@IsNotEmpty()
|
||||||
|
addressId!: string;
|
||||||
|
|
||||||
@ApiProperty({ type: [OrderItemDto] })
|
@ApiProperty({ type: [OrderItemDto] })
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@@ -37,4 +41,30 @@ export class CreateOrderDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
designListId?: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
|
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
|
||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
import { CreateOrderDto } from './dto/create-order.dto';
|
import { CreateOrderDto, ListOrdersDto } from './dto/create-order.dto';
|
||||||
|
|
||||||
@ApiTags('订单')
|
@ApiTags('订单')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -11,20 +11,32 @@ export class OrdersController {
|
|||||||
constructor(private readonly ordersService: OrdersService) {}
|
constructor(private readonly ordersService: OrdersService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '我的订单列表' })
|
@ApiOperation({ summary: '我的订单列表(分页 + 状态筛选)' })
|
||||||
list(@CurrentUser() user: JwtPayload) {
|
list(@CurrentUser() user: JwtPayload, @Query() query: ListOrdersDto) {
|
||||||
return this.ordersService.listByUser(user.sub);
|
return this.ordersService.listByUser(user.sub, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: '订单详情' })
|
@ApiOperation({ summary: '订单详情(仅本人,越权 403 / 不存在 404)' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
|
||||||
return this.ordersService.findOne(id);
|
return this.ordersService.findOne(user.sub, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: '创建订单' })
|
@ApiOperation({ summary: '创建订单(服务端重算金额,requestId 幂等)' })
|
||||||
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateOrderDto) {
|
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateOrderDto) {
|
||||||
return this.ordersService.create(user.sub, dto);
|
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
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { DesignListStatus, 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, ListOrdersDto } from './dto/create-order.dto';
|
||||||
|
|
||||||
// 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用)
|
// 订单号生成:日期 + 随机后缀(无 Math.random 依赖要求仅限 workflow 脚本,普通运行时可用)
|
||||||
function generateOrderNo(): string {
|
function generateOrderNo(): string {
|
||||||
@@ -14,47 +14,183 @@ function generateOrderNo(): string {
|
|||||||
return `${ymd}${rand}`;
|
return `${ymd}${rand}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OrderWithRelations = Prisma.OrderGetPayload<{ include: { items: true; payment: true } }>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrdersService {
|
export class OrdersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async listByUser(userId: string) {
|
/** Decimal → number(契约:totalAmount/price 是数字;Prisma Decimal 默认 JSON 序列化为字符串) */
|
||||||
return this.prisma.order.findMany({
|
private toClientOrder(order: OrderWithRelations) {
|
||||||
where: { userId },
|
return {
|
||||||
include: { items: true, payment: true },
|
...order,
|
||||||
orderBy: { createdAt: 'desc' },
|
totalAmount: Number(order.totalAmount),
|
||||||
});
|
items: (order.items || []).map((it) => ({ ...it, price: Number(it.price) })),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
private async getOwned(userId: string, id: string) {
|
||||||
// TODO: 权限校验(仅本人)
|
const order = await this.prisma.order.findUnique({
|
||||||
return this.prisma.order.findUnique({
|
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { items: true, payment: true },
|
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) {
|
async create(userId: string, dto: CreateOrderDto) {
|
||||||
// TODO: 商品库存/价格校验、事务原子性、金额防篡改(服务端重算 totalAmount)
|
// 幂等键(契约 §6):重复请求返回已创建订单,不产生第二单
|
||||||
const totalAmount = dto.items.reduce((sum, it) => sum + it.price * it.quantity, 0);
|
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(),
|
orderNo: generateOrderNo(),
|
||||||
user: { connect: { id: userId } },
|
user: { connect: { id: userId } },
|
||||||
totalAmount,
|
totalAmount,
|
||||||
addressSnapshot: dto.addressSnapshot as Prisma.InputJsonValue,
|
addressSnapshot: addressSnapshot as Prisma.InputJsonValue,
|
||||||
// 关联设计清单(R4 下单后 WCD 派单读取 designData;R3 契约 CreateOrderDto 已含该字段)
|
designList: designListConnect,
|
||||||
designList: dto.designListId ? { connect: { id: dto.designListId } } : undefined,
|
...(dto.requestId ? { requestId: dto.requestId } : {}),
|
||||||
items: {
|
items: {
|
||||||
create: dto.items.map((it) => ({
|
create: dto.items.map((it) => ({
|
||||||
productId: it.productId,
|
productId: it.productId,
|
||||||
name: it.name,
|
name: String(priceMap.get(it.productId)!.name),
|
||||||
price: it.price,
|
price: priceMap.get(it.productId)!.price,
|
||||||
quantity: it.quantity,
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user