소스 검색

fix: 支付回调幂等加固——未入账不再应答200(改为500等待微信重试)、回调锁改为事务提交后释放、t_pay_log唯一索引兜底重复入账

事故背景:2026-10-01 21:06 商户订单号 202610012106065931549256(30元)的微信支付回调
被 Redis 幂等锁误判为"重复通知",旧代码在未做任何入账的情况下应答 HTTP 200,
微信认为处理成功即停止重试,用户充值永久丢失(全天仅此 1 例,定位到 WxPayServiceImpl:311 分支)。

修复内容:
- wxNotify:锁被占用时不再回 200,改为回 500 让微信按策略(15s/15s/30s/3m…)重试;
  只有确认该笔充值已入账(payLog 存在)才应答 200,最坏是延迟到账,不会丢单
- 回调锁由 finally 提前释放改为「事务提交成功后释放」,回滚/异常时保留锁由 TTL(30s) 过期,
  避免临界区内并发重复通知穿透尚未提交的 payLog 快路径重复入账
- t_pay_log.out_trade_no 唯一索引兜底:DuplicateKeyException 时整体回滚并判定为重复通知
- 清理因锁释放迁移而空转的内层 try 块

SQL 变更:
- database/8.sql:唯一索引迁移(含执行前重复数据检查、上线后对账 1/2/3)
- database/8-rollback.sql:索引回滚
- database/8-dedupe-pay-log.sql:重复订单号诊断/定损/去重流程
  (本次生产 10 组重复已人工清理,文件保留备查;定损追回业务决定不执行)
- database/8-incident-20261001-compensation.sql:本次事故订单一次性补单
skyline 1 주 전
부모
커밋
cd1b87602f

+ 228 - 0
database/8-dedupe-pay-log.sql

