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
+11 -2
View File
@@ -14,10 +14,19 @@ export class AuthController {
@ApiOperation({ summary: '登录:wx.login code 换 openid,自动注册/续登并签发 token' })
@ApiOkResponse({
schema: {
example: { code: 0, message: 'ok', data: { accessToken: '...' } },
example: {
code: 0,
message: 'ok',
data: { accessToken: '...', isNewUser: true, nickname: null, avatar: null },
},
},
})
async login(@Body() dto: LoginDto): Promise<{ accessToken: string }> {
async login(@Body() dto: LoginDto): Promise<{
accessToken: string;
isNewUser: boolean;
nickname: string | null;
avatar: string | null;
}> {
return this.authService.login(dto);
}
+14 -3
View File
@@ -34,10 +34,16 @@ export class AuthService {
* 说明:个人主体小程序无 getPhoneNumber 权限,故不强制手机号注册。
* 手机号采集留作未来换企业主体后可选补充(见 register())。
*/
async login(dto: LoginDto): Promise<{ accessToken: string }> {
async login(dto: LoginDto): Promise<{
accessToken: string;
isNewUser: boolean;
nickname: string | null;
avatar: string | null;
}> {
const session = await this.wechat.code2Session(dto.code);
// upsert:openid 在库则续登,不在库则直接建用户(无需手机号)
// 先查一次以判断是否为首次登录;随后仍以 upsert 防止并发登录重复建用户。
const existing = await this.users.findByOpenid(session.openid);
const user = await this.users.findOrCreateByOpenid(
session.openid,
session.unionid,
@@ -52,7 +58,12 @@ export class AuthService {
);
const accessToken = await this.signToken(user.id, user.openid);
return { accessToken };
return {
accessToken,
isNewUser: !existing,
nickname: user.nickname,
avatar: user.avatar,
};
}
/**
+8 -3
View File
@@ -33,15 +33,20 @@ export class DesignListService {
constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) {
return this.prisma.designList.findMany({
const rows = await this.prisma.designList.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
include: { orders: { select: { id: true }, orderBy: { createdAt: 'desc' }, take: 1 } },
});
return rows.map(({ orders, ...row }) => ({ ...row, orderId: orders[0]?.id ?? null }));
}
async findOne(userId: string, id: string) {
const list = await this.getOwnedList(userId, id);
return list;
await this.getOwnedList(userId, id);
const linked = await this.prisma.designList.findUnique({ where: { id }, include: { orders: { select: { id: true }, orderBy: { createdAt: 'desc' }, take: 1 } } });
if (!linked) throw new NotFoundException('设计清单不存在');
const { orders, ...row } = linked;
return { ...row, orderId: orders[0]?.id ?? null };
}
async create(userId: string, dto: CreateDesignListDto) {
+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 },
});
}
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { Body, Controller, Param, Post, Req } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { PaymentsService } from './payments.service';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
@ApiTags('支付')
@ApiBearerAuth()
@@ -11,8 +12,8 @@ export class PaymentsController {
@Post(':orderId/pay')
@ApiOperation({ summary: '对指定订单发起支付(占位)' })
pay(@Param('orderId') orderId: string) {
return this.paymentsService.createPayment(orderId);
pay(@CurrentUser() user: JwtPayload, @Param('orderId') orderId: string) {
return this.paymentsService.createPayment(user.sub, orderId);
}
@Public()
+26 -7
View File
@@ -1,4 +1,6 @@
import { Injectable } from '@nestjs/common';
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OrderStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { WechatService } from '../wechat/wechat.service';
@@ -7,15 +9,32 @@ export class PaymentsService {
constructor(
private readonly prisma: PrismaService,
private readonly wechat: WechatService,
private readonly config: ConfigService,
) {}
/** 发起支付:创建支付记录并调微信统一下单(本轮占位) */
async createPayment(orderId: string) {
// TODO: 查订单、校验状态/金额、创建 Payment 记录、调 wechat.createUnifiedOrder
void this.wechat; // 占位引用,避免未使用告警
return this.prisma.payment.create({
data: { order: { connect: { id: orderId } } },
});
async createPayment(userId: string, orderId: string) {
const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { payment: true } });
if (!order) throw new NotFoundException('订单不存在');
if (order.userId !== userId) throw new ForbiddenException('无权支付该订单');
if (order.status === OrderStatus.PENDING && order.paymentExpiresAt && order.paymentExpiresAt <= new Date()) {
await this.prisma.order.updateMany({
where: { id: order.id, userId, status: OrderStatus.PENDING },
data: { status: OrderStatus.PAYMENT_EXPIRED },
});
throw new ConflictException('订单超时未支付');
}
if (order.status === OrderStatus.PAYMENT_EXPIRED) throw new ConflictException('订单超时未支付');
if (order.status !== OrderStatus.PENDING) throw new ConflictException('当前订单不可支付');
const configured = Boolean(
this.config.get<string>('wx.mchId') &&
this.config.get<string>('wx.mchApiV3Key') &&
this.config.get<string>('wx.mchSerialNo') &&
this.config.get<string>('wx.mchPrivateKeyPath'),
);
if (!configured) return { configured: false, message: '支付未配置' };
void this.wechat;
return { configured: true, message: '支付服务已配置但统一下单尚未接入' };
}
/** 微信支付回调入口(本轮占位) */
+55 -1
View File
@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { IsArray, IsEnum, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator';
import { ProductStatus } from '@prisma/client';
export class CreateProductDto {
@@ -19,6 +19,60 @@ export class CreateProductDto {
@Min(0)
price!: number;
@ApiPropertyOptional({ description: '划线原价(元)' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
originalPrice?: number;
@ApiPropertyOptional({ default: '7-10 个工作日' })
@IsOptional()
@IsString()
leadTime?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
subtitle?: string;
@ApiPropertyOptional({ type: [Number] })
@IsOptional()
@IsArray()
@IsNumber({}, { each: true })
tone?: number[];
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({ type: Object })
@IsOptional()
@IsObject()
specs?: Record<string, unknown>;
@ApiPropertyOptional({ type: Object })
@IsOptional()
@IsObject()
mask?: Record<string, unknown>;
@ApiPropertyOptional()
@IsOptional()
@IsString()
story?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
scene?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
iconImg?: string;
@ApiPropertyOptional({ description: '图片 URL 列表', type: [String] })
@IsOptional()
@IsArray()
+15
View File
@@ -0,0 +1,15 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { PaginationDto } from '../../common/dto/pagination.dto';
export class ProductQueryDto extends PaginationDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
categoryId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
keyword?: string;
}
+4 -3
View File
@@ -1,8 +1,9 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { ProductQueryDto } from './dto/product-query.dto';
@ApiTags('商品')
@ApiBearerAuth()
@@ -13,8 +14,8 @@ export class ProductsController {
@Public()
@Get()
@ApiOperation({ summary: '在售商品列表' })
list() {
return this.productsService.list();
list(@Query() query: ProductQueryDto) {
return this.productsService.list(query);
}
@Public()
+65 -12
View File
@@ -1,27 +1,80 @@
import { Injectable } from '@nestjs/common';
import { ProductStatus } from '@prisma/client';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Product, ProductStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto } from './dto/create-product.dto';
import { ProductQueryDto } from './dto/product-query.dto';
type ProductResponse = Omit<Product, 'price' | 'originalPrice'> & {
price: number;
originalPrice?: number;
};
function serialize(product: Product): ProductResponse {
return {
...product,
price: Number(product.price),
originalPrice: product.originalPrice == null ? undefined : Number(product.originalPrice),
};
}
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
async list() {
// TODO: 分页、按分类/状态筛选、价格区间
return this.prisma.product.findMany({
where: { status: ProductStatus.ON_SALE },
orderBy: { createdAt: 'desc' },
});
async list(query: ProductQueryDto) {
const where = {
status: ProductStatus.ON_SALE,
...(query.categoryId ? { categoryId: query.categoryId } : {}),
...(query.keyword
? {
OR: [
{ name: { contains: query.keyword, mode: 'insensitive' as const } },
{ description: { contains: query.keyword, mode: 'insensitive' as const } },
],
}
: {}),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.product.findMany({
where,
orderBy: [{ sort: 'asc' }, { createdAt: 'desc' }],
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
this.prisma.product.count({ where }),
]);
return { list: rows.map(serialize), total, page: query.page, pageSize: query.pageSize };
}
async findOne(id: string) {
// TODO: 404 处理、浏览量统计
return this.prisma.product.findUnique({ where: { id } });
const product = await this.prisma.product.findFirst({
where: { id, status: ProductStatus.ON_SALE },
});
if (!product) throw new NotFoundException('商品不存在');
return serialize(product);
}
async create(dto: CreateProductDto) {
// TODO: 校验 categoryId 存在、图片归属
return this.prisma.product.create({ data: dto });
const product = await this.prisma.product.create({
data: {
name: dto.name,
categoryId: dto.categoryId,
price: dto.price,
originalPrice: dto.originalPrice,
leadTime: dto.leadTime,
subtitle: dto.subtitle,
tone: dto.tone ?? [],
tags: dto.tags ?? [],
specs: dto.specs as any,
mask: dto.mask as any,
story: dto.story,
scene: dto.scene,
iconImg: dto.iconImg,
images: dto.images ?? [],
description: dto.description,
status: dto.status,
},
});
return serialize(product);
}
}