10 İşlemeler 27d0107946 ... 6dd45526cb

Yazar SHA1 Mesaj Tarih
  skyline 6dd45526cb fix: 支付回调幂等门闩移到余额更新之前 3 hafta önce
  skyline 780946e2aa fix: 资金操作接口增加权限校验,低权限账号不再能操作资金 3 hafta önce
  skyline 980ff6b712 fix: 结算确认增加条件更新门闩,并发重复确认不再双倍入账 3 hafta önce
  skyline f549d96e45 fix: 提现申请改为原子条件扣减,杜绝并发超提与负数金额 3 hafta önce
  skyline f229ed8cf6 fix: 充值归属站点改为服务端确定,客户端传值不再生效 3 hafta önce
  skyline 71367dda87 fix: 申请退款增加账户行锁,杜绝并发双退 3 hafta önce
  skyline dac5c3ef92 fix: 微信退款回调幂等与兜底 3 hafta önce
  skyline 47def8b074 fix: 订单结算增加条件更新门闩,杜绝并发双重扣款 3 hafta önce
  skyline 936fc87f86 fix: 微信支付回调幂等改造,唯一索引兜底并发重复投递 3 hafta önce
  skyline 310bde8039 fix: 资金关键表增加唯一索引,为支付/退款/结算提供幂等兜底 3 hafta önce

+ 8 - 0
car-wash-admin/src/main/java/com/kym/admin/controller/FinanceController.java