@@ -0,0 +1,228 @@
+-- ============================================================
+-- t_pay_log 重复商户订单号:诊断 + 去重(建唯一索引的前置步骤)
+--
+-- 【执行状态 · 2026-10-02】生产库残留的 10 组重复已由人工手工清理完毕。
+--   本文件不再需要执行,仅保留备查;若日后 8.sql 的【执行前检查 1】再次返回结果,
+--   可按本文件流程处理。
+--   【3.定损】只用于评估历史多入账金额,业务已决定不追溯、不追回,跳过即可。
+--   清理结果仍必须通过 8.sql 末尾的【上线后对账 1/2/3】验收(那属于数据正确性,与追款无关)。
+--
+-- 背景:8.sql 的执行前检查 1 返回了 10 条重复(同一 out_trade_no 有多行)。
+--       同一个 out_trade_no 只可能来自同一笔微信支付,多行意味着该笔回调被处理了多次,
+--       或有人工补录。必须先去重才能建 uk_pay_log_out_trade_no。
+--
+-- ⚠️ 顺序:先跑【1.诊断】→【2.判定】→【3.定损】→【4.备份并去重】→ 再回到 8.sql 建索引。
+-- ⚠️ 删除 pay_log 行 = 把"账本"改成唯一,**不等于**把多充进余额的钱收回;这两件事分开决策(见第 3 步)。
+-- ⚠️ 第 4 步只自动处理"同一用户 + 同一交易号 + 同一金额"的明确重复组,
+--    其余组会列出来要求人工判断,避免误删别人真实收款的记录。
+-- ============================================================
+
+
+-- ============================================================
+-- 【1.诊断】10 组重复的画像
+-- ============================================================
+SELECT g.out_trade_no,
+       g.cnt                                   AS `行数`,
+       g.tx_cnt                                AS `交易号种类`,
+       g.user_cnt                              AS `用户数`,
+       g.amount_cnt                            AS `金额种类`,
+       g.first_create                          AS `首次记录时间`,
+       g.last_create                           AS `末次记录时间`,
+       TIMESTAMPDIFF(SECOND, g.first_create, g.last_create) AS `间隔秒`,
+       g.ids,
+       g.txs
+FROM (
+    SELECT out_trade_no,
+           COUNT(*)                        AS cnt,
+           COUNT(DISTINCT transaction_id)  AS tx_cnt,
+           COUNT(DISTINCT user_id)         AS user_cnt,
+           COUNT(DISTINCT total)           AS amount_cnt,
+           MIN(create_time)                AS first_create,
+           MAX(create_time)                AS last_create,
+           GROUP_CONCAT(id ORDER BY id)             AS ids,
+           GROUP_CONCAT(transaction_id ORDER BY id) AS txs
+    FROM charge_app.t_pay_log
+    GROUP BY out_trade_no
+    HAVING COUNT(*) > 1
+) g
+ORDER BY g.first_create;
+
+
+-- ============================================================
+-- 【2.判定】逐行看全字段(人工确认每条重复的成因,别盲删)
+-- ============================================================
+SELECT p.*
+FROM charge_app.t_pay_log p
+JOIN (
+    SELECT out_trade_no
+    FROM charge_app.t_pay_log
+    GROUP BY out_trade_no
+    HAVING COUNT(*) > 1
+) d ON d.out_trade_no = p.out_trade_no
+ORDER BY p.out_trade_no, p.id;
+
+-- 判定口径:
+--   交易号种类 = 1 且 间隔秒很小(秒级)  → 同一笔回调被并发/重试处理了两次:真实资金只收了一次,
+--                                        多出的那行对应"多入账",保留 MIN(id),其余删除(第 3、4 步)
+--   交易号种类 = 1 且 间隔很大(天/月)    → 可能是事后人工补录(历史上的补单):
+--                                        先确认当时余额是否已补过,再决定删哪行
+--   交易号种类 > 1                       → 异常数据,逐条人工判断,不要批量删
+--   用户数 > 1                           → 订单号撞号(不同用户同一订单号),必须单独定损
+--   金额种类 > 1                         → 数据不一致,逐条人工判断
+
+-- 安全校验:确认没有其他表引用这些 pay_log 行
+-- (充值场景 wallet_detail.transaction_id 实际未写入,这里仍做一次兜底检查;有结果先处理引用再删)
+SELECT w.id, w.user_id, w.order_no, w.transaction_id
+FROM charge_app.t_wallet_detail w
+JOIN (
+    SELECT out_trade_no
+    FROM charge_app.t_pay_log
+    GROUP BY out_trade_no
+    HAVING COUNT(*) > 1
+) d ON d.out_trade_no = w.order_no
+WHERE w.transaction_id IS NOT NULL;
+
+
+-- ============================================================
+-- 【3.定损】多记了多少、涉及谁、有没有叠加影响退款
+-- ============================================================
+-- 3.1 疑似多入账金额("多出的行数 × 单笔金额"估算,单位:分)
+SELECT g.user_id,
+       COUNT(*)                        AS `重复订单数`,
+       SUM((g.cnt - 1) * g.total_each) AS `疑似多入账金额分`
+FROM (
+    SELECT user_id, out_trade_no, COUNT(*) AS cnt, MIN(total) AS total_each
+    FROM charge_app.t_pay_log
+    GROUP BY user_id, out_trade_no
+    HAVING COUNT(*) > 1
+) g
+GROUP BY g.user_id;
+
+-- 3.2 受影响用户的当前余额与充值口径(去重前后对照,用于和用户/财务核对)
+--     为避免依赖窗口函数(兼容 MySQL 5.7 / MariaDB 10.2 以下),用 MIN(id) 自连接实现
+SELECT g.user_id,
+       g.`重复订单数`,
+       g.`疑似多入账金额分`,
+       a.balance                    AS `当前余额分`,
+       a.discount_amount            AS `不可退金额分`,
+       w.wallet_recharge_total      AS `钱包已确认充值分`,
+       p.pay_log_total_with_dup     AS `pay_log合计_含重复分`,
+       p.pay_log_total_dedup        AS `pay_log合计_去重后分`
+FROM (
+    SELECT user_id, COUNT(*) AS `重复订单数`, SUM((cnt - 1) * total_each) AS `疑似多入账金额分`
+    FROM (
+        SELECT user_id, out_trade_no, COUNT(*) AS cnt, MIN(total) AS total_each
+        FROM charge_app.t_pay_log
+        GROUP BY user_id, out_trade_no
+        HAVING COUNT(*) > 1
+    ) x
+    GROUP BY user_id
+) g
+LEFT JOIN charge_app.t_account a ON a.user_id = g.user_id
+LEFT JOIN (
+    SELECT user_id, SUM(amount) AS wallet_recharge_total
+    FROM charge_app.t_wallet_detail
+    WHERE type = 1 AND status = 1
+    GROUP BY user_id
+) w ON w.user_id = g.user_id
+LEFT JOIN (
+    SELECT p.user_id,
+           SUM(p.total) AS pay_log_total_with_dup,
+           SUM(CASE WHEN p.id = k.keep_id THEN p.total ELSE 0 END) AS pay_log_total_dedup
+    FROM charge_app.t_pay_log p
+    LEFT JOIN (
+        SELECT out_trade_no, MIN(id) AS keep_id
+        FROM charge_app.t_pay_log
+        GROUP BY out_trade_no
+    ) k ON k.out_trade_no = p.out_trade_no
+    WHERE p.trade_state = 'SUCCESS'
+    GROUP BY p.user_id
+) p ON p.user_id = g.user_id
+ORDER BY g.`疑似多入账金额分` DESC;
+
+-- 3.3 是否有人靠"虚高的充值合计"通过了退款校验(applyWxRefund 用 SUM(pay_log.total) 判断可退额度)
+--     若有重复记录用户成功退款的,需单独评估是否超退
+SELECT r.user_id, r.out_trade_no, r.total, r.refund, r.status, r.create_time
+FROM charge_app.t_refund_log r
+WHERE r.user_id IN (
+    SELECT user_id FROM charge_app.t_pay_log
+    GROUP BY user_id, out_trade_no HAVING COUNT(*) > 1
+)
+ORDER BY r.user_id, r.create_time;
+
+-- 说明:第 3 步只做"定损"。是否向用户追回多入账金额属于业务/财务决策,
+--       不要在未经确认的情况下直接扣减余额(余额被扣成负数会影响后续充电与退款判断)。
+
+
+-- ============================================================
+-- 【4.备份并去重】
+-- ============================================================
+-- 4.1 备份全部重复组(完整审计留痕;确认无误后再由人工决定是否清理该备份表)
+CREATE TABLE charge_app.t_pay_log_dup_bak_20261002 AS
+SELECT p.*
+FROM charge_app.t_pay_log p
+JOIN (
+    SELECT out_trade_no
+    FROM charge_app.t_pay_log
+    GROUP BY out_trade_no
+    HAVING COUNT(*) > 1
+) d ON d.out_trade_no = p.out_trade_no;
+
+-- 4.2 需人工处理的"不安全组"(同一订单号跨用户/跨交易号/跨金额)——应为 0 行;
+--     若有结果,必须逐条人工确认后再决定,不要交给下面的自动删除
+SELECT out_trade_no,
+       COUNT(*)                       AS `行数`,
+       COUNT(DISTINCT user_id)        AS `用户数`,
+       COUNT(DISTINCT transaction_id) AS `交易号种类`,
+       COUNT(DISTINCT total)          AS `金额种类`,
+       GROUP_CONCAT(id ORDER BY id)   AS ids
+FROM charge_app.t_pay_log
+GROUP BY out_trade_no
+HAVING COUNT(*) > 1
+   AND (COUNT(DISTINCT user_id) > 1
+     OR COUNT(DISTINCT transaction_id) > 1
+     OR COUNT(DISTINCT total) > 1);
+
+-- 4.3 待删除行明细核对(仅"安全组":同一用户 + 同一交易号 + 同一金额,保留 MIN(id))
+SELECT p.id, p.out_trade_no, p.user_id, p.total, p.transaction_id, p.success_time, p.create_time
+FROM charge_app.t_pay_log p
+JOIN (
+    SELECT out_trade_no, MIN(id) AS keep_id
+    FROM charge_app.t_pay_log
+    GROUP BY out_trade_no
+    HAVING COUNT(*) > 1
+       AND COUNT(DISTINCT user_id) = 1
+       AND COUNT(DISTINCT transaction_id) = 1
+       AND COUNT(DISTINCT total) = 1
+) d ON d.out_trade_no = p.out_trade_no
+WHERE p.id <> d.keep_id
+ORDER BY p.out_trade_no, p.id;
+
+-- 4.4 正式删除(用临时表承接 keep_id,绕开 MySQL "不能在同一语句里对目标表做子查询" 的限制 1093)
+CREATE TEMPORARY TABLE tmp_pay_log_keep AS
+SELECT out_trade_no, MIN(id) AS keep_id
+FROM charge_app.t_pay_log
+GROUP BY out_trade_no
+HAVING COUNT(*) > 1
+   AND COUNT(DISTINCT user_id) = 1
+   AND COUNT(DISTINCT transaction_id) = 1
+   AND COUNT(DISTINCT total) = 1;
+
+DELETE p
+FROM charge_app.t_pay_log p
+JOIN tmp_pay_log_keep k ON k.out_trade_no = p.out_trade_no
+WHERE p.id <> k.keep_id;
+
+DROP TEMPORARY TABLE tmp_pay_log_keep;
+
+-- 4.5 复核:应为 0 行(若仍有行,说明都属于 4.2 的不安全组,需人工处理)
+SELECT out_trade_no, COUNT(*) AS cnt
+FROM charge_app.t_pay_log
+GROUP BY out_trade_no
+HAVING COUNT(*) > 1;
+
+
+-- ============================================================
+-- 【5.回到 8.sql】
+-- ============================================================
+-- 4.5 返回 0 行后,再执行 database/8.sql 建唯一索引 uk_pay_log_out_trade_no。

