四端上传
This commit is contained in:
1 parent
25bbb5f9c8
commit
c6e2cb4d5b
276 files changed
+17812
-3
No files matched your search
@@ -0,0 +1,34 @@
|
||||
import { Controller, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Public } from '../../common/auth';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
/** 取原始请求体。虚拟支付回调是 XML,必须拿到未经解析的字节。 */
|
||||
function rawBodyOf(req: Request): string {
|
||||
const withRaw = req as Request & { rawBody?: Buffer };
|
||||
if (withRaw.rawBody) return withRaw.rawBody.toString('utf8');
|
||||
return typeof req.body === 'string' ? req.body : String(req.body ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 虚拟支付回调。由微信服务器调用,不带小程序身份,靠签名与业务校验保证安全。
|
||||
* 请求体是 XML,应答也必须是 XML。
|
||||
*/
|
||||
@Controller('v1/payments')
|
||||
export class PaymentsController {
|
||||
constructor(private readonly payments: PaymentsService) {}
|
||||
|
||||
@Public()
|
||||
@Post('notify')
|
||||
async notify(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
const xml = await this.payments.handleDeliverNotify(rawBodyOf(req));
|
||||
res.type('application/xml').send(xml);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refund-notify')
|
||||
async refundNotify(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
const xml = await this.payments.handleRefundNotify(rawBodyOf(req));
|
||||
res.type('application/xml').send(xml);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PaymentsController } from './payments.controller';
|
||||
import { PaymentsScheduler } from './payments.scheduler';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { WxPayService } from './wxpay.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService, WxPayService, PaymentsScheduler],
|
||||
exports: [PaymentsService, WxPayService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
/**
|
||||
* 发货推送可能丢失(用户中途退出等情况),必须有一条兜底路径。
|
||||
* 进程内调度,不依赖系统 cron。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentsScheduler {
|
||||
private readonly logger = new Logger(PaymentsScheduler.name);
|
||||
|
||||
constructor(private readonly payments: PaymentsService) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async reconcile(): Promise<void> {
|
||||
try {
|
||||
const count = await this.payments.reconcilePendingOrders();
|
||||
if (count > 0) this.logger.log(`兜底补发 ${count} 笔订单`);
|
||||
} catch (err) {
|
||||
this.logger.error(`兜底查单异常:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { decryptSecret } from '../../common/crypto';
|
||||
import { AppError, ErrorCode } from '../../common/errors';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WxPayService } from './wxpay.service';
|
||||
|
||||
export const OrderStatus = {
|
||||
CREATED: 0,
|
||||
PAID: 1,
|
||||
DELIVERED: 2,
|
||||
REFUNDED: 3,
|
||||
CLOSED: 4,
|
||||
} as const;
|
||||
|
||||
interface DeliverNotify {
|
||||
Event?: string;
|
||||
OpenId?: string;
|
||||
OutTradeNo?: string;
|
||||
WeChatPayInfo?: { MchOrderNo?: string };
|
||||
GoodsInfo?: { ProductId?: string; Quantity?: number };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
private readonly parser = new XMLParser({ ignoreAttributes: true, trimValues: true });
|
||||
|
||||
constructor(
|
||||
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly wxpay: WxPayService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 处理发货推送。
|
||||
*
|
||||
* 纪律:
|
||||
* 1. 发货只认推送与查单,前端支付成功回调不作数
|
||||
* 2. 幂等键是平台单号 wx_order_id,不是自己的 out_trade_no
|
||||
* 3. 无论如何都要返回 XML,非 0 会被平台重试(最多 15 次)
|
||||
*/
|
||||
async handleDeliverNotify(rawXml: string): Promise<string> {
|
||||
const logId = await this.logNotify(rawXml);
|
||||
|
||||
try {
|
||||
const parsed = this.parser.parse(rawXml) as { xml?: DeliverNotify };
|
||||
const notify = parsed.xml ?? {};
|
||||
|
||||
if (notify.Event !== 'xpay_goods_deliver_notify') {
|
||||
await this.markNotify(logId, 1, `忽略事件 ${notify.Event ?? '未知'}`);
|
||||
return this.okXml();
|
||||
}
|
||||
|
||||
const outTradeNo = notify.OutTradeNo ?? '';
|
||||
const wxOrderId = notify.WeChatPayInfo?.MchOrderNo ?? '';
|
||||
const openid = notify.OpenId ?? '';
|
||||
if (!outTradeNo || !wxOrderId) throw new Error('推送缺少 OutTradeNo 或 MchOrderNo');
|
||||
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!order) throw new Error(`订单不存在:${outTradeNo}`);
|
||||
if (order.user.openid !== openid) throw new Error(`推送 openid 与订单不符:${outTradeNo}`);
|
||||
|
||||
if (order.status === OrderStatus.DELIVERED || order.status === OrderStatus.REFUNDED) {
|
||||
await this.markNotify(logId, 1, '重复推送,已忽略');
|
||||
return this.okXml();
|
||||
}
|
||||
|
||||
await this.grantEntitlement(order.user_id, order.course_id, order.id, order.out_trade_no, wxOrderId);
|
||||
await this.markNotify(logId, 1, '发货成功');
|
||||
this.logger.log(`发货成功 order=${outTradeNo} wx=${wxOrderId}`);
|
||||
return this.okXml();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`发货失败:${message}`);
|
||||
await this.markNotify(logId, 2, message);
|
||||
return `<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[${message}]]></ErrMsg></xml>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理退款推送。 */
|
||||
async handleRefundNotify(rawXml: string): Promise<string> {
|
||||
const logId = await this.logNotify(rawXml);
|
||||
try {
|
||||
const parsed = this.parser.parse(rawXml) as { xml?: { OutTradeNo?: string } };
|
||||
const outTradeNo = parsed.xml?.OutTradeNo ?? '';
|
||||
if (outTradeNo) {
|
||||
await this.prisma.order.updateMany({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
|
||||
});
|
||||
}
|
||||
await this.markNotify(logId, 1, '退款已记录');
|
||||
return this.okXml();
|
||||
} catch (err) {
|
||||
await this.markNotify(logId, 2, err instanceof Error ? err.message : String(err));
|
||||
return '<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[fail]]></ErrMsg></xml>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底查单:补发「已支付但没收到推送」的订单。
|
||||
* 由定时任务每 5 分钟调用一次。
|
||||
*/
|
||||
async reconcilePendingOrders(): Promise<number> {
|
||||
if (!this.wxpay.enabled) return 0;
|
||||
|
||||
const pending = await this.prisma.order.findMany({
|
||||
where: { status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
|
||||
include: { user: true },
|
||||
orderBy: { created_at: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
let delivered = 0;
|
||||
for (const order of pending) {
|
||||
try {
|
||||
const result = await this.wxpay.queryOrder(order.out_trade_no, order.user.openid);
|
||||
const wxOrderId = result.wx_order_id ?? result.order?.wx_order_id;
|
||||
const isPaid = result.status === 2 || result.order?.status === 2 || Boolean(wxOrderId);
|
||||
if (!isPaid) continue;
|
||||
|
||||
await this.grantEntitlement(
|
||||
order.user_id,
|
||||
order.course_id,
|
||||
order.id,
|
||||
order.out_trade_no,
|
||||
wxOrderId ?? `reconciled-${order.out_trade_no}`,
|
||||
);
|
||||
delivered++;
|
||||
this.logger.log(`兜底补发成功 order=${order.out_trade_no}`);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`兜底查单失败 order=${order.out_trade_no}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
/** 取用户 sessionKey(明文)。不存在时提示重新登录。 */
|
||||
async requireSessionKey(userId: number): Promise<string> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user?.wx_session_key) throw AppError.unauthorized('请重新进入小程序后再试');
|
||||
const key = decryptSecret(user.wx_session_key, this.cfg.SESSION_KEY_ENC_SECRET);
|
||||
if (!key) throw new AppError(ErrorCode.INTERNAL, 'sessionKey 解密失败', 500);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** 标记订单已发货并发放权益。两个写操作必须同事务。 */
|
||||
private async grantEntitlement(
|
||||
userId: number,
|
||||
courseId: number,
|
||||
orderId: number,
|
||||
outTradeNo: string,
|
||||
wxOrderId: string,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: OrderStatus.DELIVERED,
|
||||
wx_order_id: wxOrderId,
|
||||
paid_at: now,
|
||||
delivered_at: now,
|
||||
},
|
||||
}),
|
||||
this.prisma.entitlement.upsert({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
|
||||
create: {
|
||||
user_id: userId,
|
||||
course_id: courseId,
|
||||
source: 1,
|
||||
ref_id: orderId,
|
||||
granted_at: now,
|
||||
note: `订单 ${outTradeNo}`,
|
||||
},
|
||||
update: {},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async logNotify(rawXml: string): Promise<number> {
|
||||
const row = await this.prisma.paymentNotifyLog.create({ data: { raw_body: rawXml } });
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private async markNotify(id: number, processed: number, errMsg: string): Promise<void> {
|
||||
await this.prisma.paymentNotifyLog.update({
|
||||
where: { id },
|
||||
data: { processed, err_msg: errMsg.slice(0, 255) },
|
||||
});
|
||||
}
|
||||
|
||||
private okXml(): string {
|
||||
return '<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { AppError } from '../../common/errors';
|
||||
|
||||
const XPAY_BASE = 'https://api.weixin.qq.com';
|
||||
|
||||
/**
|
||||
* 微信小程序虚拟支付客户端。
|
||||
*
|
||||
* 签名规则(两套,缺一不可):
|
||||
* paySig 服务端身份,密钥是后台的「现网 AppKey」:HMAC_SHA256(appkey, uri + '&' + postBody)
|
||||
* signature 用户态身份,密钥是该用户的 sessionKey:HMAC_SHA256(sessionKey, postBody)
|
||||
*
|
||||
* 注意:postBody 必须与实际发出的字符串逐字节一致,不能格式化、不能改键顺序。
|
||||
*/
|
||||
@Injectable()
|
||||
export class WxPayService {
|
||||
private readonly logger = new Logger(WxPayService.name);
|
||||
|
||||
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.cfg.WX_OFFER_ID !== '' && this.cfg.WX_PAY_APPKEY !== '';
|
||||
}
|
||||
|
||||
get offerId(): string {
|
||||
return this.cfg.WX_OFFER_ID;
|
||||
}
|
||||
|
||||
/** 服务端身份签名。uri 不带问号参数。 */
|
||||
calcPaySig(uri: string, postBody: string): string {
|
||||
return createHmac('sha256', this.cfg.WX_PAY_APPKEY).update(`${uri}&${postBody}`).digest('hex');
|
||||
}
|
||||
|
||||
/** 用户态签名。 */
|
||||
calcSignature(postBody: string, sessionKey: string): string {
|
||||
return createHmac('sha256', sessionKey).update(postBody).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查单。用于发货推送丢失时兜底补发。
|
||||
* 参数名是 order_id(传自己的 outTradeNo),不是 out_trade_no。
|
||||
*/
|
||||
async queryOrder(outTradeNo: string, openid: string): Promise<XpayQueryResult> {
|
||||
const uri = '/xpay/query_order';
|
||||
const body = JSON.stringify({ openid, env: 0, order_id: outTradeNo });
|
||||
const paySig = this.calcPaySig(uri, body);
|
||||
|
||||
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const json = (await res.json()) as XpayQueryResult;
|
||||
if (json.errcode && json.errcode !== 0) {
|
||||
this.logger.warn(`查单失败 ${outTradeNo}: ${json.errcode} ${json.errmsg ?? ''}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动退款(仅 Android 等非 iOS 终端可用;iOS 需用户向 App Store 申请,开发者无法发起)。
|
||||
*
|
||||
* 注意:参数集合需以官方《虚拟支付 - 退款》文档核对后再上线启用,
|
||||
* 当前按已知字段拼装,失败时抛错而非静默。
|
||||
*/
|
||||
async refundOrder(input: {
|
||||
outTradeNo: string;
|
||||
openid: string;
|
||||
refundOrderId: string;
|
||||
refundAmountFen: number;
|
||||
reason: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
if (!this.enabled) throw new AppError(4002, '虚拟支付未配置', 503);
|
||||
|
||||
const uri = '/xpay/refund_order';
|
||||
const body = JSON.stringify({
|
||||
openid: input.openid,
|
||||
env: 0,
|
||||
order_id: input.outTradeNo,
|
||||
refund_order_id: input.refundOrderId,
|
||||
refund_amount: input.refundAmountFen,
|
||||
refund_reason: input.reason,
|
||||
});
|
||||
const paySig = this.calcPaySig(uri, body);
|
||||
|
||||
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
export interface XpayQueryResult {
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
order?: {
|
||||
order_id?: string;
|
||||
wx_order_id?: string;
|
||||
status?: number;
|
||||
paid_time?: number;
|
||||
};
|
||||
status?: number;
|
||||
wx_order_id?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user