Browse Source

feat(backend): 新增 RedisLockUtil 分布式锁,支付回调防并发重复处理

Xiaogang Liao 6 days ago
parent
commit
c01878f831

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java

@@ -11,6 +11,7 @@ import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.service.MembershipService;
 import com.etotem.cfc.service.ProductOrderService;
 import com.etotem.cfc.service.api.PaymentServiceInterface;
+import com.etotem.cfc.util.RedisLockUtil;
 import okhttp3.MediaType;
 import okhttp3.OkHttpClient;
 import okhttp3.Request;
@@ -70,6 +71,9 @@ public class PaymentService implements PaymentServiceInterface {
     @Resource
     private MembershipService membershipService;
 
+    @Resource
+    private RedisLockUtil redisLockUtil;
+
     @Value("${wechat.appid}")
     private String appid;
 
@@ -225,6 +229,20 @@ public class PaymentService implements PaymentServiceInterface {
 
     @Override
     public boolean handlePaymentCallback(String orderNo, String transactionId, String payMethod) {
+        // 分布式锁:防止支付回调并发重复处理
+        String lockKey = "lock:package:" + orderNo;
+        if (!redisLockUtil.tryLock(lockKey, 30)) {
+            log.warn("套餐订单支付回调处理中,跳过重复请求: orderNo={}", orderNo);
+            return true;
+        }
+        try {
+            return doHandlePaymentCallback(orderNo, transactionId, payMethod);
+        } finally {
+            redisLockUtil.unlock(lockKey);
+        }
+    }
+
+    private boolean doHandlePaymentCallback(String orderNo, String transactionId, String payMethod) {
         LambdaQueryWrapper<PackageOrder> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(PackageOrder::getOrderNo, orderNo);
         PackageOrder order = packageOrderMapper.selectOne(wrapper);

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -24,6 +24,7 @@ import com.etotem.cfc.entity.ProductServicePersonSlot;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.service.FamilyPlatformPointsService;
 import com.etotem.cfc.service.PlatformPointsService;
+import com.etotem.cfc.util.RedisLockUtil;
 import com.etotem.cfc.mapper.AfterSalesRequestMapper;
 import com.etotem.cfc.entity.SupplySystemMember;
 import com.etotem.cfc.mapper.SupplySystemMemberMapper;
@@ -107,6 +108,9 @@ public class ProductOrderService {
     @Resource
     private UserService userService;
 
+    @Resource
+    private RedisLockUtil redisLockUtil;
+
     @Resource
     private PointsService pointsService;
 
@@ -665,6 +669,20 @@ public class ProductOrderService {
      */
     @Transactional(rollbackFor = Exception.class)
     public Result<String> handlePaymentSuccess(String orderNo, String transactionId) {
+        // 分布式锁:防止支付回调并发重复处理(重复扣库存/重复结算佣金)
+        String lockKey = "lock:order:" + orderNo;
+        if (!redisLockUtil.tryLock(lockKey, 30)) {
+            log.warn("订单支付回调处理中,跳过重复请求: orderNo={}", orderNo);
+            return Result.success("处理中,请勿重复操作");
+        }
+        try {
+            return doHandlePaymentSuccess(orderNo, transactionId);
+        } finally {
+            redisLockUtil.unlock(lockKey);
+        }
+    }
+
+    private Result<String> doHandlePaymentSuccess(String orderNo, String transactionId) {
         ProductOrder order = orderMapper.selectOne(
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
         if (order == null) {

+ 93 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/RedisLockUtil.java

@@ -0,0 +1,93 @@
+package com.etotem.cfc.util;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 基于 Redis 的分布式锁工具。
+ *
+ * 实现:SET key value NX EX ttl 获取锁,释放时用 Lua 脚本校验 value 匹配后 DEL,
+ * 避免误删其他线程/实例持有的锁。value 用 UUID 标识持有者。
+ *
+ * 用法:
+ * <pre>
+ *   boolean locked = redisLockUtil.tryLock("lock:order:" + orderNo, 10);
+ *   if (!locked) { return "处理中,请勿重复操作"; }
+ *   try {
+ *       // 业务逻辑
+ *   } finally {
+ *       redisLockUtil.unlock("lock:order:" + orderNo);
+ *   }
+ * </pre>
+ */
+@Component
+public class RedisLockUtil {
+
+    private static final Logger log = LoggerFactory.getLogger(RedisLockUtil.class);
+
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
+    /** 锁持有者标识(线程本地 UUID,跨方法共享) */
+    private static final ThreadLocal<String> LOCK_TOKEN = new ThreadLocal<>();
+
+    /** 释放锁 Lua 脚本:仅当 value 匹配时才 DEL */
+    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT =
+            new DefaultRedisScript<>(
+                    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
+                    "return redis.call('del', KEYS[1]) else return 0 end",
+                    Long.class);
+
+    /**
+     * 尝试获取锁。
+     *
+     * @param key     锁 key(建议带业务前缀,如 lock:order:{orderNo})
+     * @param ttlSec  锁自动过期时间(秒),防止持锁线程崩溃导致死锁
+     * @return true=获取成功, false=已被其他线程持有
+     */
+    public boolean tryLock(String key, long ttlSec) {
+        try {
+            String token = UUID.randomUUID().toString();
+            Boolean ok = redisTemplate.opsForValue().setIfAbsent(
+                    key, token, ttlSec, TimeUnit.SECONDS);
+            if (Boolean.TRUE.equals(ok)) {
+                LOCK_TOKEN.set(token);
+                return true;
+            }
+            return false;
+        } catch (Exception e) {
+            log.warn("Redis 锁获取失败,降级为无锁执行: key={}, error={}", key, e.getMessage());
+            // Redis 异常时降级:视为获取成功(单实例下有 @Transactional 兜底)
+            LOCK_TOKEN.set(null);
+            return true;
+        }
+    }
+
+    /**
+     * 释放锁。
+     */
+    public void unlock(String key) {
+        String token = LOCK_TOKEN.get();
+        if (token == null) {
+            // 降级模式获取的锁,无需真实释放
+            LOCK_TOKEN.remove();
+            return;
+        }
+        try {
+            redisTemplate.execute(UNLOCK_SCRIPT,
+                    Collections.singletonList(key), token);
+        } catch (Exception e) {
+            log.warn("Redis 锁释放失败: key={}, error={}", key, e.getMessage());
+        } finally {
+            LOCK_TOKEN.remove();
+        }
+    }
+}