+ 63 - 0
database/8-incident-20261001-compensation.sql

@@ -0,0 +1,63 @@
+-- ============================================================
+-- 【一次性人工补偿】2026-10-01 微信充值 30 元未到账事故
+--
+-- 事故:商户订单号 202610012106065931549256,30.00 元
+--       微信交易号 4500000442202610019536073654,支付成功时间 2026-10-01 21:06:14
+--       用户 user_id = 1555324358568706048(手机号 15814577840)
+--       微信支付回调被 Redis 幂等锁误判为"重复通知"后应答 HTTP 200,
+--       微信不再重试,钱包流水一直停留在"待确认",余额未增加。
+--
+-- 说明:本文件只针对本次事故执行一次,不属于版本迁移(勿并入 8.sql 自动执行)。
+--       执行前请先跑【0.核对】,并在微信商户平台确认该笔订单确已收款。
+-- ============================================================
+
+-- 【0.核对】应返回 1 行、status = 0(待确认)
+SELECT id, user_id, type, order_no, amount, status, create_time
+FROM charge_app.t_wallet_detail
+WHERE order_no = '202610012106065931549256';
+
+-- 【0.核对】应返回 0 行(证明系统内确实没有入账记录)
+SELECT id FROM charge_app.t_pay_log WHERE out_trade_no = '202610012106065931549256';
+
+-- 【1.钱包流水置为已确认】status: 0待确认 -> 1已确认
+UPDATE charge_app.t_wallet_detail
+SET status = 1,
+    currency = 'CNY',
+    amount = 3000,
+    transaction_time = '2026-10-01 21:06:14'
+WHERE id = 1555324958656167936
+  AND order_no = '202610012106065931549256'
+  AND status = 0;
+
+-- 【2.补记支付记录】必须补:幂等快路径依赖 t_pay_log,
+--     缺了它以后再收到该订单回调会走不到快路径而重复入账
+--     <唯一ID> 请替换为雪花ID或任意未被占用的唯一值
+INSERT INTO charge_app.t_pay_log
+    (id, user_id, openid, bank_type, mch_id, out_trade_no, transaction_id, success_time,
+     trade_type, trade_state, attach, total, currency, payer_total, payer_currency)
+VALUES
+    (<唯一ID>, 1555324358568706048, 'oK-oR5g5Y9vkVeaa7wsoLt1yMGAY', 'BANK_CREDIT', '1635831469',
+     '202610012106065931549256', '4500000442202610019536073654', '2026-10-01 21:06:14',
+     'JSAPI', 'SUCCESS', '', 3000, 'CNY', 3000, 'CNY');
+
+-- 【3.补余额】
+UPDATE charge_app.t_account
+SET balance = balance + 3000
+WHERE user_id = 1555324358568706048;
+
+-- 【4.验证】
+SELECT u.id AS user_id, acc.balance, acc.discount_amount,
+       (acc.balance - acc.discount_amount) AS refundable_amount
+FROM charge_app.t_user u
+LEFT JOIN charge_app.t_account acc ON u.id = acc.user_id
+WHERE u.id = 1555324358568706048;
+
+-- 【5.全量排查同类受害者】列出当天所有"待确认"的充值流水,
+--     再与微信对账单逐笔核对(未支付就放弃的预下单也会是待确认,需用对账单确认哪些真的付了钱)
+SELECT w.id, w.user_id, w.order_no, w.amount, w.status, w.create_time
+FROM charge_app.t_wallet_detail w
+WHERE w.type = 1
+  AND w.status = 0
+  AND w.create_time >= '2026-10-01 00:00:00'
+  AND w.create_time < '2026-10-02 00:00:00'
+ORDER BY w.create_time;

