feat(r3): complete order and payment flow

This commit is contained in:
2026-09-13 13:56:59 +08:00
parent 63c0013076
commit 16e625aefb
20 changed files with 454 additions and 126 deletions
+16 -15
View File
@@ -1,31 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class OrderItemDto {
@ApiPropertyOptional({ description: '商品 id(定制类可空)' })
@IsOptional()
@IsString()
productId?: string;
@ApiProperty()
@ApiProperty({ description: '商品 id' })
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty({ description: '单价(元)' })
@Type(() => Number)
price!: number;
productId!: string;
@ApiProperty({ description: '数量' })
@Type(() => Number)
@IsInt()
@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 +32,10 @@ export class CreateOrderDto {
@IsOptional()
@IsString()
designListId?: string;
@ApiPropertyOptional({ description: '客户端幂等键' })
@IsOptional()
@IsString()
@IsNotEmpty()
requestId?: string;
}
+11
View File
@@ -0,0 +1,11 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { OrderStatus } from '@prisma/client';
import { PaginationDto } from '../../common/dto/pagination.dto';
export class OrderQueryDto extends PaginationDto {
@ApiPropertyOptional({ enum: OrderStatus })
@IsOptional()
@IsEnum(OrderStatus)
status?: OrderStatus;
}
+18 -5
View File
@@ -1,8 +1,9 @@
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 { OrderQueryDto } from './dto/order-query.dto';
@ApiTags('订单')
@ApiBearerAuth()
@@ -12,14 +13,14 @@ export class OrdersController {
@Get()
@ApiOperation({ summary: '我的订单列表' })
list(@CurrentUser() user: JwtPayload) {
return this.ordersService.listByUser(user.sub);
list(@CurrentUser() user: JwtPayload, @Query() query: OrderQueryDto) {
return this.ordersService.listByUser(user.sub, query);
}
@Get(':id')
@ApiOperation({ summary: '订单详情' })
findOne(@Param('id') id: string) {
return this.ordersService.findOne(id);
findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.ordersService.findOne(user.sub, id);
}
@Post()
@@ -27,4 +28,16 @@ export class OrdersController {
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' })
cancel(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.ordersService.cancel(user.sub, id);
}
}
+115 -33
View File
@@ -1,7 +1,10 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ConflictException, ForbiddenException, Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { 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 {
@@ -18,43 +21,122 @@ function generateOrderNo(): string {
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' },
});
private serialize(order: any) {
return {
...order,
totalAmount: Number(order.totalAmount),
paidAt: order.paidAt?.toISOString?.() ?? null,
paymentExpiresAt: order.paymentExpiresAt?.toISOString?.() ?? null,
createdAt: order.createdAt.toISOString(),
updatedAt: order.updatedAt.toISOString(),
items: order.items?.map((item: any) => ({
...item,
price: Number(item.price),
})),
};
}
async findOne(id: string) {
// TODO: 权限校验(仅本人)
return this.prisma.order.findUnique({
where: { id },
include: { items: true, payment: true },
});
async listByUser(userId: string, query: OrderQueryDto) {
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.count({ where }),
]);
return { list: rows.map((row) => this.serialize(row)), total, page: query.page, pageSize: query.pageSize };
}
async findOne(userId: string, id: string) {
await this.expirePendingOrders({ id, userId });
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 this.serialize(order);
}
async create(userId: string, dto: CreateOrderDto) {
// TODO: 商品库存/价格校验、事务原子性、金额防篡改(服务端重算 totalAmount
const totalAmount = dto.items.reduce((sum, it) => sum + it.price * it.quantity, 0);
if (dto.requestId) {
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;
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 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 paymentExpiresAt = new Date(Date.now() + PAYMENT_TIMEOUT_MS);
try {
const created = await this.prisma.$transaction(async (tx) => {
let orderNo = generateOrderNo();
for (let attempt = 0; attempt < 3; attempt += 1) {
const collision = await tx.order.findUnique({ where: { orderNo }, select: { id: true } });
if (!collision) break;
orderNo = generateOrderNo();
}
return tx.order.create({
data: {
orderNo,
user: { connect: { id: userId } },
requestId: dto.requestId,
totalAmount: total,
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 })) },
payment: { create: { status: 'PENDING' } },
},
include: { items: true, payment: true },
});
});
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;
if (existing) return this.serialize(existing);
throw new ConflictException('订单号冲突,请重试');
}
throw error;
}
}
const data: Prisma.OrderCreateInput = {
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,
items: {
create: dto.items.map((it) => ({
productId: it.productId,
name: it.name,
price: it.price,
quantity: it.quantity,
})),
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 } }));
}
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 } }));
}
private async getOwned(userId: string, id: string) {
await this.expirePendingOrders({ id, userId });
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;
}
/**
* 过期状态由服务端收口,而不是只依赖小程序倒计时。这样用户切后台、换设备或
* 直接调用支付接口时,订单仍无法绕过 30 分钟限制。
*/
private async expirePendingOrders(where: Prisma.OrderWhereInput) {
await this.prisma.order.updateMany({
where: {
...where,
status: OrderStatus.PENDING,
paymentExpiresAt: { lte: new Date() },
},
};
return this.prisma.order.create({ data, include: { items: true } });
data: { status: OrderStatus.PAYMENT_EXPIRED },
});
}
}