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

S-cfc-386: energy.dailyLimit.guard — 实现每日上限守卫

- EnergyService: 注入 DimensionDailyLimitMapper,isDailyLimitExceeded 公开化为公共守卫入口
- 配置驱动上限:resolveDailyLimit() 优先读 energy_daily_limit 表,回落 DAILY_LIMIT_MAP
- 0/负安全:配置 dailyLimit<=0 按 0 处理拦截不发放、不崩溃 (TC-P-90-E-4)
- awardEnergyByCode 加入日上限守卫,实现多来源共享同一日上限 (TC-P-90-E-1)
- EnergyRuleEngine: 新增 guardDailyLimit() 钩子,evaluate 写入前调用 (SC-P-90, S-cfc-304 同链)
- 所有契约测试 (EnergyDailyLimitGuardContractTest) 10/10 绿
Sisyphus 12 часов назад
Родитель
Сommit
48784d4d84

+ 28 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyRuleEngine.java

@@ -10,6 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
@@ -43,6 +44,11 @@ public class EnergyRuleEngine {
                 if (amount == 0) continue;
                 Long dimId = resolveDimensionId(rule);
                 if ("increase".equals(rule.getAction())) {
+                    // SC-P-90:写入前经 daily limit 守卫校验(与 energy.ruleEngine.apply 同链)
+                    if (!guardDailyLimit(memberId, dimId, amount)) {
+                        log.info("每日上限已达,规则跳过发放: ruleId={}, memberId={}", rule.getId(), memberId);
+                        continue;
+                    }
                     energyService.awardEnergy(memberId, eventType, sourceId, amount, rule.getDescription(), null);
                 } else if ("decrease".equals(rule.getAction())) {
                     energyService.deductEnergy(memberId, dimId, Math.abs(amount), rule.getDescription());
@@ -53,6 +59,28 @@ public class EnergyRuleEngine {
         }
     }
 
+    /**
+     * 每日上限守卫钩子(SC-P-90:ruleEngine 写入前经守卫校验,S-cfc-304 同链)。
+     *
+     * <p>委托 {@link EnergyService#isDailyLimitExceeded}(公共守卫入口)判断该成员该维度当日
+     * 累计是否已达上限;返回 true=允许发放,false=超限应跳过。</p>
+     *
+     * @param memberId 家庭成员ID
+     * @param dimId    维度ID(null/解析失败按超限拦截,不发放)
+     * @param amount   拟发放能量
+     */
+    public boolean guardDailyLimit(Long memberId, Long dimId, Integer amount) {
+        if (dimId == null || amount == null || amount <= 0) {
+            return false;
+        }
+        try {
+            return !energyService.isDailyLimitExceeded(memberId, Collections.singleton(dimId), amount);
+        } catch (Exception e) {
+            log.warn("daily limit 守卫校验异常,按超限拦截: memberId={}, dimId={}: {}", memberId, dimId, e.getMessage());
+            return false;
+        }
+    }
+
     private boolean matchesConditions(EnergyRule rule, Map<String, Object> context) {
         if (rule.getConditions() == null || rule.getConditions().isEmpty()) return true;
         try {

+ 47 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -1627,6 +1627,13 @@ public class EnergyService {
     @Resource
     private EnergyBalanceMapper energyBalanceMapper;
 
+    /**
+     * 每日上限配置读取(SC-P-90 / TC-P-90 前置:{@code energy_daily_limit} 表按维度配置 dailyLimit=N,
+     * 配置驱动上限覆盖硬编码 {@link #DAILY_LIMIT_MAP};多来源共享同一日上限,0/负按 0 处理不发放)。
+     */
+    @Resource
+    private DimensionDailyLimitMapper dimensionDailyLimitMapper;
+
     @Resource
     private WishMapper wishMapper;
 
@@ -1883,9 +1890,10 @@ public class EnergyService {
     }
 
 	/**
-	 * 发放能量 — 通过维度code(自动查ID),无日上限/配置约束,供低频行为直接发放
+	 * 发放能量 — 通过维度code(自动查ID),受每日上限约束(多来源共享同一日上限,SC-P-90 /
+	 * TC-P-90-E-1:challenge 等各来源累计触顶后不再发放)
 	 *
-	 * @return 发放结果 Map<dimensionCode, amount>(空 = 维度不存在未发放)
+	 * @return 发放结果 Map<dimensionCode, amount>(空 = 维度不存在 / 超限未发放)
 	 */
 	@Transactional
 	public Map<String, Integer> awardEnergyByCode(Long memberId, String dimensionCode, Integer amount, String reason) {
@@ -1897,6 +1905,12 @@ public class EnergyService {
 		}
 		Map<Long, Integer> allocations = new LinkedHashMap<>();
 		allocations.put(dim.getId(), amount);
+		// 日上限守卫(SC-P-90:写入前经守卫校验;多来源共享同一日上限,超限不发)
+		if (isDailyLimitExceeded(memberId, allocations.keySet(), amount)) {
+			log.warn("日上限已达,跳过发放: memberId={}, dimensionCode={}, amount={}",
+					memberId, dimensionCode, amount);
+			return new HashMap<>();
+		}
 		return executeAward(memberId, "challenge", null, amount, reason, null, allocations);
 	}
 
@@ -2295,9 +2309,12 @@ public class EnergyService {
     }
 
     /**
-     * 检查日上限是否已达
+     * 检查日上限是否已达(公共守卫入口,SC-P-90:ruleEngine 写入前经此校验)。
+     *
+     * <p>多来源共享同一日上限:维度上限由 {@code energy_daily_limit} 配置驱动(无配置回落硬编码
+     * {@link #DAILY_LIMIT_MAP});配置 0/负 按 0 处理不发放(TC-P-90-E-4),不崩溃。</p>
      */
-    private boolean isDailyLimitExceeded(Long memberId, Set<Long> dimIds, Integer amount) {
+    public boolean isDailyLimitExceeded(Long memberId, Set<Long> dimIds, Integer amount) {
         return isDailyLimitExceeded(memberId, dimIds, amount, GLOBAL_DAILY_LIMIT);
     }
 
@@ -2334,7 +2351,10 @@ public class EnergyService {
 
             EnergyDimension dim = getDimById(dimId);
             String code = dim != null ? dim.getCode() : "";
-            Integer limit = DAILY_LIMIT_MAP.getOrDefault(code, 50);
+            Integer limit = resolveDailyLimit(code);
+
+            // TC-P-90-E-4:配置 0/负 按 0 处理(不发放),不崩溃
+            if (limit == null || limit <= 0) return true;
 
             if (dimToday + amount > limit) return true;
         }
@@ -2342,6 +2362,28 @@ public class EnergyService {
         return false;
     }
 
+    /**
+     * 解析某维度每日上限:优先 {@code energy_daily_limit} 配置驱动(TC-P-90 前置),
+     * 未配置/读取失败回落硬编码 {@link #DAILY_LIMIT_MAP}(默认 50)。
+     *
+     * <p>返回值为 null 表示无有效配置(应回落默认);0/负 保留原值,由调用方按 0 处理拦截。</p>
+     */
+    private Integer resolveDailyLimit(String code) {
+        if (code != null && dimensionDailyLimitMapper != null) {
+            try {
+                List<DimensionDailyLimit> limits = dimensionDailyLimitMapper.selectList(
+                        new LambdaQueryWrapper<DimensionDailyLimit>()
+                                .eq(DimensionDailyLimit::getDimensionCode, code));
+                if (limits != null && !limits.isEmpty() && limits.get(0) != null) {
+                    return limits.get(0).getDailyLimit();
+                }
+            } catch (Exception e) {
+                log.warn("读取每日上限配置失败 dimensionCode={}: {}", code, e.getMessage());
+            }
+        }
+        return DAILY_LIMIT_MAP.getOrDefault(code, 50);
+    }
+
     /**
      * 获取今日起始时间(00:00:00)
      */