+ 4 - 0
database/8-rollback.sql

@@ -0,0 +1,4 @@
+-- 回滚:移除支付回调幂等唯一索引
+-- 注意:回滚后代码层的 Redis 锁 + payLog 快路径仍是唯一防线,但不具备并发兜底能力,
+--       并发重复投递存在重复入账风险,仅在索引导致线上问题时才回滚。
+ALTER TABLE charge_app.t_pay_log DROP INDEX uk_pay_log_out_trade_no;

+ 56 - 0
database/8.sql

@@ -0,0 +1,56 @@
+-- ============================================================
+-- 支付回调幂等加固:t_pay_log.out_trade_no 唯一索引
+--
+-- 事故背景:2026-10-01 21:06 商户订单号 202610012106065931549256(30 元)
+--   微信支付回调被 Redis 幂等锁误判为"重复通知",代码在未做任何入账的情况下
+--   应答了 HTTP 200,微信认为处理成功即停止重试,用户充值永久丢失。
+--   修复后:Redis 锁仅用于削峰,幂等的最终保证是本唯一索引
+--   (并发投递时只有一个事务能落库,落库失败的一方整体回滚,不会重复加钱)。
+-- ============================================================
+
+-- 【执行前检查 1】是否已存在重复的商户订单号(正常应为 0 行;有结果必须先人工核对处理)
+--   若本查询有结果(当前生产库为 10 条),请先执行 database/8-dedupe-pay-log.sql
+--   完成"诊断 → 定损 → 备份 → 去重",返回 0 行后再继续本文件。
+SELECT out_trade_no, COUNT(*) AS cnt, MIN(id) AS keep_id, GROUP_CONCAT(id) AS ids
+FROM charge_app.t_pay_log
+GROUP BY out_trade_no
+HAVING COUNT(*) > 1;
+
+-- 【执行前检查 2】当前索引情况(若已存在 uk_pay_log_out_trade_no 则无需重复执行变更)
+SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = 'charge_app'
+  AND TABLE_NAME = 't_pay_log';
+
+-- 【正式变更】商户订单号唯一(微信侧 out_trade_no 本身唯一)
+ALTER TABLE charge_app.t_pay_log
+    ADD UNIQUE KEY uk_pay_log_out_trade_no (out_trade_no);
+
+-- 【验证】NON_UNIQUE 应为 0
+SELECT INDEX_NAME, NON_UNIQUE, COLUMN_NAME
+FROM information_schema.STATISTICS
+WHERE TABLE_SCHEMA = 'charge_app'
+  AND TABLE_NAME = 't_pay_log'
+  AND INDEX_NAME = 'uk_pay_log_out_trade_no';
+
+-- 【上线后对账 1】唯一性:应返回 0 行
+SELECT out_trade_no, COUNT(*) AS cnt
+FROM charge_app.t_pay_log
+GROUP BY out_trade_no
+HAVING COUNT(*) > 1;
+
+-- 【上线后对账 2】正向:已确认的充值流水必须都能在 t_pay_log 找到同一订单号,应返回 0 行
+--   有结果 = 该笔充值缺少支付记录(可能被清理误删,或历史人工补单),需逐条确认后补齐 pay_log,
+--   否则后续再收到该订单回调会走不到幂等快路径而重复入账
+SELECT w.id, w.user_id, w.order_no, w.amount, w.status, w.create_time
+FROM charge_app.t_wallet_detail w
+LEFT JOIN charge_app.t_pay_log p ON p.out_trade_no = w.order_no
+WHERE w.type = 1 AND w.status = 1 AND p.id IS NULL;
+
+-- 【上线后对账 3】反向:成功的支付记录必须都能找到对应的已确认充值流水,应返回 0 行
+--   有结果 = 微信收了钱但钱包流水没确认(本次事故就是这一类),需按 8-incident-*.sql 的方式补单
+SELECT p.id, p.user_id, p.out_trade_no, p.total, p.transaction_id, p.success_time
+FROM charge_app.t_pay_log p
+LEFT JOIN charge_app.t_wallet_detail w
+       ON w.order_no = p.out_trade_no AND w.type = 1 AND w.status = 1
+WHERE p.trade_state = 'SUCCESS' AND w.id IS NULL;

