feat(pay): 接入微信支付V3原路退款(按支付渠道分流 + 幂等 + 退款回调)

- 新增微信退款客户端 createRefund/queryRefundByOutRefundNo(/v3/refund/domestic/refunds)

- 新增退款桥接模块与 payment_refunds 幂等退款记录表

- 订单退款按渠道分流:微信支付原路退回微信;余额支付退回钱包余额;微信退款失败(502)不回退余额,防重复退款

- 覆盖拒单/买家取消/仲裁全额与部分/超时自动退款;新增 /api/pay/wechat/refund-notify 回调

- 后台新增退款回调地址配置;文档补充退款验收说明

- 新增回归护栏 wechat-refund.spec.ts(77 套件/605 用例全绿)
这个提交包含在:
xcbyy
2026-10-19 08:47:08 +08:00
父节点 e719de59a2
当前提交 8edffbefa2
共有 8 个文件被更改,包括 428 次插入 和 67 次删除

查看文件

@@ -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;