@@ -75,6 +75,7 @@ public class FinanceController {
      * @param params
      * @return
      */
+    @SaCheckPermission("withdrawnRecord.modify")
     @PostMapping("/applyWithdrawn")
     public R<?> applyWithdrawn(@RequestBody WithdrawnQueryParam params) {
         stationAccountService.applyWithdrawn(params);
@@ -98,6 +99,7 @@ public class FinanceController {
      * @param params
      * @return
      */
+    @SaCheckPermission("withdrawnRecord.modify")
     @PostMapping("/reviewWithdrawn")
     public R<?> reviewWithdrawn(@RequestBody WithdrawnQueryParam params) {
         withdrawnRecordService.reviewWithdrawn(params);
@@ -110,6 +112,7 @@ public class FinanceController {
      * @param params
      * @return
      */
+    @SaCheckPermission("withdrawnRecord.modify")
     @PostMapping("/confirmWithdrawnPayment")
     public R<?> confirmWithdrawnPayment(@RequestBody WithdrawnQueryParam params) {
         withdrawnRecordService.confirmWithdrawnPayment(params);
@@ -123,6 +126,7 @@ public class FinanceController {
      * @param refundLogId
      * @return
      */
+    @SaCheckPermission("refundLog.modify")
     @SysLog("处理用户微信退款")
     @GetMapping("/customWxRefund/{refundLogId}")
     R<?> customWxRefund(@PathVariable("refundLogId") long refundLogId) {
@@ -133,6 +137,7 @@ public class FinanceController {
     /**
      * 批量处理用户退款
      */
+    @SaCheckPermission("refundLog.modify")
     @SysLog("批量处理用户微信退款")
     @PostMapping("/batchWxRefund")
     R<?> batchWxRefund(@RequestBody java.util.List<Long> refundLogIds) {
@@ -159,6 +164,7 @@ public class FinanceController {
      * @param param
      * @return
      */
+    @SaCheckPermission("refundLog.modify")
     @SysLog("用户退款申请")
     @PostMapping("/applyRefund")
     @ResponseBody
@@ -178,6 +184,7 @@ public class FinanceController {
     /**
      * 手动触发结算(运维用)
      */
+    @SaCheckPermission("settlement.modify")
     @SysLog("手动触发月度结算")
     @PostMapping("/triggerSettlement")
     public R<?> triggerSettlement() {
@@ -188,6 +195,7 @@ public class FinanceController {
     /**
      * 确认结算 — 将待结算金额转入站点可提现余额
      */
+    @SaCheckPermission("settlement.modify")
     @SysLog("确认结算")
     @PostMapping("/confirmSettlement")
     public R<?> confirmSettlement(@RequestBody Map<String, Long> params) {

+ 69 - 0
car-wash-entity/src/main/resources/sql/v20_add_finance_idempotency_indexes.sql

@@ -0,0 +1,69 @@
+-- ====================================================
+-- v20: 资金关键表唯一索引(幂等兜底)
+-- 目的:为支付回调 / 退款回调 / 订单结算提供数据库层幂等兜底,
+--       防止并发回调、消息重投导致重复入账 / 重复扣减 / 重复分账。
+-- 涉及表:t_pay_log、t_wallet_detail、t_split_record
+--
+-- 执行前提(重要):
+--   1. 生产执行前先运行下方"查重"SQL,确认无重复数据;
+--      若存在重复,需先人工清理后再执行本脚本(否则建索引会失败)。
+--   2. 建议在业务低峰期执行(大表加唯一索引会锁表/耗时)。
+-- ====================================================
+
+-- ----------------------------
+-- 0. 查重检查(执行前先跑,以下 3 条应全部返回 0 行或空集)
+-- ----------------------------
+-- SELECT out_trade_no, COUNT(*) FROM t_pay_log GROUP BY out_trade_no HAVING COUNT(*) > 1;
+-- SELECT order_no, type, COUNT(*) FROM t_wallet_detail GROUP BY order_no, type HAVING COUNT(*) > 1;
+-- SELECT trade_no, type, from_station_id, to_station_id, COUNT(*) FROM t_split_record
+--     GROUP BY trade_no, type, from_station_id, to_station_id HAVING COUNT(*) > 1;
+
+-- ----------------------------
+-- 1. 幂等建唯一索引
+-- ----------------------------
+DROP PROCEDURE IF EXISTS add_finance_idempotency_indexes;
+DELIMITER //
+CREATE PROCEDURE add_finance_idempotency_indexes()
+BEGIN
+    -- t_pay_log.out_trade_no:微信商户订单号唯一,同一笔支付只允许处理一次
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_pay_log'
+          AND INDEX_NAME = 'uk_out_trade_no'
+    ) THEN
+        ALTER TABLE `t_pay_log` ADD UNIQUE KEY `uk_out_trade_no` (`out_trade_no`);
+    END IF;
+
+    -- t_wallet_detail(order_no, type):同一订单号同类型的钱包流水唯一
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_wallet_detail'
+          AND INDEX_NAME = 'uk_order_no_type'
+    ) THEN
+        ALTER TABLE `t_wallet_detail` ADD UNIQUE KEY `uk_order_no_type` (`order_no`, `type`);
+    END IF;
+
+    -- t_split_record(trade_no, type, from_station_id, to_station_id):
+    -- 同一交易同类型同流向的分账记录唯一(跨店 EXPEND/INCOME 两条 type 不同,互不冲突)
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.STATISTICS
+        WHERE TABLE_SCHEMA = DATABASE()
+          AND TABLE_NAME = 't_split_record'
+          AND INDEX_NAME = 'uk_trade_no_type_from_to'
+    ) THEN
+        ALTER TABLE `t_split_record`
+            ADD UNIQUE KEY `uk_trade_no_type_from_to` (`trade_no`, `type`, `from_station_id`, `to_station_id`);
+    END IF;
+END //
+DELIMITER ;
+CALL add_finance_idempotency_indexes();
+DROP PROCEDURE IF EXISTS add_finance_idempotency_indexes;
+
+-- ====================================================
+-- 验证:
+--   SHOW INDEX FROM t_pay_log WHERE Key_name = 'uk_out_trade_no';
+--   SHOW INDEX FROM t_wallet_detail WHERE Key_name = 'uk_order_no_type';
+--   SHOW INDEX FROM t_split_record WHERE Key_name = 'uk_trade_no_type_from_to';
+-- ====================================================

+ 52 - 0
car-wash-entity/src/main/resources/sql/v21_add_settlement_modify_permission.sql

@@ -0,0 +1,52 @@
+-- ====================================================
+-- v21: 新增 settlement.modify 权限(确认结算)
+-- 背景:FinanceController 的 triggerSettlement / confirmSettlement
+--       将增加 @SaCheckPermission("settlement.modify") 注解,
+--       需要先补齐权限数据,避免现有角色被 403 拦截。
+--
+-- 迁移策略:
+--   1. 新增权限记录 settlement.modify(挂到财务分组 pid=64)
+--   2. 自动授权给所有已拥有 settlement.list 的角色(保持现有功能不中断)
+--   3. 超管角色(id=1)在 v9 中拥有全部权限,此处同样补上
+-- ====================================================
+
+DROP PROCEDURE IF EXISTS add_settlement_modify_permission;
+DELIMITER //
+CREATE PROCEDURE add_settlement_modify_permission()
+BEGIN
+    -- 1. 新增权限记录(幂等)
+    IF NOT EXISTS (
+        SELECT 1 FROM information_schema.TABLES
+        WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 't_permission'
+    ) THEN
+        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 't_permission 表不存在,请先执行 init.sql';
+    END IF;
+
+    IF NOT EXISTS (
+        SELECT 1 FROM t_permission WHERE value = 'settlement.modify'
+    ) THEN
+        INSERT INTO `t_permission` (`id`, `company_id`, `name`, `value`, `pid`, `weight`)
+        VALUES (93, NULL, '确认结算', 'settlement.modify', 64, 7);
+    END IF;
+
+    -- 2. 授权给已拥有 settlement.list 的角色(管道分隔权限字符串,幂等)
+    UPDATE `t_role`
+    SET `permissions` = CONCAT(`permissions`, '|settlement.modify')
+    WHERE FIND_IN_SET('settlement.list', REPLACE(IFNULL(`permissions`, ''), '|', ',')) > 0
+      AND FIND_IN_SET('settlement.modify', REPLACE(IFNULL(`permissions`, ''), '|', ',')) = 0;
+
+    -- 3. 超管角色兜底(若其权限字符串未包含新权限)
+    UPDATE `t_role`
+    SET `permissions` = CONCAT(IFNULL(`permissions`, ''), '|settlement.modify')
+    WHERE `id` = 1
+      AND FIND_IN_SET('settlement.modify', REPLACE(IFNULL(`permissions`, ''), '|', ',')) = 0;
+END //
+DELIMITER ;
+CALL add_settlement_modify_permission();
+DROP PROCEDURE IF EXISTS add_settlement_modify_permission;
+
+-- ====================================================
+-- 验证:
+--   SELECT * FROM t_permission WHERE value = 'settlement.modify';
+--   SELECT id, role_name, permissions FROM t_role WHERE permissions LIKE '%settlement.modify%';
+-- ====================================================

+ 6 - 7
car-wash-miniapp/src/main/java/com/kym/miniapp/controller/PaymentController.java

@@ -27,7 +27,7 @@ public class PaymentController {
     }
 
     /**
-     * 充值
+     * 充值(归属站点由服务端按用户归属确定,客户端传值无效)
      *
      * @param rechargeConfigId
      * @return
@@ -35,8 +35,8 @@ public class PaymentController {
     @ApiLog("用户充值微信支付")
     @GetMapping("/wxPay")
     @ResponseBody
-    R<?> prepay(@RequestParam Long rechargeConfigId, @RequestParam(value = "stationId", required = false) String stationId) {
-        return R.success(wxPayService.wxPay(rechargeConfigId, stationId));
+    R<?> prepay(@RequestParam Long rechargeConfigId) {
+        return R.success(wxPayService.wxPay(rechargeConfigId));
     }
 
     @ApiLog(value = "微信回调", ignoreParams = true)
@@ -49,14 +49,13 @@ public class PaymentController {
 
 
     /**
-     * 专属优惠活动充值
+     * 专属优惠活动充值(归属站点由服务端按用户归属确定,客户端传值无效)
      */
     @ApiLog("用户优惠活动充值微信支付")
     @GetMapping("/promotionPay")
     @ResponseBody
-    R<?> promotionPay(@RequestParam String promotionToken,
-                      @RequestParam(value = "stationId", required = false) String stationId) {
-        return R.success(wxPayService.promotionPay(promotionToken, stationId));
+    R<?> promotionPay(@RequestParam String promotionToken) {
+        return R.success(wxPayService.promotionPay(promotionToken));
     }
 
     @ApiLog("用户申请退款")

+ 7 - 3
car-wash-service/src/main/java/com/kym/service/impl/OrderSettlementServiceImpl.java

@@ -59,9 +59,13 @@ public class OrderSettlementServiceImpl implements OrderSettlementService {
     public void settleOrder(WashOrder washOrder, OrderInfo orderInfo) {
         log.info("执行订单结算,订单:{},结算信息:{}", orderInfo.getOrder_id(), orderInfo);
 
-        // 幂等保护:重新加载订单并检查是否已结算
-        var freshOrder = washOrderService.lambdaQuery().eq(WashOrder::getId, washOrder.getId()).one();
-        if (freshOrder != null && Integer.valueOf(WashOrder.PAY_STATUS_已支付).equals(freshOrder.getPayStatus())) {
+        // 幂等门闩:条件更新占位(未支付 → 已支付),并发结算时第二个事务更新 0 行直接跳过
+        boolean claimed = washOrderService.lambdaUpdate()
+                .eq(WashOrder::getId, washOrder.getId())
+                .eq(WashOrder::getPayStatus, WashOrder.PAY_STATUS_未支付)
+                .set(WashOrder::getPayStatus, WashOrder.PAY_STATUS_已支付)
+                .update();
+        if (!claimed) {
             log.warn("订单:{},已结算,跳过重复结算", orderInfo.getOrder_id());
             return;
         }

+ 11 - 6
car-wash-service/src/main/java/com/kym/service/impl/SettlementServiceImpl.java

@@ -216,6 +216,17 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
             throw new IllegalArgumentException("仅待结算记录可确认");
         }
 
+        // 幂等门闩:条件更新(仅待结算可确认),并发重复确认时第二个事务更新 0 行直接失败,
+        // 余额转入与平台费记录不会重复执行
+        boolean claimed = lambdaUpdate()
+                .set(SettlementRecord::getStatus, SettlementRecord.STATUS_已结算)
+                .eq(SettlementRecord::getId, recordId)
+                .eq(SettlementRecord::getStatus, SettlementRecord.STATUS_待结算)
+                .update();
+        if (!claimed) {
+            throw new IllegalArgumentException("该记录已确认,不可重复操作");
+        }
+
         // 转入站点可提现余额
         stationAccountService.lambdaUpdate()
                 .setSql("available_balance = available_balance + {0}", record.getSettlementAmount())
@@ -228,12 +239,6 @@ public class SettlementServiceImpl extends MyBaseServiceImpl<SettlementRecordMap
             platformAccountService.addRevenue(record.getPlatformFee());
         }
 
-        // 更新状态为已结算
-        lambdaUpdate()
-                .set(SettlementRecord::getStatus, SettlementRecord.STATUS_已结算)
-                .eq(SettlementRecord::getId, recordId)
-                .update();
-
         log.info("结算确认完成,记录ID:{},站点:{},周期:{},金额:{} 分",
                 recordId, record.getStationId(), record.getSettlementPeriod(), record.getSettlementAmount());
     }

+ 12 - 3
car-wash-service/src/main/java/com/kym/service/impl/StationAccountServiceImpl.java

@@ -68,14 +68,23 @@ public class StationAccountServiceImpl extends MyBaseServiceImpl<StationAccountM
     @Override
     @Transactional(rollbackFor = Exception.class)
     public void applyWithdrawn(WithdrawnQueryParam params) {
+        // 金额正数校验,防止负数绕过余额检查反向增加余额
+        if (params.getAmount() == null || params.getAmount() <= 0) {
+            throw new BusinessException("提现金额必须大于0");
+        }
         var stationAccount = getStationAccount(params.getStationId());
-        if (stationAccount.getAvailableBalance() == null || stationAccount.getAvailableBalance() < params.getAmount()) {
-            throw new BusinessException("提现金额超出可提现金额!");
+        if (stationAccount == null) {
+            throw new BusinessException("站点账户不存在");
         }
-        lambdaUpdate()
+        // 原子条件扣减:余额不足或并发提现时影响行数为 0,不会把可提现余额扣成负数
+        boolean updated = lambdaUpdate()
                 .setSql("available_balance = available_balance - {0}, withdrawn_frozen_amount = withdrawn_frozen_amount + {0}", params.getAmount())
                 .eq(StationAccount::getStationId, params.getStationId())
+                .ge(StationAccount::getAvailableBalance, params.getAmount())
                 .update();
+        if (!updated) {
+            throw new BusinessException("提现金额超出可提现金额!");
+        }
 
         var withdrawnRecord = new WithdrawnRecord()
                 .setStationId(params.getStationId())

+ 2 - 2
car-wash-service/src/main/java/com/kym/service/wechat/WxPayService.java

@@ -26,12 +26,12 @@ public interface WxPayService {
     ResponseEntity<Object> wxRefundNotify(HttpServletRequest request);
 
     @Transactional
-    PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId, String stationId);
+    PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId);
 
     ResponseEntity<Object> wxNotify(HttpServletRequest request) throws IOException;
 
     @Transactional
-    PrepayWithRequestPaymentResponse promotionPay(String promotionToken, String stationId);
+    PrepayWithRequestPaymentResponse promotionPay(String promotionToken);
 
     void offlineRecharge(Long userId, Integer amount, Integer grantsAmount, String remark);
 

+ 98 - 51
car-wash-service/src/main/java/com/kym/service/wechat/impl/WxPayServiceImpl.java

@@ -41,6 +41,7 @@ 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.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
@@ -90,6 +91,7 @@ public class WxPayServiceImpl implements WxPayService {
     private final WashOrderService washOrderService;
     private final MpMsgTemplateService mpMsgTemplateService;
     private final RechargePromotionService rechargePromotionService;
+    private final UserService userService;
 
 
     /**
@@ -104,7 +106,8 @@ public class WxPayServiceImpl implements WxPayService {
                             ActivityService activityService,
                             RechargeConfigService rechargeConfigService, SplitRecordService splitRecordService,
                             WashOrderService washOrderService, MpMsgTemplateService mpMsgTemplateService,
-                            RechargePromotionService rechargePromotionService) {
+                            RechargePromotionService rechargePromotionService,
+                            UserService userService) {
         this.conf = conf;
         this.walletDetailService = walletDetailService;
         this.payLogService = payLogService;
@@ -115,6 +118,7 @@ public class WxPayServiceImpl implements WxPayService {
         this.washOrderService = washOrderService;
         this.mpMsgTemplateService = mpMsgTemplateService;
         this.rechargePromotionService = rechargePromotionService;
+        this.userService = userService;
     }
 
     /**
@@ -213,7 +217,7 @@ public class WxPayServiceImpl implements WxPayService {
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId, String stationId) {
+    public PrepayWithRequestPaymentResponse wxPay(Long rechargeConfigId) {
         // 充值配置
         var rechargeConfig = rechargeConfigService.getById(rechargeConfigId);
 
@@ -224,6 +228,8 @@ public class WxPayServiceImpl implements WxPayService {
         var rechargeAmount = rechargeConfig.getRechargeAmount();
         var openid = StpUtil.getSession().getString("openid");
         var userId = StpUtil.getLoginIdAsLong();
+        // 充值归属站点以服务端用户归属为准,不信任客户端传值(防止分账归属被篡改)
+        var stationId = getUserStationId(userId);
         // 生成订单号
         String outTradeNo = OrderUtils.getOrderNo();
         // 创建钱包流水
@@ -261,7 +267,7 @@ public class WxPayServiceImpl implements WxPayService {
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public PrepayWithRequestPaymentResponse promotionPay(String promotionToken, String stationId) {
+    public PrepayWithRequestPaymentResponse promotionPay(String promotionToken) {
         var promotion = rechargePromotionService.getByToken(promotionToken);
         if (promotion == null) {
             throw new BusinessException("优惠活动不存在或已过期");
@@ -273,6 +279,8 @@ public class WxPayServiceImpl implements WxPayService {
         }
 
         var openid = StpUtil.getSession().getString("openid");
+        // 充值归属站点以服务端用户归属为准,不信任客户端传值(防止分账归属被篡改)
+        var stationId = getUserStationId(userId);
         var outTradeNo = OrderUtils.getOrderNo();
 
         var walletDetail = new WalletDetail()
@@ -314,6 +322,18 @@ public class WxPayServiceImpl implements WxPayService {
         return jsapiService.queryOrderById(request);
     }
 
+    /**
+     * 服务端获取用户归属站点(KymCache 优先,未命中查库兜底),用于充值分账归属
+     */
+    private String getUserStationId(Long userId) {
+        var stationId = KymCache.INSTANCE.getUserStationId(userId);
+        if (stationId == null) {
+            var user = userService.getById(userId);
+            stationId = user != null ? user.getStationId() : null;
+        }
+        return stationId;
+    }
+
     /**
      * 关闭订单
      *
@@ -371,24 +391,8 @@ public class WxPayServiceImpl implements WxPayService {
                     grantsAmount = rechargeConfig.getGrantsAmount();
                 }
 
-                // 更新余额(赠款计入不可退优惠金额)
-                var account = accountService.getAccountByUserId(walletDetail.getUserId());
-                accountService.lambdaUpdate().setSql("balance = balance + {0}, recharge_balance = recharge_balance + {0}, grants_balance = grants_balance + {1}, discount_amount = discount_amount + {1}", transaction.getAmount().getTotal(), grantsAmount)
-                        .eq(Account::getUserId, walletDetail.getUserId()).update();
-
-                walletDetail.setStatus(WalletDetail.STATUS_已确认);  //已确认
-                walletDetail.setSource("WX_PAY");
-                walletDetail.setCurrency(transaction.getAmount().getCurrency());
-                walletDetail.setAmount(transaction.getAmount().getTotal());
-                walletDetail.setGrantsAmount(grantsAmount);
-                walletDetail.setBeforeBalance(account.getBalance());
-                walletDetail.setAfterBalance(account.getBalance() + walletDetail.getAmount());
-                walletDetail.setBeforeGrantsBalance(account.getGrantsBalance());
-                walletDetail.setAfterGrantsBalance(account.getGrantsBalance() + grantsAmount);
-                walletDetail.setTransactionTime(successTime);
-                walletDetailService.updateById(walletDetail);
-
-                // 支付记录
+                // 支付记录(幂等门闩:必须先落库 payLog,唯一索引 uk_out_trade_no 兜底并发重复回调;
+                // 若放在余额更新之后,重复回调会先重复加余额再被拦截)
                 var payLog = new PayLog();
                 payLog.setUserId(walletDetail.getUserId());
                 payLog.setOpenid(transaction.getPayer().getOpenid());
@@ -405,7 +409,30 @@ public class WxPayServiceImpl implements WxPayService {
                 payLog.setCurrency(transaction.getAmount().getCurrency());
                 payLog.setPayerTotal(transaction.getAmount().getPayerTotal());
                 payLog.setPayerCurrency(transaction.getAmount().getPayerCurrency());
-                payLogService.save(payLog);
+                try {
+                    payLogService.save(payLog);
+                } catch (DuplicateKeyException e) {
+                    // 并发重复回调:唯一索引拦截,视为已处理
+                    LOGGER.warn("微信支付回调重复投递已被唯一索引拦截,outTradeNo={}", transaction.getOutTradeNo());
+                    return ResponseEntity.status(HttpStatus.OK).build();
+                }
+
+                // 更新余额(赠款计入不可退优惠金额)
+                var account = accountService.getAccountByUserId(walletDetail.getUserId());
+                accountService.lambdaUpdate().setSql("balance = balance + {0}, recharge_balance = recharge_balance + {0}, grants_balance = grants_balance + {1}, discount_amount = discount_amount + {1}", transaction.getAmount().getTotal(), grantsAmount)
+                        .eq(Account::getUserId, walletDetail.getUserId()).update();
+
+                walletDetail.setStatus(WalletDetail.STATUS_已确认);  //已确认
+                walletDetail.setSource("WX_PAY");
+                walletDetail.setCurrency(transaction.getAmount().getCurrency());
+                walletDetail.setAmount(transaction.getAmount().getTotal());
+                walletDetail.setGrantsAmount(grantsAmount);
+                walletDetail.setBeforeBalance(account.getBalance());
+                walletDetail.setAfterBalance(account.getBalance() + walletDetail.getAmount());
+                walletDetail.setBeforeGrantsBalance(account.getGrantsBalance());
+                walletDetail.setAfterGrantsBalance(account.getGrantsBalance() + grantsAmount);
+                walletDetail.setTransactionTime(successTime);
+                walletDetailService.updateById(walletDetail);
 
                 // V2 结算方案:充值资金记录分账流水,结算日统一处理,不再即时转入站点账户
                 // 无归属站点的用户充值暂不生成分账记录,首次消费时追溯补建
@@ -429,15 +456,14 @@ public class WxPayServiceImpl implements WxPayService {
                 LOGGER.error("微信支付通知处理异常,资金流水为空,回调信息:{}", transaction);
                 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "资金流水为空"));
             }
-        } catch (Exception e) {
-            if (e instanceof ValidationException) {
-                // 签名验证失败,返回 401 UNAUTHORIZED 状态码
-                LOGGER.error("微信支付通知验签失败", e);
-            }
-            if (e instanceof BusinessException) {
-                LOGGER.error("业务异常", e);
-            }
+        } catch (ValidationException e) {
+            // 签名验证失败,返回 401 UNAUTHORIZED 状态码
+            LOGGER.error("微信支付通知验签失败", e);
             return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("code", HttpStatus.UNAUTHORIZED, "message", "验签失败"));
+        } catch (Exception e) {
+            // 业务处理失败返回 500,微信会重试投递;重试时由唯一索引/幂等快路径保证不会重复入账
+            LOGGER.error("微信支付通知处理异常", e);
+            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "处理失败"));
         }
     }
 
@@ -502,9 +528,14 @@ public class WxPayServiceImpl implements WxPayService {
             throw new BusinessException("存在未完结的订单,请完成所有订单完结之后重试");
         }
 
-        var account = accountService.getAccountByUserId(userId);
-        if (account.getBalance() <= 0) {
-            throw new BusinessException("账户余额不足,无需退款");
+        // 行锁读取账户,防止并发退款请求同时通过余额校验造成重复退款
+        var account = accountService.lambdaQuery().eq(Account::getUserId, userId).last("FOR UPDATE").one();
+        if (account == null) {
+            throw new BusinessException("用户账户不存在");
+        }
+        // 退款金额以充值余额为准(纯赠款余额不可退),校验与冻结/退款口径一致
+        if (account.getRechargeBalance() <= 0) {
+            throw new BusinessException("充值余额不足,无需退款");
         }
         // // 校验余额大于优惠金额
         // if (account.getBalance() <= account.getDiscountAmount()) {
@@ -699,9 +730,13 @@ public class WxPayServiceImpl implements WxPayService {
             RefundNotification refundNotification = ((NotificationParser) notifyRes[1]).parse((RequestParam) notifyRes[0], RefundNotification.class);
             LOGGER.info("微信退款回调{}:验签解密完毕,数据:\n{}", notifyRes[2], refundNotification);
 
-            //退款日志在申请时插入,接收通知时更新
+            // 退款日志在申请时插入,接收通知时更新
             var refundLog = refundLogService.lambdaQuery().eq(RefundLog::getOutRefundNo, refundNotification.getOutRefundNo()).one();
-            // 防止重复处理消息
+            if (refundLog == null) {
+                LOGGER.error("微信退款回调:未找到退款记录,outRefundNo={}", refundNotification.getOutRefundNo());
+                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "退款记录不存在"));
+            }
+            // 幂等快路径:已处理成功直接返回
             if (RefundLog.STATUS_退款成功.equals(refundLog.getStatus())) {
                 return ResponseEntity.status(HttpStatus.OK).build();
             }
@@ -709,28 +744,41 @@ public class WxPayServiceImpl implements WxPayService {
             DateTime dt = DateUtil.parse(refundNotification.getSuccessTime());
             LocalDateTime successTime = LocalDateTimeUtil.of(dt);
 
-            refundLogService.lambdaUpdate()
-                    .set(RefundLog::getRefundId, refundNotification.getRefundId())
-                    .set(RefundLog::getTransactionId, refundNotification.getTransactionId())
-                    .set(RefundLog::getUserReceivedAccount, refundNotification.getUserReceivedAccount())
-                    .set(RefundLog::getSuccessTime, successTime)
-                    .set(RefundLog::getStatus, refundNotification.getRefundStatus().name())
-                    .set(RefundLog::getTotal, refundNotification.getAmount().getTotal().intValue())
-                    .set(RefundLog::getRefund, refundNotification.getAmount().getRefund().intValue())
-                    .eq(RefundLog::getId, refundLog.getId()).update();
-
             if (RefundLog.STATUS_退款成功.equals(refundNotification.getRefundStatus().name())) {
+                // 资金流水必须在申请退款时已存在;检查放在门闩之前,避免门闩已置成功但后续失败造成不一致
+                var walletDetail = walletDetailService.getWalletDetailByOrderNo(refundNotification.getOutRefundNo(), WalletDetail.TYPE_退款);
+                if (walletDetail == null) {
+                    LOGGER.error("微信退款回调:未找到退款资金流水,outRefundNo={}", refundNotification.getOutRefundNo());
+                    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("code", HttpStatus.INTERNAL_SERVER_ERROR, "message", "退款流水不存在"));
+                }
+
+                // 幂等门闩:条件更新(status <> 已成功),并发重复回调第二个事务更新 0 行直接跳过
+                boolean claimed = refundLogService.lambdaUpdate()
+                        .set(RefundLog::getRefundId, refundNotification.getRefundId())
+                        .set(RefundLog::getTransactionId, refundNotification.getTransactionId())
+                        .set(RefundLog::getUserReceivedAccount, refundNotification.getUserReceivedAccount())
+                        .set(RefundLog::getSuccessTime, successTime)
+                        .set(RefundLog::getStatus, refundNotification.getRefundStatus().name())
+                        .set(RefundLog::getTotal, refundNotification.getAmount().getTotal().intValue())
+                        .set(RefundLog::getRefund, refundNotification.getAmount().getRefund().intValue())
+                        .eq(RefundLog::getId, refundLog.getId())
+                        .ne(RefundLog::getStatus, RefundLog.STATUS_退款成功)
+                        .update();
+                if (!claimed) {
+                    return ResponseEntity.status(HttpStatus.OK).build();
+                }
+
                 // 冻结金额扣减此次(退款金额+优惠金额),优惠金额字段减去申请退款时的优惠金额
                 var account = accountService.getAccountByUserId(refundLog.getUserId());
-                accountService.lambdaUpdate().setSql("frozen_amount = (frozen_amount - %d) , discount_amount = (discount_amount - %d)"
-                                .formatted(refundNotification.getAmount().getRefund().intValue(), refundLog.getDiscountAmount()))
+                var refundAmount = refundNotification.getAmount().getRefund().intValue();
+                accountService.lambdaUpdate()
+                        .setSql("frozen_amount = (frozen_amount - {0}), discount_amount = (discount_amount - {1})",
+                                refundAmount, refundLog.getDiscountAmount())
                         .eq(Account::getUserId, refundLog.getUserId()).update();
 
                 // 更新资金流水
                 // 注意:此时 balance 已在 applyWxRefund 阶段清零,
                 // beforeBalance 应反映退款前余额(即退款金额),afterBalance 为 0
-                var walletDetail = walletDetailService.getWalletDetailByOrderNo(refundNotification.getOutRefundNo(), WalletDetail.TYPE_退款);
-                var refundAmount = refundNotification.getAmount().getRefund().intValue();
                 walletDetailService.lambdaUpdate()
                         .set(WalletDetail::getStatus, WalletDetail.STATUS_已确认)
                         .set(WalletDetail::getTransactionId, refundNotification.getTransactionId())
@@ -738,7 +786,6 @@ public class WxPayServiceImpl implements WxPayService {
                         .set(WalletDetail::getAmount, refundAmount)
                         .set(WalletDetail::getBeforeBalance, account.getBalance() + refundAmount)
                         .set(WalletDetail::getAfterBalance, account.getBalance())
-                        .set(WalletDetail::getTransactionTime, successTime)
                         .eq(WalletDetail::getId, walletDetail.getId()).update();
                 LOGGER.info("微信退款回调{}:业务处理结束", notifyRes[2]);
 
@@ -748,7 +795,7 @@ public class WxPayServiceImpl implements WxPayService {
                         .setFromStationId(stationId)
                         .setToStationId(stationId)
                         .setTradeNo(refundNotification.getTransactionId())
-                        .setAmount(refundNotification.getAmount().getRefund().intValue())
+                        .setAmount(refundAmount)
                         .setType(SplitRecord.TYPE_REFUND);
                 splitRecordService.save(refundSplit);