四端上传
This commit is contained in:
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]: '已关闭',
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user