Просмотр исходного кода

feat(backend): 每日打卡用 Redis SETNX 幂等,防止重复提交重复发积分

Xiaogang Liao 1 неделя назад
Родитель
Сommit
aad80e0a52

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DailyCheckinService.java

@@ -5,8 +5,11 @@ import com.etotem.cfc.entity.EmotionCheckin;
 import com.etotem.cfc.mapper.DailyCheckinMapper;
 import com.etotem.cfc.mapper.EmotionCheckinMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.util.IdempotencyUtil;
 import org.springframework.stereotype.Service;
 
+import java.time.LocalDate;
+
 import javax.annotation.Resource;
 import lombok.extern.slf4j.Slf4j;
 import java.util.Date;
@@ -29,6 +32,9 @@ public class DailyCheckinService {
     @Resource
     private ChallengeService challengeService;
 
+    @Resource
+    private IdempotencyUtil idempotencyUtil;
+
     public List<DailyHealthCheckin> getCheckinsByMember(Long memberId) {
         return dailyCheckinMapper.selectList(new LambdaQueryWrapper<DailyHealthCheckin>()
                 .eq(DailyHealthCheckin::getMemberId, memberId)
@@ -40,6 +46,29 @@ public class DailyCheckinService {
     }
 
     public DailyHealthCheckin createCheckin(DailyHealthCheckin checkin) {
+        // 每日幂等:同一天同一成员只允许打卡一次(防重复提交重复发积分)
+        String idemKey = "checkin:" + checkin.getMemberId() + ":" + LocalDate.now();
+        if (!idempotencyUtil.tryAcquire(idemKey, 24 * 3600)) {
+            log.warn("成员{}今日已打卡,跳过重复提交", checkin.getMemberId());
+            DailyHealthCheckin existing = dailyCheckinMapper.selectOne(
+                    new LambdaQueryWrapper<DailyHealthCheckin>()
+                            .eq(DailyHealthCheckin::getMemberId, checkin.getMemberId())
+                            .eq(DailyHealthCheckin::getCheckinDate, new java.sql.Date(System.currentTimeMillis()))
+                            .last("LIMIT 1"));
+            if (existing != null) {
+                return existing;
+            }
+            throw new RuntimeException("今日已打卡");
+        }
+        try {
+            return doCreateCheckin(checkin);
+        } catch (RuntimeException e) {
+            idempotencyUtil.release(idemKey);
+            throw e;
+        }
+    }
+
+    private DailyHealthCheckin doCreateCheckin(DailyHealthCheckin checkin) {
         checkin.setCreatedAt(new Date());
         checkin.setUpdatedAt(new Date());
         dailyCheckinMapper.insert(checkin);

+ 60 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/IdempotencyUtil.java

@@ -0,0 +1,60 @@
+package com.etotem.cfc.util;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 基于 Redis SETNX 的幂等工具。
+ *
+ * 用于"每天一次/每单一次"类操作的幂等控制:key 存在则说明已处理过,
+ * 防止重复请求导致重复发奖、重复扣款等问题。Redis 不可用时降级为放行
+ * (依赖业务自身的唯一约束兜底)。
+ *
+ * 用法:
+ * <pre>
+ *   boolean first = idempotencyUtil.tryAcquire("checkin:" + memberId + ":" + LocalDate.now(), 24);
+ *   if (!first) { return "今日已打卡"; }
+ * </pre>
+ */
+@Component
+public class IdempotencyUtil {
+
+    private static final Logger log = LoggerFactory.getLogger(IdempotencyUtil.class);
+
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
+    /**
+     * 尝试获取幂等标记。
+     *
+     * @param key    幂等 key(建议带业务前缀)
+     * @param ttlSec 标记有效期(秒);到期后自动清除,允许再次操作
+     * @return true=首次操作(本次应执行),false=已操作过(应跳过)
+     */
+    public boolean tryAcquire(String key, long ttlSec) {
+        try {
+            Boolean ok = redisTemplate.opsForValue().setIfAbsent(
+                    key, "1", ttlSec, TimeUnit.SECONDS);
+            return Boolean.TRUE.equals(ok);
+        } catch (Exception e) {
+            log.warn("Redis 幂等标记获取失败,降级为放行: key={}, error={}", key, e.getMessage());
+            return true;
+        }
+    }
+
+    /**
+     * 释放幂等标记(业务失败回滚时调用)。
+     */
+    public void release(String key) {
+        try {
+            redisTemplate.delete(key);
+        } catch (Exception e) {
+            log.warn("Redis 幂等标记释放失败: key={}, error={}", key, e.getMessage());
+        }
+    }
+}