+ 105 - 60
service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -47,12 +47,15 @@ import lombok.SneakyThrows;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.core.io.ClassPathResource;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.data.redis.core.StringRedisTemplate;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.interceptor.TransactionAspectSupport;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 
 import java.io.ByteArrayOutputStream;
 import java.io.File;
@@ -303,77 +306,93 @@ public class WxPayServiceImpl implements WxPayService {
                     transaction.getAmount() == null ? null : transaction.getAmount().getTotal(),
                     transaction.getTradeState(), transaction.getSuccessTime());
             // 判断是否已经接收处理过通知(快路径)
+            // 注意:只有确认"该笔充值已入账"才允许应答 200。微信只认 200/204 为处理成功,
+            // 收到成功应答即停止重试(重试阶梯 15s/15s/30s/3m…),因此任何"未入账却回 200"的
+            // 分支都会造成用户资金永久丢失。
             if (payLogService.lambdaQuery().eq(PayLog::getOutTradeNo, transaction.getOutTradeNo()).one() != null) {
                 return ResponseEntity.status(HttpStatus.OK).build();
             }
 
-            // Redis锁防并发重复处理(微信会对未应答的通知重试)
+            // Redis锁仅用于削减并发(微信会对未应答的通知重试);
+            // 幂等的最终保证是 t_pay_log.out_trade_no 唯一索引,锁失效不会导致重复入账
             String notifyLockKey = RedisKeys.PAY_NOTIFY_LOCK + transaction.getOutTradeNo();
             Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(notifyLockKey, "1", 30, TimeUnit.SECONDS);
             if (Boolean.FALSE.equals(locked)) {
-                LOGGER.info("微信支付回调{}:订单{}正在处理中,忽略本次重复通知", notifyRes[2], transaction.getOutTradeNo());
-                return ResponseEntity.status(HttpStatus.OK).build();
+                // 未抢到锁:该订单的另一次投递正在处理,或存在残留锁。
+                // 此处绝不能回 200:本次没有完成任何入账,回 200 会让微信停止重试,充值将永久丢失。
+                // 返回 500 让微信按策略重试:重试时要么命中上面的已入账快路径,要么在锁过期后正常入账,
+                // 最坏只是延迟到账,不会丢单。
+                LOGGER.warn("微信支付回调{}:订单{}正在处理中,本次未入账,返回失败等待微信重试", notifyRes[2], transaction.getOutTradeNo());
+                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                        .body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "处理中,等待微信重试"));
             }
