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 次删除

查看文件

@@ -26,8 +26,10 @@ import {
queryOrderByOutTradeNo,
transferToWallet,
queryTransferByOutBillNo,
createRefund,
} from './core/pay/wechat-pay-v3';
import { sendSubscribeMessageHttp, buildTemplateData } from './core/notify/wechat-subscribe.service';
import { setWeChatRefundFn } from './core/pay/refund-bridge';
const app = express();
const port = process.env.PORT || 3000;
@@ -1949,6 +1951,7 @@ async function resolveWechatPayRuntimeConfig(overrideCfg?: any) {
const transferSceneId = String(merged.transferSceneId || '').trim();
const transferRemark = String(merged.transferRemark || '').trim();
const transferNotifyUrl = String(merged.transferNotifyUrl || '').trim();
const refundNotifyUrl = String(merged.refundNotifyUrl || '').trim();
const transferUserNameEnabled = merged.transferUserNameEnabled === true || merged.transferUserNameEnabled === 'true' || Number(merged.transferUserNameEnabled) === 1;
const transferSceneReportInfos = Array.isArray(merged.transferSceneReportInfos) ? merged.transferSceneReportInfos : [];
const isReadyForLive = Boolean(appId && mchId && apiV3Key.length === 32 && privateKey);
@@ -1977,6 +1980,7 @@ async function resolveWechatPayRuntimeConfig(overrideCfg?: any) {
transferSceneId,
transferRemark,
transferNotifyUrl,
refundNotifyUrl,
transferUserNameEnabled,
transferSceneReportInfos,
isReadyForLive,
@@ -2074,6 +2078,7 @@ app.post('/api/admin/pay/wechat/config', async (req: Request, res: Response) =>
transferSceneId: String(incoming.transferSceneId || '').trim(),
transferRemark: String(incoming.transferRemark || '').trim(),
transferNotifyUrl: String(incoming.transferNotifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/transfer-notify').trim(),
refundNotifyUrl: String(incoming.refundNotifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/refund-notify').trim(),
transferUserNameEnabled: incoming.transferUserNameEnabled === true || incoming.transferUserNameEnabled === 'true' || Number(incoming.transferUserNameEnabled) === 1,
transferSceneReportInfos: Array.isArray(incoming.transferSceneReportInfos) ? incoming.transferSceneReportInfos : [],
remark: String(incoming.remark || '微信支付V3直连商户参数配置').trim()
@@ -2359,6 +2364,43 @@ async function notifyBySubscribe(
}
}
// 注册微信原路退款执行器(供仓储层退款时调用;未就绪时返回 skipped,由调用方回退余额退款)
setWeChatRefundFn(async (orderId, refundCents, totalCents, reason, outRefundNo) => {
try {
const cfg: any = await resolveWechatPayRuntimeConfig();
if (!(cfg.effectiveMode === 'LIVE' && cfg.isReadyForLive)) {
return { ok: false, skipped: true, error: '微信支付未就绪' };
}
// 幂等:已成功的退款直接返回
const existing = await repo.getRefundByOutRefundNo(outRefundNo);
if (existing && existing.status === 'SUCCESS') {
return { ok: true, status: 'SUCCESS', refundId: existing.refund_id };
}
const resp = await createRefund(cfg, {
outTradeNo: orderId,
outRefundNo,
refundCents,
totalCents,
reason,
notifyUrl: cfg.refundNotifyUrl || undefined,
});
await repo.upsertRefundRecord({
outRefundNo,
outTradeNo: orderId,
orderId,
refundId: resp && resp.refund_id,
refundCents,
totalCents,
status: String((resp && resp.status) || 'PROCESSING'),
reason,
});
return { ok: true, status: String((resp && resp.status) || 'PROCESSING'), refundId: resp && resp.refund_id };
} catch (e: any) {
logServerError('wechatRefund', e);
return { ok: false, error: (e && e.message) || String(e) };
}
});
// 注册微信官方内容安全 msgSecCheck(v2) 真实检测器:
// 本地词库先做即时拦截,本地放行后再交由微信官方机器审核做第二层真实防御。
// 未配置真实小程序凭证(仍为内置占位值)或显式关闭时,自动跳过远端并降级本地词库。
@@ -2781,6 +2823,53 @@ app.post('/api/pay/wechat/transfer-notify', async (req: Request, res: Response)
res.status(500).json({ code: 'FAIL', message: err.message });
}
});
// 微信支付「退款」结果回调(验签 + 解密 + 幂等更新退款记录)
app.post('/api/pay/wechat/refund-notify', async (req: Request, res: Response) => {
try {
const cfg: any = await resolveWechatPayRuntimeConfig();
const rawBody = (req as any).rawBody || JSON.stringify(req.body || {});
const timestamp = String(req.headers['wechatpay-timestamp'] || '');
const nonce = String(req.headers['wechatpay-nonce'] || '');
const signature = String(req.headers['wechatpay-signature'] || '');
const serial = String(req.headers['wechatpay-serial'] || '');
if (!signature) {
return res.status(401).json({ code: 'FAIL', message: '缺少微信支付签名头' });
}
if (!cfg.apiV3Key) {
return res.status(500).json({ code: 'FAIL', message: '未配置 APIv3 密钥,无法解密回调' });
}
const verified = verifyNotifySignature({ cfg, timestamp, nonce, body: rawBody, signature, serial });
if (!verified) {
return res.status(401).json({ code: 'FAIL', message: '退款回调验签失败' });
}
const payload: any = req.body || {};
const resource = payload.resource;
if (resource && resource.ciphertext) {
const plain = decryptNotifyResource(cfg.apiV3Key, resource);
const outRefundNo = plain && (plain.out_refund_no || plain.outRefundNo);
const refundStatus = plain && (plain.refund_status || plain.status);
if (outRefundNo && refundStatus) {
const existing = await repo.getRefundByOutRefundNo(String(outRefundNo));
await repo.upsertRefundRecord({
outRefundNo: String(outRefundNo),
outTradeNo: String((plain && plain.out_trade_no) || (existing && existing.out_trade_no) || ''),
orderId: String((existing && existing.order_id) || ''),
refundId: String((plain && plain.refund_id) || (existing && existing.refund_id) || ''),
refundCents: Number((existing && existing.refund_cents) || 0),
totalCents: Number((existing && existing.total_cents) || 0),
status: String(refundStatus),
reason: String((existing && existing.reason) || ''),
});
}
}
res.json({ code: 'SUCCESS', message: '成功' });
} catch (err: any) {
res.status(500).json({ code: 'FAIL', message: err.message });
}
});
// 大神拒单自动全额退款
app.post('/api/orders/:id/reject', async (req: Request, res: Response) => {
try {
const orderId = String(req.params.id);