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

查看文件

@@ -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');
}
// 剩余未退部分解冻结算给大神