Parcourir la source

merge(cfc-en-001): 能量发放 500 修复合入(独立核验 PASS)

- 修复:grantEnergy 显式构造 allocations 直达指定维度,绕过 task_complete 行为配置 100% action 固定分配(根因:请求 body 维度时 balanceMap.get('body')=null → 500)
- RuntimeException → IllegalArgumentException/IllegalStateException(GlobalExceptionHandler 映射 400)
- 新增单测:EnergyControllerGrantEnergyTest(3) + EnergyServiceGrantEnergyTest(2);e2e 移除 2 fixme + 1 特征用例
- 核验:独立核验 verdict=PASS(编译 BUILD SUCCESS / 单测 5/5 + energy 套件 11/11 / Q1-Q4,Q6 通过;Q5 既有小瑕疵+2 项并发债列入技术债不阻塞)
Agnes Agent il y a 1 semaine
Parent
commit
7b62281680

+ 26 - 14
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -1992,26 +1992,38 @@ public class EnergyService {
      */
     @Transactional
     public EnergyLog grantEnergy(Long memberId, Long taskId, Long amount, String dimensionCode) {
-        // 调用 awardEnergy 发放能量(awardEnergy 已完成余额更新+日志写入)
-        Map<String, Integer> balanceMap = awardEnergy(memberId, "task", taskId, amount.intValue(), "任务奖励", null);
-        
-        if (balanceMap == null || balanceMap.isEmpty()) {
-            throw new RuntimeException("能量发放失败:没有分配到任何维度能量");
+        // CFC-EN-001 修复:grantEnergy 的维度由调用方显式指定,能量应直接发放到该维度。
+        // 旧实现委托 awardEnergy("task"),命中 task_complete 行为配置(100% 分配到 action),
+        // 随后按请求的 dimensionCode 从分配结果取数,请求 body 维度时 balanceMap.get("body")=null
+        // → 抛 RuntimeException → GlobalExceptionHandler 未映射 → code=500「服务器内部错误」。
+        if (amount == null || amount <= 0) {
+            throw new IllegalArgumentException("能量数量必须为正数");
         }
-        
-        // 获取维度ID
         EnergyDimension dim = getDimByCode(dimensionCode);
         if (dim == null) {
-            throw new RuntimeException("维度代码无效:" + dimensionCode);
+            throw new IllegalArgumentException("维度代码无效:" + dimensionCode);
         }
-        
-        // 获取分配到的能量数量
+
+        Map<Long, Integer> allocations = new LinkedHashMap<>();
+        allocations.put(dim.getId(), amount.intValue());
+
+        // 日上限检查(沿用全局/维度上限逻辑,超限改为 400 业务提示而非 500)
+        if (isDailyLimitExceeded(memberId, allocations.keySet(), amount.intValue())) {
+            log.warn("日上限已达,跳过发放: memberId={}, dimensionCode={}, amount={}",
+                    memberId, dimensionCode, amount);
+            throw new IllegalStateException("今日能量发放已达上限,请明天再试");
+        }
+
+        // 执行发放(更新余额 + 写流水 + 站内通知)
+        Map<String, Integer> balanceMap = executeAward(memberId, "task", taskId,
+                amount.intValue(), "任务奖励", null, allocations);
+
         Integer energyAmount = balanceMap.get(dimensionCode);
         if (energyAmount == null || energyAmount <= 0) {
-            throw new RuntimeException("能量发放失败:维度 " + dimensionCode + " 没有分配能量");
+            throw new IllegalStateException("能量发放失败:维度 " + dimensionCode + " 没有分配能量");
         }
-        
-        // 构建返回对象(awardEnergy 已写入日志,此处不再重复插入)
+
+        // 构建返回对象(executeAward 已写入日志,此处不再重复插入)
         EnergyLog energyLog = new EnergyLog();
         energyLog.setMemberId(memberId);
         energyLog.setDimensionId(dim.getId());
@@ -2020,7 +2032,7 @@ public class EnergyService {
         energyLog.setSourceId(taskId);
         energyLog.setDescription("任务奖励");
         energyLog.setCreatedAt(new Date());
-        
+
         return energyLog;
     }
 

+ 98 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/EnergyControllerGrantEnergyTest.java

@@ -0,0 +1,98 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.EnergyLog;
+import com.etotem.cfc.service.EnergyService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * CFC-EN-001:/api/energy/grant 成功路径 Controller 层回归测试。
+ *
+ * <p>补齐 Controller 层覆盖:grantEnergy 成功时返回 {@code Result.success(energyLog)}
+ * (code=200 且 data 有值);缺参/无家庭时返回错误提示。</p>
+ */
+class EnergyControllerGrantEnergyTest {
+
+    private EnergyController controller;
+    private EnergyService energyService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new EnergyController();
+        energyService = mock(EnergyService.class);
+        inject(controller, "energyService", energyService);
+    }
+
+    @Test
+    @DisplayName("CFC-EN-001:grant 成功返回 code=200 且 data 有值")
+    void grantEnergy_success_returns200WithData() {
+        EnergyLog log = new EnergyLog();
+        log.setMemberId(50L);
+        log.setDimensionId(5L);
+        log.setAmount(10);
+        log.setSourceType("task");
+        log.setSourceId(8888L);
+        when(energyService.grantEnergy(eq(50L), eq(8888L), eq(10L), eq("body"))).thenReturn(log);
+
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", 50L);
+        params.put("taskId", 8888L);
+        params.put("amount", 10L);
+        params.put("dimensionCode", "body");
+
+        Result<EnergyLog> result = controller.grantEnergy(params, 100L, 50L);
+
+        assertEquals(200, result.getCode());
+        assertNotNull(result.getData());
+        assertEquals(10, result.getData().getAmount());
+        assertEquals(8888L, result.getData().getSourceId());
+    }
+
+    @Test
+    @DisplayName("CFC-EN-001:缺参返回错误提示而非 500")
+    void grantEnergy_missingParam_returnsError() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", 50L);
+        // 缺 taskId / amount
+
+        Result<EnergyLog> result = controller.grantEnergy(params, 100L, 50L);
+
+        assertEquals(500, result.getCode());
+        assertNotNull(result.getMessage());
+        assertEquals("memberId、taskId和amount不能为空", result.getMessage());
+    }
+
+    @Test
+    @DisplayName("CFC-EN-001:无家庭返回 noFamily 错误提示")
+    void grantEnergy_noFamily_returnsNoFamilyError() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", 50L);
+        params.put("taskId", 8888L);
+        params.put("amount", 10L);
+        params.put("dimensionCode", "body");
+
+        Result<EnergyLog> result = controller.grantEnergy(params, null, 50L);
+
+        assertNotNull(result.getMessage());
+        assertEquals(5001, result.getCode());
+        assertEquals("请先创建或加入家庭", result.getMessage());
+    }
+
+    private void inject(Object target, String fieldName, Object value) throws Exception {
+        Field field = EnergyController.class.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+}

+ 139 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/EnergyServiceGrantEnergyTest.java

@@ -0,0 +1,139 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.EnergyBalance;
+import com.etotem.cfc.entity.EnergyBehaviorConfig;
+import com.etotem.cfc.entity.EnergyDimension;
+import com.etotem.cfc.entity.EnergyLog;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.EnergyBalanceMapper;
+import com.etotem.cfc.mapper.EnergyBehaviorConfigMapper;
+import com.etotem.cfc.mapper.EnergyDimensionMapper;
+import com.etotem.cfc.mapper.EnergyLogMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * CFC-EN-001:/api/energy/grant 稳定返回 code=500「服务器内部错误」回归测试。
+ *
+ * <p>根因(以真实异常栈确认,见 grantEnergy_bodyDimension_succeedsAndWritesLog 修复前的 RED 断言):
+ * {@link EnergyService#grantEnergy} 委托 {@code awardEnergy("task")},命中 task_complete 行为配置
+ * (维度分配 {@code [{"dim":"action","ratio":100}]}),能量按固定比例全部分配到 {@code action} 维度;
+ * 随后 grantEnergy 按请求的 {@code dimensionCode} 从分配结果取数,当调用方请求 {@code body} 维度时
+ * {@code balanceMap.get("body")} 为 {@code null} → 抛 RuntimeException → GlobalExceptionHandler 未映射
+ * → code=500「服务器内部错误」。</p>
+ *
+ * <p>修复语义:grantEnergy 的维度由调用方显式指定,能量应直接发放到该维度(不再受行为配置固定比例
+ * 约束),成功路径返回 EnergyLog 且写入余额与流水;非法维度/日上限改为 400 业务提示。</p>
+ */
+class EnergyServiceGrantEnergyTest {
+
+    private EnergyService service;
+    private EnergyDimensionMapper dimensionMapper;
+    private EnergyBehaviorConfigMapper behaviorConfigMapper;
+    private EnergyLogMapper logMapper;
+    private EnergyBalanceMapper balanceMapper;
+    private FamilyMemberMapper familyMemberMapper;
+    private NotificationDispatchService notificationDispatchService;
+
+    private static final Long MEMBER_ID = 50L;
+    private static final Long TASK_ID = 8888L;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new EnergyService();
+        dimensionMapper = mock(EnergyDimensionMapper.class);
+        behaviorConfigMapper = mock(EnergyBehaviorConfigMapper.class);
+        logMapper = mock(EnergyLogMapper.class);
+        balanceMapper = mock(EnergyBalanceMapper.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        notificationDispatchService = mock(NotificationDispatchService.class);
+
+        inject(service, "energyDimensionMapper", dimensionMapper);
+        inject(service, "energyBehaviorConfigMapper", behaviorConfigMapper);
+        inject(service, "energyLogMapper", logMapper);
+        inject(service, "energyBalanceMapper", balanceMapper);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "notificationDispatchService", notificationDispatchService);
+
+        // 五维种子(与 energy_dimension 表一致:body 存在,排除「维度代码无效」候选根因)
+        when(dimensionMapper.selectList(any())).thenReturn(Arrays.asList(
+                dim(1L, "wisdom"), dim(2L, "wealth"), dim(3L, "action"),
+                dim(4L, "mind"), dim(5L, "body")));
+        // task_complete 行为配置:100% 分配到 action(与线上种子一致,是 500 的触发来源)
+        when(behaviorConfigMapper.selectOne(any())).thenReturn(behaviorConfig("task_complete"));
+        // 日上限:无既有流水
+        when(logMapper.selectList(any())).thenReturn(Collections.emptyList());
+        // 余额:无既有记录 → getOrCreateBalance 新建
+        when(balanceMapper.selectOne(any())).thenReturn(null);
+        // 家庭成员:用于站内通知
+        FamilyMember member = new FamilyMember();
+        member.setId(MEMBER_ID);
+        member.setUserId(12L);
+        when(familyMemberMapper.selectById(any())).thenReturn(member);
+    }
+
+    @Test
+    @DisplayName("CFC-EN-001:grant(body) 成功返回 EnergyLog 且写入余额+流水")
+    void grantEnergy_bodyDimension_succeedsAndWritesLog() {
+        EnergyLog result = service.grantEnergy(MEMBER_ID, TASK_ID, 10L, "body");
+
+        assertNotNull(result);
+        assertEquals(10, result.getAmount());
+        assertEquals(TASK_ID, result.getSourceId());
+        assertEquals("task", result.getSourceType());
+        assertEquals(5L, result.getDimensionId(), "应发放到请求的 body 维度");
+
+        // 余额更新 + 流水写入(executeAward 已完成持久化)
+        verify(balanceMapper, atLeastOnce()).updateById(any(EnergyBalance.class));
+        verify(logMapper, atLeastOnce()).insert(any(EnergyLog.class));
+    }
+
+    @Test
+    @DisplayName("CFC-EN-001:非法维度应返回 400 业务提示而非 500")
+    void grantEnergy_invalidDimension_returnsBadRequest() {
+        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
+                () -> service.grantEnergy(MEMBER_ID, TASK_ID, 10L, "nonexistent"));
+        assertTrue(ex.getMessage().contains("维度代码无效"));
+    }
+
+    private EnergyDimension dim(Long id, String code) {
+        EnergyDimension d = new EnergyDimension();
+        d.setId(id);
+        d.setCode(code);
+        d.setName(code);
+        d.setStatus(1);
+        return d;
+    }
+
+    private EnergyBehaviorConfig behaviorConfig(String code) {
+        EnergyBehaviorConfig c = new EnergyBehaviorConfig();
+        c.setId(1L);
+        c.setBehaviorCode(code);
+        c.setEnabled(1);
+        c.setDimensionAssign("[{\"dim\":\"action\",\"ratio\":100}]");
+        c.setDailyLimit(200);
+        return c;
+    }
+
+    private void inject(Object target, String fieldName, Object value) throws Exception {
+        Field field = EnergyService.class.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+}

+ 101 - 0
docs/CFC-EN-001核验报告.md

@@ -0,0 +1,101 @@
+# CFC-EN-001 独立核验报告
+
+> 核验对象:`/api/energy/grant` 返回 500「服务器内部错误」修复(后端未捕获异常)。
+> 被测提交:`fcc6aa876`(`fix(energy): CFC-EN-001 修复 /api/energy/grant 500 服务器内部错误`)
+> 核验基线:`origin/feature/cfc-en-001-energy-grant-500` = `fcc6aa876`
+> 核验分支:`verify/cfc-en-001`(基于被测提交 `checkout -b`,已 `rebase origin/cfclub` 无冲突,重放后提交 `129b1ab3f`)
+> 核验方式:独立 worktree `D:\tmp\en001-verify`,只读纪律下逐项验收,**未修改任何业务代码**。
+> 核验日期:2026-11-01
+
+---
+
+## 总体结论:**verdict = PASS**
+
+- 检出 + rebase(步骤1):成功,无冲突。
+- 编译(步骤2):`mvn -T 1C compile -DskipTests` → **BUILD SUCCESS**(2032 源文件,1:54min)。
+- 单测(步骤3):新增 2 类 **5/5 全绿**(Controller 3 + Service 2);顺带跑 commit message 声称的 energy 相关套件 **11/11 全绿**(GrantEnergy 5 + Family 2 + WealthIncome 4)。
+- 逻辑审查(步骤4):Q1–Q4、Q6 全部通过;Q5 为**既有**小瑕疵(非本次引入)。
+- 边界风险(步骤5):无阻断性风险;2 项为**既有**并发非原子性,非本次引入。
+
+**结论:建议合入。** 无需要修订的缺陷;2 项非阻断观察建议(缺参 code=500 语义、并发非原子)列入后续技术债,不阻塞本修复。
+
+---
+
+## 步骤 1:检出 + Rebase
+
+| 项 | 结果 |
+|----|------|
+| `git checkout -b verify/cfc-en-001 origin/feature/cfc-en-001-energy-grant-500` | ✅ HEAD=fcc6aa876 |
+| `git rebase origin/cfclub`(origin/cfclub=ddf36696d) | ✅ 无冲突,1/1 重放成功 |
+| 重放后提交 | `129b1ab3f`(仅 commit message 不变) |
+| 与 cfclub 差异 | 4 文件,+265/-32(`EnergyService.java` 40 行改、2 个新测试类、e2e spec 20 行改) |
+
+- merge-base(origin/cfclub, feature) = `7baa09caa`,feature 分支仅领先 1 个提交,rebase 语义干净。
+- 无 `DatabaseInitializer`/schema.sql 改动 → **未新增 DB 迁移**(符合纪律)。
+
+## 步骤 2:编译
+
+命令:`mvn -T 1C compile -DskipTests`(Maven 3.9.6,Java 1.8.0_25)
+
+| 结果 | 说明 |
+|------|------|
+| **BUILD SUCCESS** | 2032 源文件编译通过,1:54min;仅有既有 deprecation/unchecked 警告(KnowledgeBaseController、ReportBlockAssembler、CommissionDistService410Test、UserServiceFamilyNameLinkTest),与本修复无关 |
+
+## 步骤 3:单测
+
+命令:`mvn -T 1C test -Dtest="..." -DfailIfNoTests=false -DskipTests=false`
+> 注:本仓库 pom 默认 skipTests=true(见提交 03e1eb810),必须显式 `-DskipTests=false` 才真正执行;首次按指令文件未带该参数时日志显示「Tests are skipped」假绿灯,已按仓库既定约定修正重跑。
+
+### 3.1 新增 2 类(指令文件要求范围)— 5/5 全绿
+
+| 测试类 | 用例 | 结果 |
+|--------|------|:----:|
+| `EnergyControllerGrantEnergyTest` | `grantEnergy_success_returns200WithData` | ✅ |
+| | `grantEnergy_missingParam_returnsError` | ✅(断言 code=500,锁定既有缺参行为) |
+| | `grantEnergy_noFamily_returnsNoFamilyError` | ✅ |
+| `EnergyServiceGrantEnergyTest` | `grantEnergy_bodyDimension_succeedsAndWritesLog` | ✅ |
+| | `grantEnergy_invalidDimension_returnsBadRequest` | ✅ |
+
+### 3.2 energy 相关套件(commit message 声称 11 例)— 11/11 全绿
+
+| 测试类 | 用例数 | 结果 |
+|--------|:----:|:----:|
+| `EnergyControllerGrantEnergyTest` | 3 | ✅ |
+| `EnergyServiceFamilyTest` | 2 | ✅ |
+| `EnergyServiceGrantEnergyTest` | 2 | ✅ |
+| `EnergyServiceWealthIncomeTest` | 4 | ✅ |
+
+## 步骤 4:逻辑审查(6 项)
+
+| # | 审查点 | 结论 | 理由 |
+|---|--------|:----:|------|
+| **Q1** | 根因是否彻底消除 | ✅ | `grantEnergy`(L1994) 不再调用 `awardEnergy("task")`,改为显式构造 `allocations={请求维度: amount}` 后直接 `executeAward`(L2018),完全绕过 `matchBehaviorConfig("task")→task_complete`(`dimension_assign=[{"dim":"action","ratio":100}]`,L8131 种子)的 100%-action 固定分配。请求 body 时 `executeAward` 返回 `{"body":amount}`,`balanceMap.get("body")` 不再为 null。单测 `grantEnergy_bodyDimension_succeedsAndWritesLog` 实证。 |
+| **Q2** | 日上限逻辑 | ✅ | `isDailyLimitExceeded(memberId, {bodyId}, amount)`(L2011) 走 4 参版本(L2211):全局查当日全部正向流水之和 + amount vs `GLOBAL_DAILY_LIMIT=200`(L1608);再逐维度查 `dimToday + amount` vs `DAILY_LIMIT_MAP[code]`(body=50, mind=40, wisdom=80, action=60, wealth=50,L1600-1606)。覆盖「本次请求 + 今日已有」。与既有 `awardByConfig` 共用同一校验(语义一致)。 |
+| **Q3** | executeAward 签名一致性 | ✅ | 调用 `executeAward(memberId, "task", taskId, amount.intValue(), "任务奖励", null, allocations)` 与定义 `(Long, String, Long, Integer, String, Integer, Map<Long,Integer>)`(L1723) 完全匹配。编译通过 + 单测全绿。 |
+| **Q4** | 异常安全网 | ✅ | `IllegalArgumentException`(L24-27)与 `IllegalStateException`(L30-33)在 `GlobalExceptionHandler` 均显式映射 `Result.error(400, msg)`;不再落入 catch-all `Exception→500`(L101-105)。非法维度/非正数→400「维度代码无效/能量数量必须为正数」;日上限/余额失败→400「今日能量发放已达上限/维度没有分配能量」。 |
+| **Q5** | Controller 缺参早返回 | ⚠ | `Result.error("memberId、taskId和amount不能为空")` 的默认 code=**500**(`Result.error(String)` L37-42)。消息友好但 code=500 对「缺参客户端错误」语义不当,**应为 400**。此行为为**既有**(Controller 与 `Result.error` 均未改动,新增单测 L72 甚至锁定该 500),**非本次修复引入**,不阻塞合入;建议后续单独改 400。另 `dimensionCode` 未在 Controller 判空,但 `getDimByCode(null)`(HashMap 容忍 null key,`initDimMaps` L2302-2308)返回 null → `IllegalArgumentException`→400,不 500。 |
+| **Q6** | e2e 用例修复 | ✅ | `energy-api.spec.js`:移除 场景2(L53)/场景6(L131) 两处 `test.fixme`;删除 场景2c(已知缺陷复现、断言 code=500)。场景2 断言 `grant.code=200` + `grant.data` + 流水 `sourceId==taskId`;场景6 断言 200 + 流水可查。与修复后契约一致。注:e2e 为远程 `cfc.iwintrue.com` 运行,需修复部署后才转绿;本核验不执行远程。 |
+
+## 步骤 5:边界风险检查
+
+| # | 检查点 | 结论 | 说明 |
+|---|--------|:----:|------|
+| B1 | `getOrCreateBalance` 缺失记录时 insert vs update | ✅ | `EnergyBalance` 用 `@TableId(IdType.AUTO)`(L17-18),`getOrCreateBalance`(L2182) 缺失时先 `insert`(回填 id),再 `executeAward` L1738 `updateById(balance)` 可正确定位。无「只插不更/只更不插」错配。 |
+| B2 | `GLOBAL_DAILY_LIMIT` 常量 | ✅ | `= 200`(L1608),`DAILY_LIMIT_MAP` 五维上限已确认(body 50 / mind 40 / wisdom 80 / action 60 / wealth 50)。 |
+| B3 | 并发非原子性 | ⚠ 既有 | `isDailyLimitExceeded`(L2211) 读-判-写非原子,`executeAward`(L1734-1738) 余额读-改-写非原子;多线程同 member 同维度同日并发可超发/丢更新。**该模式为既有**(`awardByConfig` 同用),非本次引入;建议后续对 `(member_id, dimension_id)` 加唯一约束 + 行锁或原子 `UPDATE ... SET balance=balance+?` 加固。 |
+
+## 其他非阻断观察(供主理人知悉)
+
+1. **语义变更(有意为之)**:`grantEnergy` 维度由调用方显式指定并直接发放到该维度,不再受行为配置固定比例约束。对非 `action` 维度(body/mind/wisdom/wealth),此修复将**必现 500 转为 200(额度内)/400(超该维度日上限)**,属净改善;对 `action` 维度行为不变。`task_complete` 的 `daily_limit=200`(种子 L8131)不再被本 API 查询,改由全局默认 200 + 请求维度自身上限约束,结果更严格但语义自洽。
+2. **过期语义无变化**:`task_complete` 种子 `expire_days=0`(L8131)→ 旧 `awardByConfig` 中 `getExpireDays()>0` 为假 → `daysToExpire=null`;新路径 `executeAward(..., expireDays=null, ...)` 亦不过期。等效。
+3. **被绕过配置字段无实际影响**:`task_complete` 的 `enabled=1`、`cooldown_seconds=0`、`amount_source=points`;绕过 `enabled`/`cooldown` 均无副作用(enabled 恒真、cooldown 恒 0),`amount_source` 因 amount 由调用方显式传入而无影响。
+4. **`grantEnergy` 调用面**:生产侧仅 `EnergyController.grantEnergy`(L223) 一处调用;`awardEnergy` 仍供 TaskService 等其它来源使用,未受影响。
+
+---
+
+## 核验人签名
+
+- 核验分支:`verify/cfc-en-001`(基于 `fcc6aa876` checkout -b,rebase origin/cfclub 无冲突)
+- 核验范围:检出/rebase ✅ / 编译 ✅ / 单测 ✅(5/5 新增、11/11 energy 套件)/ 逻辑审查 ✅(Q5 既有小瑕疵)/ 边界风险 ✅(2 项既有并发债)
+- 最终 verdict:**PASS(建议合入)**
+- 只读纪律:本报告仅含核验发现,未对业务代码/数据库/迁移做任何写操作;唯一新增文件为本报告,提交至独立分支 `verify/cfc-en-001`,**未回写 cfclub、未合并任何分支到 cfclub**。

+ 2 - 18
tests/e2e/api/energy-api.spec.js

@@ -47,13 +47,10 @@ test.describe('【API级】能量系统收支全流程', () => {
   // ===== 场景2:任务完成能量发放 =====
 
   /**
-   * 【已知缺陷 CFC-EN-001】/api/energy/grant 在远程稳定返回 code=500「服务器内部错误」,
-   * 属后端未捕获异常(EnergyController.grantEnergy → EnergyService.grantEnergy
-   * → awardEnergy → awardByConfig → executeAward)。
-   * 用例保留「期望成功」的正确断言,修复后自动转绿;当前以 fixme 标记不阻塞 CI。
+   * CFC-EN-001:/api/energy/grant 已修复(维度由调用方显式指定,能量直接发放到该维度)。
+   * 用例断言成功路径(200 + data + 流水记录),不再以 fixme 标记阻塞。
    */
   test('[场景2] 任务完成能量发放 - 期望能量增加且流水记录', async ({ request }) => {
-    test.fixme(true, 'CFC-EN-001:/api/energy/grant 返回 500 服务器内部错误(后端未捕获异常),修复后移除本标记');
     const amount = 10;
     const taskId = Number(uniqueSuffix());
 
@@ -74,18 +71,6 @@ test.describe('【API级】能量系统收支全流程', () => {
     expect(records.some((r) => String(r.sourceId) === String(taskId))).toBeTruthy();
   });
 
-  test('[场景2c] 已知缺陷复现 - 期望当前返回 500 服务器内部错误', async ({ request }) => {
-    // 特征化用例:锁定当前(缺陷)行为;研发修复后本用例会失败,提示移除 场景2 的 fixme
-    const r = await apiPost(
-      request,
-      '/api/energy/grant',
-      { memberId: selfMemberId, taskId: Number(uniqueSuffix()), amount: 1, dimensionCode: DIMENSION },
-      ctx.token
-    );
-    expect(r.code, 'CFC-EN-001 当前表现为 500').toBe(500);
-    expect(String(r.message)).toContain('服务器内部错误');
-  });
-
   test('[场景2b] 能量发放缺参 - 期望返回错误提示', async ({ request }) => {
     const r = await apiPost(request, '/api/energy/grant', { memberId: selfMemberId }, ctx.token);
     expect(r.code).not.toBe(200);
@@ -144,7 +129,6 @@ test.describe('【API级】能量系统收支全流程', () => {
   // ===== 场景6:完整能量收支流程 =====
 
   test('[场景6] 完整能量收支流程 - 期望全流程成功', async ({ request }) => {
-    test.fixme(true, 'CFC-EN-001:/api/energy/grant 返回 500 服务器内部错误,修复后移除本标记');
     const taskId = Number(uniqueSuffix());
     const amount = 5;