-            try {
-                DateTime dt = DateUtil.parse(transaction.getSuccessTime());
-                LocalDateTime successTime = LocalDateTimeUtil.of(dt);
-
-                // 钱包流水
-                var walletDetail = walletDetailService.getWalletDetailByOrderNo(transaction.getOutTradeNo(), WalletDetail.TYPE_充值);
-                if (walletDetail != null) {
-                    walletDetail.setStatus(WalletDetail.STATUS_已确认);  //已确认
-                    walletDetail.setCurrency(transaction.getAmount().getCurrency());
-                    walletDetail.setAmount(transaction.getAmount().getTotal());
-                    walletDetail.setTransactionTime(successTime);
-                    walletDetailService.updateById(walletDetail);
-
-                    // 更新余额
-                    accountService.lambdaUpdate().setSql("balance = (balance+%d)".formatted(transaction.getAmount().getTotal()))
-                            .eq(Account::getUserId, walletDetail.getUserId()).update();
-
-                    // 异步处理充值服务费打折权益活动相关逻辑
-                    activityService.handleRechargeActivity(walletDetail.getUserId(), transaction.getAmount().getTotal());
-
-                    // 支付记录
-                    var payLog = new PayLog();
-                    payLog.setUserId(walletDetail.getUserId());
-                    payLog.setOpenid(transaction.getPayer().getOpenid());
-                    payLog.setBankType(transaction.getBankType());
-                    payLog.setMchId(transaction.getMchid());
-                    payLog.setOutTradeNo(transaction.getOutTradeNo());
-                    payLog.setTransactionId(transaction.getTransactionId());
-                    payLog.setSuccessTime(successTime);
-                    payLog.setTradeType(transaction.getTradeType().name());
-                    payLog.setTradeState(transaction.getTradeState().name());
-                    payLog.setAttach(transaction.getAttach());
-                    payLog.setTotal(transaction.getAmount().getTotal());
-                    payLog.setCurrency(transaction.getAmount().getCurrency());
-                    payLog.setPayerTotal(transaction.getAmount().getPayerTotal());
-                    payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
+            // 锁在事务提交成功后才释放;回滚/异常时保留锁由 TTL(30s) 过期,避免并发重复入账
+            releaseNotifyLockAfterCommit(notifyLockKey);
+            DateTime dt = DateUtil.parse(transaction.getSuccessTime());
+            LocalDateTime successTime = LocalDateTimeUtil.of(dt);
+
+            // 钱包流水
+            var walletDetail = walletDetailService.getWalletDetailByOrderNo(transaction.getOutTradeNo(), WalletDetail.TYPE_充值);
+            if (walletDetail != null) {
+                walletDetail.setStatus(WalletDetail.STATUS_已确认);  //已确认
+                walletDetail.setCurrency(transaction.getAmount().getCurrency());
+                walletDetail.setAmount(transaction.getAmount().getTotal());
+                walletDetail.setTransactionTime(successTime);
+                walletDetailService.updateById(walletDetail);
+
+                // 更新余额
+                accountService.lambdaUpdate().setSql("balance = (balance+%d)".formatted(transaction.getAmount().getTotal()))
+                        .eq(Account::getUserId, walletDetail.getUserId()).update();
+
+                // 异步处理充值服务费打折权益活动相关逻辑
+                activityService.handleRechargeActivity(walletDetail.getUserId(), transaction.getAmount().getTotal());
+
+                // 支付记录
+                var payLog = new PayLog();
+                payLog.setUserId(walletDetail.getUserId());
+                payLog.setOpenid(transaction.getPayer().getOpenid());
+                payLog.setBankType(transaction.getBankType());
+                payLog.setMchId(transaction.getMchid());
+                payLog.setOutTradeNo(transaction.getOutTradeNo());
+                payLog.setTransactionId(transaction.getTransactionId());
+                payLog.setSuccessTime(successTime);
+                payLog.setTradeType(transaction.getTradeType().name());
+                payLog.setTradeState(transaction.getTradeState().name());
+                payLog.setAttach(transaction.getAttach());
+                payLog.setTotal(transaction.getAmount().getTotal());
+                payLog.setCurrency(transaction.getAmount().getCurrency());
+                payLog.setPayerTotal(transaction.getAmount().getPayerTotal());
+                payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
+                // t_pay_log.out_trade_no 唯一索引兜底:并发投递时只有一个事务能落库,
+                // 落库失败的这次整体回滚(含重复增加的余额),避免重复入账
+                try {
                     payLogService.save(payLog);
-                    LOGGER.info("微信支付回调{}:业务处理结束", notifyRes[2]);
-
-                    // 如果存在进行中的订单,则向en+更新用户余额(朗新不支持此操作)
-                    // 平台同步为附加操作,失败时不能回滚充值事务,仅记录日志待补偿
-                    try {
-                        var chargingOrder = chargeOrderService.getChargingOrderByUserId(walletDetail.getUserId());
-                        if (chargingOrder != null && "EN_PLUS".equals(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()))) {
-                            var account = accountService.getAccountByUserId(walletDetail.getUserId());
-                            // TODO: 2023-11-30 快充这里考虑过充的金额要提高
-                            var res = platformApiService.updateBalanceByQueryEquipChargeStatus(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()), chargingOrder.getStartChargeSeq(), account.getBalance() - 50);
-                            LOGGER.info("用户:{}充电过程中充值,已更新EN_PLUS充电金额,en+返回数据:{}", account.getUserId(), res);
-                        }
-                    } catch (Exception e) {
-                        LOGGER.error("微信支付回调{}:充值成功,但同步EN_PLUS平台余额失败,用户:{}", notifyRes[2], walletDetail.getUserId(), e);
-                    }
+                } catch (DuplicateKeyException e) {
+                    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
+                    LOGGER.warn("微信支付回调{}:订单{}已存在支付记录,判定为重复通知,本次回滚不入账",
+                            notifyRes[2], transaction.getOutTradeNo());
                     return ResponseEntity.status(HttpStatus.OK).build();
-
-                } else {
-                    LOGGER.error("微信支付通知处理异常,资金流水为空,回调信息:{}", transaction);
-                    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "资金流水为空"));
                 }
