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

fix(energy): CFC-EN-001 修复 /api/energy/grant 500 服务器内部错误

根因:EnergyService.grantEnergy 委托 awardEnergy("task"),命中 task_complete
行为配置(dimension_assign=[{"dim":"action","ratio":100}]),能量按固定比例
全部分配到 action 维度;随后 grantEnergy 按请求的 dimensionCode 从分配结果
取数,请求 body 维度时 balanceMap.get("body")=null → 抛 RuntimeException →
GlobalExceptionHandler 未映射 → code=500「服务器内部错误」。

修复:
- grantEnergy 维度由调用方显式指定,能量直接发放到该维度(不再受行为配置
  固定比例约束),成功路径返回 EnergyLog 且写入余额+流水。
- 非法维度/非正数改为 IllegalArgumentException(400);日上限/未分配改为
  IllegalStateException(400 业务提示),不再裸抛 500。
- 补单测:EnergyServiceGrantEnergyTest(服务层 2 例)+ EnergyControllerGrantEnergyTest
  (控制层 3 例)覆盖 CFC-EN-001 成功路径;本地 energy 相关用例 11 例全绿。
- tests/e2e/api/energy-api.spec.js 移除场景2/场景6 两处 test.fixme 标记,
  并删除已失效的缺陷特征化用例场景2c(原断言 code=500)。

验证:mvn -o compile 通过;energy 相关单测 11 例全绿(GrantEnergy 5 例 +
Family 2 例 + WealthIncome 4 例)。
Sisyphus 1 неделя назад
Родитель
Сommit
129b1ab3f3

+ 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);
+    }
+}

+ 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;