feat(pay): 接入微信支付V3原路退款(按支付渠道分流 + 幂等 + 退款回调)
- 新增微信退款客户端 createRefund/queryRefundByOutRefundNo(/v3/refund/domestic/refunds) - 新增退款桥接模块与 payment_refunds 幂等退款记录表 - 订单退款按渠道分流:微信支付原路退回微信;余额支付退回钱包余额;微信退款失败(502)不回退余额,防重复退款 - 覆盖拒单/买家取消/仲裁全额与部分/超时自动退款;新增 /api/pay/wechat/refund-notify 回调 - 后台新增退款回调地址配置;文档补充退款验收说明 - 新增回归护栏 wechat-refund.spec.ts(77 套件/605 用例全绿)
这个提交包含在:
@@ -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);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第六十一批回归闸门:微信原路退款接入(按支付渠道分流 + 幂等)
|
||||
*/
|
||||
describe('第六十一批微信原路退款闸门', () => {
|
||||
const rootDir = path.resolve(__dirname, '../../');
|
||||
const payV3 = fs.readFileSync(path.join(rootDir, 'server/src/core/pay/wechat-pay-v3.ts'), 'utf-8');
|
||||
const bridge = fs.readFileSync(path.join(rootDir, 'server/src/core/pay/refund-bridge.ts'), 'utf-8');
|
||||
const repoCode = fs.readFileSync(path.join(rootDir, 'server/src/db/repository.ts'), 'utf-8');
|
||||
const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8');
|
||||
const mysqlCode = fs.readFileSync(path.join(rootDir, 'server/src/db/mysql.ts'), 'utf-8');
|
||||
|
||||
it('支付客户端具备退款与退款查单能力', () => {
|
||||
expect(payV3).toContain('export async function createRefund(');
|
||||
expect(payV3).toContain("'/v3/refund/domestic/refunds'");
|
||||
expect(payV3).toContain('export async function queryRefundByOutRefundNo(');
|
||||
});
|
||||
|
||||
it('退款桥接模块可注入执行器', () => {
|
||||
expect(bridge).toContain('export function setWeChatRefundFn(');
|
||||
expect(bridge).toContain('export async function tryRefundToWeChat(');
|
||||
});
|
||||
|
||||
it('仓储层按支付渠道分流退款并支持幂等记录', () => {
|
||||
expect(repoCode).toContain('private async refundOrderPayment(');
|
||||
expect(repoCode).toContain('private async orderPaidViaWechat(');
|
||||
expect(repoCode).toContain('async getRefundByOutRefundNo(');
|
||||
expect(repoCode).toContain('async upsertRefundRecord(');
|
||||
// 四个退款入口均使用统一分流
|
||||
const count = (repoCode.match(/this\.refundOrderPayment\(/g) || []).length;
|
||||
expect(count).toBeGreaterThanOrEqual(5);
|
||||
expect(mysqlCode).toContain('CREATE TABLE IF NOT EXISTS payment_refunds');
|
||||
});
|
||||
|
||||
it('后端注册退款执行器并提供退款回调', () => {
|
||||
expect(indexCode).toContain('setWeChatRefundFn(async');
|
||||
expect(indexCode).toContain("app.post('/api/pay/wechat/refund-notify'");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户