-            } finally {
-                stringRedisTemplate.delete(notifyLockKey);
+                LOGGER.info("微信支付回调{}:业务处理结束", notifyRes[2]);
+
+                // 如果存在进行中的订单,则向en+更新用户余额(朗新不支持此操作)
+                // 平台同步为附加操作,失败时不能回滚充值事务,仅记录日志待补偿
+                try {
+                    var chargingOrder = chargeOrderService.getChargingOrderByUserId(walletDetail.getUserId());
+                    if (chargingOrder != null && "EN_PLUS".equals(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()))) {
+                        var account = accountService.getAccountByUserId(walletDetail.getUserId());
+                        // TODO: 2023-11-30 快充这里考虑过充的金额要提高
+                        var res = platformApiService.updateBalanceByQueryEquipChargeStatus(PlatformCache.INSTANCE.getPlatformNameByConnectorId(chargingOrder.getConnectorId()), chargingOrder.getStartChargeSeq(), account.getBalance() - 50);
+                        LOGGER.info("用户:{}充电过程中充值,已更新EN_PLUS充电金额,en+返回数据:{}", account.getUserId(), res);
+                    }
+                } catch (Exception e) {
+                    LOGGER.error("微信支付回调{}:充值成功,但同步EN_PLUS平台余额失败,用户:{}", notifyRes[2], walletDetail.getUserId(), e);
+                }
+                return ResponseEntity.status(HttpStatus.OK).build();
+
+            } else {
+                LOGGER.error("微信支付通知处理异常,资金流水为空,回调信息:{}", transaction);
+                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "资金流水为空"));
             }
         } catch (Exception e) {
             // 标记事务回滚,防止异常时部分提交
@@ -393,6 +412,32 @@ public class WxPayServiceImpl implements WxPayService {
         }
     }
 
+    /**
+     * 支付回调 Redis 锁:仅在事务成功提交后释放。
+     * <p>
+     * 原实现把删除放在 finally 中,而 finally 先于 {@link Transactional} 的提交执行,
+     * 锁并没有覆盖"入账 + 提交"这段临界区:临界区内到达的重复通知能穿过尚未提交的
+     * payLog 快路径再次入账(重复加钱)。改为提交后释放后,锁真正覆盖了整个临界区。
+     * <p>
+     * 回滚/异常时故意不删除,让锁随 TTL(30s) 自然过期:最坏结果是微信重试被推迟,
+     * 而不会出现重复入账或丢单。
+     */
+    private void releaseNotifyLockAfterCommit(String notifyLockKey) {
+        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+            // 理论上不会走到这里(wxNotify 带 @Transactional),兜底为立即释放
+            stringRedisTemplate.delete(notifyLockKey);
+            return;
+        }
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override
+            public void afterCompletion(int status) {
+                if (status == TransactionSynchronization.STATUS_COMMITTED) {
+                    stringRedisTemplate.delete(notifyLockKey);
+                }
+            }
+        });
+    }
+
     /**
      * 申请退款
      */