四端上传

This commit is contained in:
tianmo committed 2026-09-29 15:25:40 +08:00
1 parent 25bbb5f9c8
commit c6e2cb4d5b
276 files changed
+17812 -3

No files matched your search

@@ -0,0 +1,33 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { z } from 'zod';
import { CurrentUser, type AuthPrincipal } from '../../common/auth';
import { AppError } from '../../common/errors';
import { OrdersService } from './orders.service';
const createSchema = z.object({
courseCode: z.string().min(1),
quantity: z.coerce.number().int().min(1).max(10).optional(),
});
@Controller('v1/orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
create(@CurrentUser() user: AuthPrincipal, @Body() body: unknown) {
const parsed = createSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误:缺少 courseCode');
return this.orders.create(user.id, parsed.data.courseCode, parsed.data.quantity ?? 1);
}
@Get()
list(@CurrentUser() user: AuthPrincipal) {
return this.orders.list(user.id);
}
@Get(':outTradeNo')
status(@CurrentUser() user: AuthPrincipal, @Param('outTradeNo') outTradeNo: string) {
return this.orders.status(user.id, outTradeNo);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PaymentsModule } from '../payments/payments.module';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
imports: [PaymentsModule],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}
@@ -0,0 +1,146 @@
import { randomInt } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { AppError, ErrorCode } from '../../common/errors';
import { PrismaService } from '../../prisma/prisma.service';
import { OrderStatus, PaymentsService } from '../payments/payments.service';
import { WxPayService } from '../payments/wxpay.service';
export interface PayData {
signData: string;
mode: 'short_series_goods';
paySig: string;
signature: string;
}
@Injectable()
export class OrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly wxpay: WxPayService,
private readonly payments: PaymentsService,
) {}
/**
* 下单并返回支付参数。
*
* 关键点:
* - out_trade_no 每次重新生成且唯一,8-32 位,不能以下划线开头
* - signData 是要原样传给客户端的字符串,签名基于同一份字符串
* - goodsPrice 单位是分,必须与 MP 后台道具价格一致
*/
async create(userId: number, courseCode: string, quantity = 1): Promise<{ outTradeNo: string; payData: PayData }> {
if (!this.wxpay.enabled) {
throw new AppError(ErrorCode.PAY_NOT_ENABLED, '支付尚未开通,请联系管理员', 503);
}
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
throw AppError.badRequest('购买数量不合法');
}
const course = await this.prisma.course.findUnique({ where: { code: courseCode } });
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
const owned = await this.prisma.entitlement.findUnique({
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
});
if (owned) throw AppError.conflict('你已拥有该课程');
const product = await this.prisma.product.findFirst({
where: { course_id: course.id, status: 1 },
});
if (!product) throw AppError.notFound('该课程尚未配置支付商品');
const outTradeNo = this.newOutTradeNo();
const amountFen = product.price_fen * quantity;
await this.prisma.order.create({
data: {
out_trade_no: outTradeNo,
user_id: userId,
course_id: course.id,
product_id: product.wx_product_id,
quantity,
amount_fen: amountFen,
status: OrderStatus.CREATED,
env: 0,
attach: `course:${course.code}`,
},
});
const sessionKey = await this.payments.requireSessionKey(userId);
const signData = JSON.stringify({
offerId: this.wxpay.offerId,
buyQuantity: quantity,
env: 0,
currencyType: 'CNY',
productId: product.wx_product_id,
goodsPrice: product.price_fen,
outTradeNo,
attach: `course:${course.code}`,
});
return {
outTradeNo,
payData: {
signData,
mode: 'short_series_goods',
paySig: this.wxpay.calcPaySig('requestVirtualPayment', signData),
signature: this.wxpay.calcSignature(signData, sessionKey),
},
};
}
/** 查询自己的订单状态。前端支付返回后用它确认是否已发货。 */
async status(userId: number, outTradeNo: string) {
const order = await this.prisma.order.findUnique({
where: { out_trade_no: outTradeNo },
include: { course: { select: { code: true, title: true } } },
});
if (!order || order.user_id !== userId) throw AppError.notFound('订单不存在');
return {
outTradeNo: order.out_trade_no,
status: order.status,
statusText: STATUS_TEXT[order.status] ?? '未知',
amountFen: order.amount_fen,
courseCode: order.course.code,
courseTitle: order.course.title,
createdAt: order.created_at,
paidAt: order.paid_at,
deliveredAt: order.delivered_at,
refundedAt: order.refunded_at,
};
}
/** 我的订单列表。 */
async list(userId: number) {
const rows = await this.prisma.order.findMany({
where: { user_id: userId },
orderBy: { created_at: 'desc' },
take: 100,
include: { course: { select: { code: true, title: true } } },
});
return rows.map((o) => ({
outTradeNo: o.out_trade_no,
status: o.status,
statusText: STATUS_TEXT[o.status] ?? '未知',
amountFen: o.amount_fen,
courseCode: o.course.code,
courseTitle: o.course.title,
createdAt: o.created_at,
}));
}
private newOutTradeNo(): string {
// 13 位时间戳 + 4 位随机,共 18 位,满足 8-32 位且不以 _ 开头
return `T${Date.now()}${String(randomInt(1000, 10000))}`;
}
}
export const STATUS_TEXT: Record<number, string> = {
[OrderStatus.CREATED]: '待支付',
[OrderStatus.PAID]: '已支付',
[OrderStatus.DELIVERED]: '已开通',
[OrderStatus.REFUNDED]: '已退款',
[OrderStatus.CLOSED]: '已关闭',
};