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