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'"); + }); +});