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;

查看文件

@@ -253,6 +253,24 @@ export async function initDatabase(): Promise<void> {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 7.5.1 微信原路退款记录(幂等:out_refund_no 唯一)
await conn.query(`
CREATE TABLE IF NOT EXISTS payment_refunds (
id INT PRIMARY KEY AUTO_INCREMENT,
out_refund_no VARCHAR(64) UNIQUE NOT NULL,
out_trade_no VARCHAR(64) NOT NULL,
order_id VARCHAR(64) DEFAULT '',
refund_id VARCHAR(64) DEFAULT '',
refund_cents INT NOT NULL DEFAULT 0,
total_cents INT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'PROCESSING',
reason VARCHAR(255) DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 8. 线下合规场所库
await conn.query(`
CREATE TABLE IF NOT EXISTS safety_places (

查看文件

@@ -1,4 +1,5 @@
import { pool } from './mysql';
import { tryRefundToWeChat } from '../core/pay/refund-bridge';
// 统一服务端错误日志(替代静默吞错,保证资金/结算异常可观测)
function logServerError(scope: string, err: any): void {
@@ -1104,6 +1105,127 @@ export class MysqlRepository {
};
}
/**
* 判断订单是否通过微信支付成功付款(用于退款渠道分流)。
* 返回 totalCents 为原支付金额(分)。
*/
private async orderPaidViaWechat(orderId: string): Promise<{ wechat: boolean; totalCents: number }> {
try {
const [rows]: any = await pool.query(
`SELECT amount_cents FROM payment_intents
WHERE scene = 'ORDER' AND status = 'SUCCESS' AND (order_id = ? OR out_trade_no = ?)
ORDER BY id DESC LIMIT 1`,
[orderId, orderId]
);
if (rows && rows.length > 0) {
return { wechat: true, totalCents: Number(rows[0].amount_cents) || 0 };
}
} catch (e) {
logServerError('orderPaidViaWechat', e);
}
return { wechat: false, totalCents: 0 };
}
/** 查询微信退款记录 */
async getRefundByOutRefundNo(outRefundNo: string): Promise<any | null> {
const [rows]: any = await pool.query('SELECT * FROM payment_refunds WHERE out_refund_no = ? LIMIT 1', [outRefundNo]);
return (rows && rows[0]) || null;
}
/** 写入/更新微信退款记录(幂等) */
async upsertRefundRecord(data: {
outRefundNo: string;
outTradeNo: string;
orderId: string;
refundId?: string;
refundCents: number;
totalCents: number;
status: string;
reason?: string;
}): Promise<any> {
await pool.query(
`INSERT INTO payment_refunds
(out_refund_no, out_trade_no, order_id, refund_id, refund_cents, total_cents, status, reason, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
refund_id = VALUES(refund_id), status = VALUES(status), reason = VALUES(reason)`,
[
data.outRefundNo,
data.outTradeNo,
data.orderId,
data.refundId || '',
Math.round(Number(data.refundCents) || 0),
Math.round(Number(data.totalCents) || 0),
data.status || 'PROCESSING',
(data.reason || '').slice(0, 255),
]
);
return { ...data };
}
/**
* 执行订单退款(按支付渠道分流):
* - 微信支付订单:调用微信原路退款(幂等 out_refund_no),失败抛错(不回退余额,防重复退款);
* - 余额支付订单:退回用户钱包余额。
*/
private async refundOrderPayment(
conn: any,
order: any,
refundCents: number,
reason: string,
financeType: string,
tag: string
): Promise<{ viaWechat: boolean }> {
const orderId = String(order.id);
const cents = Math.max(0, Math.round(Number(refundCents) || 0));
const amountYuan = Number((cents / 100).toFixed(2));
if (amountYuan <= 0) return { viaWechat: false };
const paid = await this.orderPaidViaWechat(orderId);
if (paid.wechat) {
const outRefundNo = `${tag}_${orderId}`.slice(0, 64);
const r = await tryRefundToWeChat(orderId, cents, paid.totalCents || cents, reason, outRefundNo);
if (r && r.ok) {
await conn.query(`
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW())
`, [
`fin_wxr_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`,
`WXR${Date.now()}${Math.floor(100 + Math.random() * 900)}`,
Number(order.buyer_id) || 0,
financeType,
amountYuan.toFixed(2),
'微信支付原路退款',
orderId,
]);
return { viaWechat: true };
}
const err: any = new Error(`微信原路退款失败:${(r && r.error) || '未知错误'}`);
err.status = 502;
err.code = 50200;
throw err;
}
// 余额支付:退回钱包余额
const buyerId = Number(order.buyer_id) || 0;
if (buyerId > 0) {
await conn.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [amountYuan, buyerId]);
await conn.query(`
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW())
`, [
`fin_bref_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`,
`BR${Date.now()}${Math.floor(100 + Math.random() * 900)}`,
buyerId,
financeType,
amountYuan.toFixed(2),
`${reason || '订单退款'} (${order.order_no || order.id})`,
orderId,
]);
}
return { viaWechat: false };
}
// 扫描并执行订单超时自动流转 (15分钟待付款自动取消 / 30分钟待接单自动退款 / 48小时待确认自动完成结算)
async processOrderTimeouts(): Promise<{ cancelledCount: number; refundedCount: number; completedCount: number }> {
let cancelledCount = 0;
@@ -1134,18 +1256,18 @@ export class MysqlRepository {
[ord.id]
);
if (upd && upd.affectedRows === 1) {
refundedCount += 1;
const buyerId = Number(ord.buyer_id || 1001);
const refundAmt = Number(ord.amount || 0);
if (buyerId > 0 && refundAmt > 0) {
await pool.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [refundAmt, buyerId]);
const ts = Date.now();
const rand = Math.floor(100 + Math.random() * 900);
await pool.query(
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '超时未接单自动退款', ?, 'IN', ?, ?, NOW())`,
[`fin_tmo_${ts}_${rand}`, `TO${ts}${rand}`, buyerId, refundAmt, `超时未接单退款 (${ord.order_no || ord.id})`, String(ord.id)]
);
try {
if (refundAmt > 0) {
await this.refundOrderPayment(pool as any, ord, Math.round(refundAmt * 100), '超时未接单退款', '超时未接单自动退款', 'RFND_TMO');
}
refundedCount += 1;
} catch (e) {
// 退款失败(如微信原路退款异常):回滚状态,等待下次扫描重试,避免"已退款但未到账"
try {
await pool.query("UPDATE orders SET status = '待接单', refund_amount = 0 WHERE id = ?", [ord.id]);
} catch (_) {}
logServerError('processOrderTimeouts.refund', e);
}
}
}
@@ -1439,22 +1561,8 @@ export class MysqlRepository {
if (st === '待接单') {
const refundAmount = Number(order.amount || 0);
const buyerId = Number(order.buyer_id || 0);
await conn.query('UPDATE orders SET status = "已退款", refund_amount = ? WHERE id = ?', [refundAmount, order.id]);
if (buyerId > 0 && refundAmount > 0) {
await conn.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [refundAmount, buyerId]);
await conn.query(`
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '买家取消订单退款', ?, 'IN', ?, ?, NOW())
`, [
`fin_cx_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`,
`CX${Date.now()}${Math.floor(100 + Math.random() * 900)}`,
buyerId,
refundAmount.toFixed(2),
`订单 ${order.order_no || order.id}`,
String(order.id)
]);
}
await this.refundOrderPayment(conn, order, Math.round(refundAmount * 100), '买家取消订单', '买家取消订单退款', 'RFND_CX');
} else {
await conn.query('UPDATE orders SET status = "已取消" WHERE id = ?', [order.id]);
}
@@ -1554,24 +1662,9 @@ export class MysqlRepository {
[rejectNote.slice(0, 60), order.id]
);
const buyerId = Number(order.buyer_id || 1001);
const refundAmount = Number(order.amount || 0);
if (buyerId > 0 && refundAmount > 0) {
await conn.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [refundAmount, buyerId]);
const ts = Date.now();
const rand = Math.floor(100 + Math.random() * 900);
await conn.query(
`INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '大神拒单自动退款', ?, 'IN', ?, ?, NOW())`,
[
`fin_rej_${ts}_${rand}`,
`RJ${ts}${rand}`,
buyerId,
refundAmount,
`大神拒单退款 (${order.order_no || order.id})`,
String(order.id)
]
);
if (refundAmount > 0) {
await this.refundOrderPayment(conn, order, Math.round(refundAmount * 100), '大神拒单', '大神拒单自动退款', 'RFND_REJ');
}
await conn.commit();
@@ -2774,18 +2867,9 @@ export class MysqlRepository {
// 1. 更新订单为已全额退款
await conn.query('UPDATE orders SET status = "已全额退款", refund_amount = ? WHERE id = ?', [refundAmount, order.id]);
// 2. 将订单金额真实退回买家钱包余额
const buyerId = Number(order.buyer_id || 1001);
if (buyerId > 0 && refundAmount > 0) {
await conn.query('UPDATE users SET balance = balance + ? WHERE id = ?', [refundAmount, buyerId]);
// 3. 记录财务退款入账流水
const recId = `fin_ref_${Date.now()}`;
const recNo = `TK${Date.now()}`;
await conn.query(`
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '仲裁退款', ?, 'IN', ?, ?, NOW())
`, [recId, recNo, buyerId, refundAmount, `平台仲裁退款 (${order.order_no})`, String(order.id)]);
// 2. 原路退款:微信支付订单走微信退款 API,余额支付退回钱包余额
if (refundAmount > 0) {
await this.refundOrderPayment(conn, order, Math.round(refundAmount * 100), '平台仲裁退款', '仲裁退款', 'RFND_ARB');
}
// 4. 联动扣减违规大神评分
@@ -2803,15 +2887,9 @@ export class MysqlRepository {
await conn.query('UPDATE orders SET status = "已部分退款", refund_amount = ? WHERE id = ?', [partialAmt, order.id]);
const buyerId = Number(order.buyer_id || 1001);
if (buyerId > 0 && partialAmt > 0) {
await conn.query('UPDATE users SET balance = balance + ? WHERE id = ?', [partialAmt, buyerId]);
const recId = `fin_pref_${Date.now()}`;
const recNo = `PTK${Date.now()}`;
await conn.query(`
INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at)
VALUES (?, ?, ?, '仲裁部分退款', ?, 'IN', ?, ?, NOW())
`, [recId, recNo, buyerId, partialAmt, `平台仲裁部分退款 (${order.order_no})`, String(order.id)]);
// 原路退款(微信支付→微信退款;余额支付→退回余额)
if (partialAmt > 0) {
await this.refundOrderPayment(conn, order, Math.round(partialAmt * 100), '平台仲裁部分退款', '仲裁部分退款', 'RFND_ARBP');
}
// 剩余未退部分解冻结算给大神

查看文件

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