feat(r3): complete order and payment flow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 派单读取 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,
|
||||
})),
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user