feat(pay): 接入微信支付V3原路退款(按支付渠道分流 + 幂等 + 退款回调)
- 新增微信退款客户端 createRefund/queryRefundByOutRefundNo(/v3/refund/domestic/refunds) - 新增退款桥接模块与 payment_refunds 幂等退款记录表 - 订单退款按渠道分流:微信支付原路退回微信;余额支付退回钱包余额;微信退款失败(502)不回退余额,防重复退款 - 覆盖拒单/买家取消/仲裁全额与部分/超时自动退款;新增 /api/pay/wechat/refund-notify 回调 - 后台新增退款回调地址配置;文档补充退款验收说明 - 新增回归护栏 wechat-refund.spec.ts(77 套件/605 用例全绿)
这个提交包含在:
@@ -0,0 +1,41 @@
|
||||
// 已读取AGENTS.md
|
||||
/**
|
||||
* 退款桥接:仓储层在订单退款时通过该桥发起"微信原路退款",
|
||||
* 避免仓储层直接依赖支付配置与网络客户端(由 index.ts 启动时注入执行器)。
|
||||
*/
|
||||
export interface WeChatRefundResult {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
status?: string;
|
||||
refundId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type WeChatRefundFn = (
|
||||
orderId: string,
|
||||
refundCents: number,
|
||||
totalCents: number,
|
||||
reason: string,
|
||||
outRefundNo: string
|
||||
) => Promise<WeChatRefundResult>;
|
||||
|
||||
let refundFn: WeChatRefundFn | null = null;
|
||||
|
||||
export function setWeChatRefundFn(fn: WeChatRefundFn): void {
|
||||
refundFn = fn;
|
||||
}
|
||||
|
||||
export async function tryRefundToWeChat(
|
||||
orderId: string,
|
||||
refundCents: number,
|
||||
totalCents: number,
|
||||
reason: string,
|
||||
outRefundNo: string
|
||||
): Promise<WeChatRefundResult> {
|
||||
if (!refundFn) return { ok: false, skipped: true, error: '未注入微信退款执行器' };
|
||||
try {
|
||||
return await refundFn(orderId, refundCents, totalCents, reason, outRefundNo);
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: (e && e.message) || String(e) };
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,77 @@ export async function queryTransferByOutBillNo(cfg: WechatPayConfig, outBillNo:
|
||||
return JSON.parse(resp.body || '{}');
|
||||
}
|
||||
|
||||
export interface RefundParams {
|
||||
/** 原支付订单号(与下单时 out_trade_no 一致) */
|
||||
outTradeNo: string;
|
||||
/** 商户退款单号(幂等:同一退款重复提交返回同一结果) */
|
||||
outRefundNo: string;
|
||||
refundCents: number;
|
||||
totalCents: number;
|
||||
reason?: string;
|
||||
notifyUrl?: string;
|
||||
}
|
||||
|
||||
/** 申请退款(V3,原路退回):返回 { refund_id, status, ... } */
|
||||
export async function createRefund(cfg: WechatPayConfig, p: RefundParams): Promise<any> {
|
||||
const urlPath = '/v3/refund/domestic/refunds';
|
||||
const amount = {
|
||||
refund: Math.round(Number(p.refundCents) || 0),
|
||||
total: Math.round(Number(p.totalCents) || 0),
|
||||
currency: 'CNY',
|
||||
};
|
||||
const bodyObj: any = {
|
||||
out_trade_no: p.outTradeNo,
|
||||
out_refund_no: p.outRefundNo,
|
||||
amount,
|
||||
};
|
||||
const reason = String(p.reason || '').trim();
|
||||
if (reason) bodyObj.reason = reason.slice(0, 80);
|
||||
if (p.notifyUrl) bodyObj.notify_url = p.notifyUrl;
|
||||
const body = JSON.stringify(bodyObj);
|
||||
const authorization = buildAuthorization({ method: 'POST', urlPath, body, cfg });
|
||||
const resp = await httpsRequest({
|
||||
method: 'POST',
|
||||
urlPath,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: authorization,
|
||||
'User-Agent': 'peiwan-server/1.0',
|
||||
},
|
||||
body,
|
||||
});
|
||||
let parsed: any = {};
|
||||
try {
|
||||
parsed = JSON.parse(resp.body || '{}');
|
||||
} catch (_) {
|
||||
parsed = {};
|
||||
}
|
||||
if (resp.statusCode >= 400) {
|
||||
throw new Error(`微信退款失败 [${resp.statusCode}]: ${parsed.message || resp.body}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** 查询退款单结果(按商户退款单号)。status: SUCCESS/PROCESSING/CLOSED/ABNORMAL */
|
||||
export async function queryRefundByOutRefundNo(cfg: WechatPayConfig, outRefundNo: string): Promise<any> {
|
||||
const urlPath = `/v3/refund/domestic/refunds/${encodeURIComponent(outRefundNo)}`;
|
||||
const authorization = buildAuthorization({ method: 'GET', urlPath, body: '', cfg });
|
||||
const resp = await httpsRequest({
|
||||
method: 'GET',
|
||||
urlPath,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: authorization,
|
||||
'User-Agent': 'peiwan-server/1.0',
|
||||
},
|
||||
});
|
||||
if (resp.statusCode >= 400) {
|
||||
throw new Error(`微信退款查单失败 [${resp.statusCode}]: ${resp.body}`);
|
||||
}
|
||||
return JSON.parse(resp.body || '{}');
|
||||
}
|
||||
|
||||
/** 校验微信支付回调签名(使用平台公钥/平台证书),时间戳容差 5 分钟 */
|
||||
export function verifyNotifySignature(params: {
|
||||
cfg: WechatPayConfig;
|
||||
|
||||
在新工单中引用
屏蔽一个用户