From 8edffbefa29fd2697bc623a205c73a7985de78a0 Mon Sep 17 00:00:00 2001
From: xcbyy <2578898740@qq.com>
Date: Mon, 19 Oct 2026 08:47:08 +0800
Subject: [PATCH] =?UTF-8?q?feat(pay):=20=E6=8E=A5=E5=85=A5=E5=BE=AE?=
=?UTF-8?q?=E4=BF=A1=E6=94=AF=E4=BB=98V3=E5=8E=9F=E8=B7=AF=E9=80=80?=
=?UTF-8?q?=E6=AC=BE=EF=BC=88=E6=8C=89=E6=94=AF=E4=BB=98=E6=B8=A0=E9=81=93?=
=?UTF-8?q?=E5=88=86=E6=B5=81=20+=20=E5=B9=82=E7=AD=89=20+=20=E9=80=80?=
=?UTF-8?q?=E6=AC=BE=E5=9B=9E=E8=B0=83=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增微信退款客户端 createRefund/queryRefundByOutRefundNo(/v3/refund/domestic/refunds)
- 新增退款桥接模块与 payment_refunds 幂等退款记录表
- 订单退款按渠道分流:微信支付原路退回微信;余额支付退回钱包余额;微信退款失败(502)不回退余额,防重复退款
- 覆盖拒单/买家取消/仲裁全额与部分/超时自动退款;新增 /api/pay/wechat/refund-notify 回调
- 后台新增退款回调地址配置;文档补充退款验收说明
- 新增回归护栏 wechat-refund.spec.ts(77 套件/605 用例全绿)
---
.../src/views/finance/config/index.vue | 8 +
docs/部署验收与1分钱支付闭环.md | 20 +-
server/src/core/pay/refund-bridge.ts | 41 ++++
server/src/core/pay/wechat-pay-v3.ts | 71 ++++++
server/src/db/mysql.ts | 18 ++
server/src/db/repository.ts | 206 ++++++++++++------
server/src/index.ts | 89 ++++++++
server/tests/wechat-refund.spec.ts | 42 ++++
8 files changed, 428 insertions(+), 67 deletions(-)
create mode 100644 server/src/core/pay/refund-bridge.ts
create mode 100644 server/tests/wechat-refund.spec.ts
diff --git a/admin/arco-design-pro-vite/src/views/finance/config/index.vue b/admin/arco-design-pro-vite/src/views/finance/config/index.vue
index 8a30f36..a6ce8eb 100644
--- a/admin/arco-design-pro-vite/src/views/finance/config/index.vue
+++ b/admin/arco-design-pro-vite/src/views/finance/config/index.vue
@@ -318,6 +318,13 @@
allow-clear
/>
+
+
+
,
transferSceneReportInfosText: '',
diff --git a/docs/部署验收与1分钱支付闭环.md b/docs/部署验收与1分钱支付闭环.md
index de19fde..918faa0 100644
--- a/docs/部署验收与1分钱支付闭环.md
+++ b/docs/部署验收与1分钱支付闭环.md
@@ -97,7 +97,21 @@ MYSQL_DATABASE=peiwan
验收:提现 1 元 → 后台通过 → 观察工单由"处理中"→"已打款"、大神微信零钱到账;人为失败(如场景ID 错误)应置"打款失败"并退回佣金。
-## 六、微信订阅消息(服务通知)配置与验收
+## 六、订单退款(微信原路退款)
+
+订单退款现在按**支付渠道分流**:
+- 微信支付订单 → 调用微信 V3 退款接口 `POST /v3/refund/domestic/refunds`,**原路退回买家微信零钱/银行卡**;
+- 余额支付订单 → 退回平台钱包余额。
+
+说明:
+- 退款单号 `out_refund_no` 幂等(同一订单重复退款不会重复出款),退款记录落 `payment_refunds` 表;
+- 退款结果回调:`POST /api/pay/wechat/refund-notify`(后台可配置 `refund_notify_url`,默认 `https://peiww.gynhc.com/api/pay/wechat/refund-notify`),未回调时以微信退款受理状态为准;
+- **微信支付订单若退款接口调用失败,不会改退余额**(避免重复退款),接口返回 502,订单保持原状态可重试;超时自动退款失败会自动回滚状态等待下次重试。
+- 若未配置微信支付 LIVE/就绪,微信支付订单退款会返回 502(不会静默改退余额);余额支付订单不受影响。
+
+验收:用 LIVE 支付 1 元订单 → 拒单/仲裁全额退款 → 买家微信收到原路退款,`payment_refunds` 记录状态更新。
+
+## 七、微信订阅消息(服务通知)配置与验收
后台「平台配置与协议规则 → 微信服务通知与模板」维护:总开关 `enablePush` + 4 类模板ID(订单状态 / 组局报名 / 提现到账 / 核销码)。
@@ -109,10 +123,10 @@ MYSQL_DATABASE=peiwan
- **小程序授权**:设置页开启通知时用**真实模板ID** 调 `wx.requestSubscribeMessage` 收集授权。订阅为**一次性**,需在关键动作前再次请求(后续可在下单/报名/提现前补请求)。
- **启动自检**:不额外打印;如需可在后台测试推送处验证。
-## 七、内容安全(可选)
+## 八、内容安全(可选)
后台填入真实小程序 `appId` 与 32 位 `appSecret` 后,内容检测会真实调用微信 `msgSecCheck`;未配置时自动跳过远端、仅跑本地敏感词库(fail-open,不影响业务)。
-## 八、回滚
+## 九、回滚
本轮改动遵循"边界内最小改动",无破坏性数据库结构变更。如需回滚,按部署前的文件/库备份还原即可。
diff --git a/server/src/core/pay/refund-bridge.ts b/server/src/core/pay/refund-bridge.ts
new file mode 100644
index 0000000..93fbe6b
--- /dev/null
+++ b/server/src/core/pay/refund-bridge.ts
@@ -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;
+
+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 {
+ 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) };
+ }
+}
diff --git a/server/src/core/pay/wechat-pay-v3.ts b/server/src/core/pay/wechat-pay-v3.ts
index 228952b..b55f35c 100644
--- a/server/src/core/pay/wechat-pay-v3.ts
+++ b/server/src/core/pay/wechat-pay-v3.ts
@@ -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 {
+ 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 {
+ 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;
diff --git a/server/src/db/mysql.ts b/server/src/db/mysql.ts
index 30c7909..ff2ff8b 100644
--- a/server/src/db/mysql.ts
+++ b/server/src/db/mysql.ts
@@ -253,6 +253,24 @@ export async function initDatabase(): Promise {
) 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 (
diff --git a/server/src/db/repository.ts b/server/src/db/repository.ts
index a7d5184..a696c8b 100644
--- a/server/src/db/repository.ts
+++ b/server/src/db/repository.ts
@@ -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 {
+ 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 {
+ 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');
}
// 剩余未退部分解冻结算给大神
diff --git a/server/src/index.ts b/server/src/index.ts
index 4e30d8a..e37b6e9 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -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);
diff --git a/server/tests/wechat-refund.spec.ts b/server/tests/wechat-refund.spec.ts
new file mode 100644
index 0000000..5ed1fd2
--- /dev/null
+++ b/server/tests/wechat-refund.spec.ts
@@ -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'");
+ });
+});