支付通路
This commit is contained in:
1 parent
c7f43e30a9
commit
85b0e70907
26 files changed
+1324
-74
No files matched your search
@@ -66,7 +66,16 @@ export class PaymentsController {
|
||||
res.type('application/xml').send(await this.payments.handleRefundNotify(body));
|
||||
return;
|
||||
}
|
||||
// 地址会收到全部事件(投诉、风控、代币等),其余统一回 success 表示已收到
|
||||
// iOS 退款问询必须 3 秒内表态,否则微信向 Apple 回「不确定」
|
||||
if (body.includes('xpay_subscribe_ios_refund_query_notify')) {
|
||||
const answer = await this.payments.handleIosRefundQuery(body);
|
||||
// 来的是 XML 信封就回 XML,来的是 JSON 就回 JSON
|
||||
res.type(body.trimStart().startsWith('<') ? 'application/xml' : 'application/json').send(answer);
|
||||
return;
|
||||
}
|
||||
// 地址会收到全部事件(投诉、风控、代币等),其余统一回 success 表示已收到。
|
||||
// 先落一条日志,否则这类事件事后完全查不到痕迹。
|
||||
await this.payments.logIgnoredNotify(body);
|
||||
res.type('text/plain').send('success');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { cdata, eventOf, fieldOf } from '../../common/wechat-msg';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WxPayService } from './wxpay.service';
|
||||
|
||||
@@ -18,7 +19,7 @@ interface DeliverNotify {
|
||||
Event?: string;
|
||||
OpenId?: string;
|
||||
OutTradeNo?: string;
|
||||
WeChatPayInfo?: { MchOrderNo?: string };
|
||||
WeChatPayInfo?: { MchOrderNo?: string; TransactionId?: string };
|
||||
GoodsInfo?: { ProductId?: string; Quantity?: number };
|
||||
}
|
||||
|
||||
@@ -37,6 +38,26 @@ interface RefundNotify {
|
||||
RetMsg?: string;
|
||||
}
|
||||
|
||||
/** iOS 退款问询(JSON 报文)。 */
|
||||
interface RefundQueryNotify {
|
||||
/** 退款对应的支付订单号 */
|
||||
pay_order_id?: string;
|
||||
/** 0 未发货 / 1 已发货 / 2 发货中 */
|
||||
provide_status?: string;
|
||||
refund_request_reason?: string;
|
||||
product_id?: string;
|
||||
channel_bill?: string;
|
||||
}
|
||||
|
||||
/** 订单状态中文名。不走 orders.service 的导出,避免两个模块循环依赖。 */
|
||||
const STATUS_CN: Record<number, string> = {
|
||||
[OrderStatus.CREATED]: '待支付',
|
||||
[OrderStatus.PAID]: '已支付未发货',
|
||||
[OrderStatus.DELIVERED]: '已开通',
|
||||
[OrderStatus.REFUNDED]: '已退款',
|
||||
[OrderStatus.CLOSED]: '已关闭',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
@@ -69,9 +90,16 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
const outTradeNo = notify.OutTradeNo ?? '';
|
||||
const wxOrderId = notify.WeChatPayInfo?.MchOrderNo ?? '';
|
||||
const openid = notify.OpenId ?? '';
|
||||
if (!outTradeNo || !wxOrderId) throw new Error('推送缺少 OutTradeNo 或 MchOrderNo');
|
||||
if (!outTradeNo) throw new Error('推送缺少 OutTradeNo');
|
||||
|
||||
// 平台单号只用于幂等。文档说 WeChatPayInfo「非微信支付渠道可能没有」,
|
||||
// 也就是 Apple 支付的推送可能不带它——那样按原样判失败,iOS 用户付了钱也开不了课。
|
||||
// 取不到就用订单号兜底,保证照常发货。
|
||||
const wxOrderId =
|
||||
notify.WeChatPayInfo?.MchOrderNo ||
|
||||
notify.WeChatPayInfo?.TransactionId ||
|
||||
`local:${outTradeNo}`;
|
||||
|
||||
const order = await this.prisma.active.order.findUnique({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
@@ -156,6 +184,106 @@ export class PaymentsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到但不由我们处理的推送也要留痕。
|
||||
* 之前这类事件被静默丢掉,连日志都没有,事后完全看不出它来过。
|
||||
*/
|
||||
async logIgnoredNotify(rawBody: string): Promise<void> {
|
||||
const event = eventOf(rawBody) || '(无 Event)';
|
||||
await this.prisma.active.paymentNotifyLog.create({
|
||||
data: { raw_body: rawBody, event, processed: 0, err_msg: `收到未处理的推送:${event}`.slice(0, 255) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS 退款问询(xpay_subscribe_ios_refund_query_notify)。
|
||||
*
|
||||
* Apple 支付不能由开发者主动退款,用户去 App Store 申请后,Apple 会向我们发问询,
|
||||
* 我们必须表态:0 = 放过(建议退款),1 = 拦截(拒绝退款),并给决策凭据。
|
||||
* 连续 3 次、每次 3 秒内没答,微信就向 Apple 回「不确定」,等于弃权。
|
||||
*
|
||||
* 策略:以我们自己的订单状态为准——已发货就拦(用户已经拿到权益),其余放过。
|
||||
* 真退了钱平台还会推 xpay_refund_notify,那时再收回权益,所以「放过」不会让自己吃亏。
|
||||
*/
|
||||
async handleIosRefundQuery(rawBody: string): Promise<string> {
|
||||
const logId = await this.logNotify(rawBody);
|
||||
let payOrderId = '';
|
||||
try {
|
||||
// 文档写的是 JSON,实际推过来的是明文 XML 信封,两种都按字段名取
|
||||
payOrderId = fieldOf(rawBody, 'pay_order_id');
|
||||
const provideStatus = fieldOf(rawBody, 'provide_status');
|
||||
const reason = fieldOf(rawBody, 'refund_request_reason');
|
||||
|
||||
// pay_order_id 是「支付订单号」,我们自己的 out_trade_no 和平台单号都试一遍
|
||||
const order = payOrderId
|
||||
? await this.prisma.active.order.findFirst({
|
||||
where: { OR: [{ out_trade_no: payOrderId }, { wx_order_id: payOrderId }] },
|
||||
select: { id: true, out_trade_no: true, status: true, refund_consent_at: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
// 查得到订单就信自己的状态;查不到才退回用推送里的发货状态
|
||||
const delivered = order ? order.status === OrderStatus.DELIVERED : provideStatus === '1';
|
||||
// 管理员提前打过「同意退款」的,一律放过——用户是跟你沟通过的
|
||||
const consented = Boolean(order?.refund_consent_at);
|
||||
const reject = delivered && !consented;
|
||||
const resultCode = reject ? 1 : 0;
|
||||
const resultInfo = reject ? '已发货,拒绝退款' : consented ? '管理员已同意退款' : '未发货,同意退款';
|
||||
const evidence = consented
|
||||
? `管理员已于 ${order!.refund_consent_at!.toISOString()} 同意该订单退款`
|
||||
: order
|
||||
? `本地订单 ${order.out_trade_no} 状态为${STATUS_CN[order.status] ?? order.status}${delivered ? ',用户已获得课程权益' : ',尚未发放权益'}`
|
||||
: `未找到对应订单(pay_order_id=${payOrderId || '空'}),按未发货处理`;
|
||||
|
||||
// 落一条「退款申请」供后台查看。平台会重复问询,同一个支付订单号只累加次数。
|
||||
if (payOrderId) {
|
||||
const record = {
|
||||
order_id: order?.id ?? null,
|
||||
reason: reason.slice(0, 255) || null,
|
||||
provide_status: provideStatus || null,
|
||||
our_result: resultCode,
|
||||
our_evidence: evidence.slice(0, 255),
|
||||
};
|
||||
await this.prisma.active.refundRequest.upsert({
|
||||
where: { pay_order_id: payOrderId },
|
||||
create: { pay_order_id: payOrderId, ...record },
|
||||
update: { ...record, query_count: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
await this.markNotify(
|
||||
logId,
|
||||
1,
|
||||
`退款问询→${reject ? '拒绝' : '放过'} | ${payOrderId} | ${reason}`.slice(0, 255),
|
||||
);
|
||||
this.logger.log(`iOS 退款问询 ${payOrderId || '-'} → ${resultInfo}`);
|
||||
return this.refundQueryAnswer(rawBody, resultCode, resultInfo, evidence);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`退款问询处理失败:${message}`);
|
||||
await this.markNotify(logId, 2, `退款问询处理失败:${message}`.slice(0, 255));
|
||||
// 自己出错时不要替用户做决定:退回「放过」,真退了钱还有退款推送兜底收回权益
|
||||
return this.refundQueryAnswer(
|
||||
rawBody,
|
||||
0,
|
||||
'处理异常,不做拦截',
|
||||
`服务端处理问询异常:${message}`.slice(0, 200),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按来件格式回包:来的是 XML 就回 XML,来的是 JSON 就回 JSON。
|
||||
* 文档只列了三个字段,没说报文体格式;明文模式下实际推来的是 XML 信封,
|
||||
* 所以对称回 XML 最稳。
|
||||
*/
|
||||
private refundQueryAnswer(request: string, code: number, info: string, evidence: string): string {
|
||||
if (request.trimStart().startsWith('<')) {
|
||||
return `<xml><result_code>${code}</result_code><result_info>${cdata(info)}</result_info><evidence>${cdata(evidence)}</evidence></xml>`;
|
||||
}
|
||||
return JSON.stringify({ result_code: code, result_info: info, evidence });
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底查单:补发「已支付但没收到推送」的订单。
|
||||
* 由定时任务每 5 分钟调用一次。
|
||||
@@ -175,8 +303,17 @@ export class PaymentsService {
|
||||
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;
|
||||
// 只认「已支付待发货 / 发货中 / 已发货」这三种状态。
|
||||
// 不能用「有没有 wx_order_id」当判据——已关闭(6)和已退款(5)的单也有单号,
|
||||
// 那样会把关闭的沙箱单当成已支付补发出去(真实发生过)。
|
||||
const status = result.order?.status;
|
||||
const isPaid = status === 2 || status === 3 || status === 4;
|
||||
if (!isPaid) {
|
||||
if (status !== undefined && status !== 1) {
|
||||
this.logger.warn(`兜底查单跳过 ${order.out_trade_no}:微信侧状态 ${status}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.grantEntitlement(
|
||||
order.user_id,
|
||||
@@ -240,7 +377,9 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
private async logNotify(rawXml: string): Promise<number> {
|
||||
const row = await this.prisma.active.paymentNotifyLog.create({ data: { raw_body: rawXml } });
|
||||
const row = await this.prisma.active.paymentNotifyLog.create({
|
||||
data: { raw_body: rawXml, event: eventOf(rawXml) || null },
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,19 @@ import { AppError } from '../../common/errors';
|
||||
|
||||
const XPAY_BASE = 'https://api.weixin.qq.com';
|
||||
|
||||
/** query_order 返回的订单状态。 */
|
||||
const XPAY_STATUS: Record<number, string> = {
|
||||
0: '初始化',
|
||||
1: '创建成功未支付',
|
||||
2: '已支付待发货',
|
||||
3: '发货中',
|
||||
4: '已发货',
|
||||
5: '已退款',
|
||||
6: '已关闭',
|
||||
7: '退款失败',
|
||||
8: '用户退款完成',
|
||||
};
|
||||
|
||||
/**
|
||||
* 微信小程序虚拟支付客户端。
|
||||
*
|
||||
@@ -21,6 +34,9 @@ const XPAY_BASE = 'https://api.weixin.qq.com';
|
||||
export class WxPayService {
|
||||
private readonly logger = new Logger(WxPayService.name);
|
||||
|
||||
/** access_token 缓存。有效期 7200 秒,提前 5 分钟换新的。 */
|
||||
private tokenCache: { value: string; expiresAt: number } | null = null;
|
||||
|
||||
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
|
||||
|
||||
/** 当前支付环境:0 现网 / 1 沙箱。 */
|
||||
@@ -51,6 +67,46 @@ export class WxPayService {
|
||||
return createHmac('sha256', sessionKey).update(postBody).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序 access_token。
|
||||
*
|
||||
* 这些 /xpay/* 服务端接口要求 URL 上同时带 access_token 和 pay_sig:
|
||||
* https://api.weixin.qq.com/xpay/query_order?access_token=xxx&pay_sig=xxx
|
||||
* 只带 pay_sig 会返回 41001 access_token missing——查单和退款都会静默失败。
|
||||
*/
|
||||
private async accessToken(): Promise<string> {
|
||||
const now = Date.now();
|
||||
if (this.tokenCache && this.tokenCache.expiresAt > now) return this.tokenCache.value;
|
||||
|
||||
const url =
|
||||
`${XPAY_BASE}/cgi-bin/token?grant_type=client_credential` +
|
||||
`&appid=${encodeURIComponent(this.cfg.WX_APPID)}` +
|
||||
`&secret=${encodeURIComponent(this.cfg.WX_APPSECRET)}`;
|
||||
const res = await fetch(url);
|
||||
const json = (await res.json()) as {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
};
|
||||
if (!json.access_token) {
|
||||
throw new AppError(
|
||||
4002,
|
||||
`获取 access_token 失败:${json.errcode ?? '-'} ${json.errmsg ?? ''}`.trim(),
|
||||
502,
|
||||
);
|
||||
}
|
||||
const ttl = Math.max((json.expires_in ?? 7200) - 300, 60);
|
||||
this.tokenCache = { value: json.access_token, expiresAt: now + ttl * 1000 };
|
||||
return json.access_token;
|
||||
}
|
||||
|
||||
/** 拼出带 access_token 与 pay_sig 的接口地址。 */
|
||||
private async signedUrl(uri: string, postBody: string): Promise<string> {
|
||||
const token = await this.accessToken();
|
||||
return `${XPAY_BASE}${uri}?access_token=${encodeURIComponent(token)}&pay_sig=${this.calcPaySig(uri, postBody)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查单。用于发货推送丢失时兜底补发。
|
||||
* 参数名是 order_id(传自己的 outTradeNo),不是 out_trade_no。
|
||||
@@ -58,9 +114,8 @@ export class WxPayService {
|
||||
async queryOrder(outTradeNo: string, openid: string): Promise<XpayQueryResult> {
|
||||
const uri = '/xpay/query_order';
|
||||
const body = JSON.stringify({ openid, env: this.env, order_id: outTradeNo });
|
||||
const paySig = this.calcPaySig(uri, body);
|
||||
|
||||
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
|
||||
const res = await fetch(await this.signedUrl(uri, body), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
@@ -82,28 +137,67 @@ export class WxPayService {
|
||||
outTradeNo: string;
|
||||
openid: string;
|
||||
refundOrderId: string;
|
||||
refundAmountFen: number;
|
||||
reason: string;
|
||||
refundFeeFen: number;
|
||||
/** 0-5,微信只认这几个枚举值 */
|
||||
reasonCode: string;
|
||||
/** 商家自定义数据,必填,退款原因的文字记在这里,query_order 可原样查回 */
|
||||
bizMeta: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
if (!this.enabled) throw new AppError(4002, '虚拟支付未配置', 503);
|
||||
|
||||
// left_fee 是「当前单剩余可退金额」,接口必填,只能先查单拿到。
|
||||
// 千万不要拿订单金额兜底——那等于编一个数发给微信,会被 268490016 打回来,
|
||||
// 而且把「这单根本不能退」这个真实原因盖掉了。
|
||||
const queried = await this.queryOrder(input.outTradeNo, input.openid);
|
||||
const status = queried.order?.status;
|
||||
const statusText = status === undefined ? '未知' : XPAY_STATUS[status] ?? String(status);
|
||||
const leftFee = queried.order?.left_fee;
|
||||
|
||||
if (leftFee === undefined || leftFee === null) {
|
||||
throw new AppError(
|
||||
4002,
|
||||
`微信侧订单状态是「${statusText}」,没有可退金额,无法退款`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
if (leftFee <= 0) {
|
||||
throw new AppError(4002, `该订单可退金额为 0(微信侧状态:${statusText}),无法退款`, 409);
|
||||
}
|
||||
if (input.refundFeeFen > leftFee) {
|
||||
throw new AppError(
|
||||
4002,
|
||||
`退款金额 ${input.refundFeeFen} 分超过可退金额 ${leftFee} 分,无法退款`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const uri = '/xpay/refund_order';
|
||||
// 字段名和取值都必须严格照文档,少一个必填字段整笔退款就被拒
|
||||
const body = JSON.stringify({
|
||||
openid: input.openid,
|
||||
env: this.env,
|
||||
order_id: input.outTradeNo,
|
||||
refund_order_id: input.refundOrderId,
|
||||
refund_amount: input.refundAmountFen,
|
||||
refund_reason: input.reason,
|
||||
left_fee: leftFee,
|
||||
refund_fee: input.refundFeeFen,
|
||||
biz_meta: input.bizMeta.slice(0, 1024),
|
||||
refund_reason: input.reasonCode,
|
||||
/** 1-人工客服退款:后台管理员发起的都属于这一类 */
|
||||
req_from: '1',
|
||||
env: this.env,
|
||||
});
|
||||
const paySig = this.calcPaySig(uri, body);
|
||||
|
||||
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
|
||||
const res = await fetch(await this.signedUrl(uri, body), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
const json = (await res.json()) as { errcode?: number; errmsg?: string };
|
||||
|
||||
// 以前不看返回码,微信拒了也照样告诉后台「已提交」,退款就这么静默消失了
|
||||
if (json.errcode && json.errcode !== 0) {
|
||||
throw new AppError(4002, `微信退款接口拒绝:${json.errcode} ${json.errmsg ?? ''}`.trim(), 502);
|
||||
}
|
||||
return json as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +209,8 @@ export interface XpayQueryResult {
|
||||
wx_order_id?: string;
|
||||
status?: number;
|
||||
paid_time?: number;
|
||||
/** 当前单剩余可退金额,单位分 */
|
||||
left_fee?: number;
|
||||
};
|
||||
status?: number;
|
||||
wx_order_id?: string;
|
||||
|
||||
Reference in new issue
Block a user