feat(pay): 接入微信支付V3原路退款(按支付渠道分流 + 幂等 + 退款回调)
- 新增微信退款客户端 createRefund/queryRefundByOutRefundNo(/v3/refund/domestic/refunds) - 新增退款桥接模块与 payment_refunds 幂等退款记录表 - 订单退款按渠道分流:微信支付原路退回微信;余额支付退回钱包余额;微信退款失败(502)不回退余额,防重复退款 - 覆盖拒单/买家取消/仲裁全额与部分/超时自动退款;新增 /api/pay/wechat/refund-notify 回调 - 后台新增退款回调地址配置;文档补充退款验收说明 - 新增回归护栏 wechat-refund.spec.ts(77 套件/605 用例全绿)
这个提交包含在:
@@ -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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户