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

merge(origin/cfclub): 追平 cfclub 主线(S-cfc-236/224 等),task/S-cfc-242 supplier.nutrition.supply 契约测试合入

Sisyphus 1 час назад
Родитель
Сommit
efcf3fc5f6

+ 12 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3508,6 +3508,17 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移468: 创建心理测评记录表 mind_psychological_assessments
         // 迁移468: 创建心理测评记录表 mind_psychological_assessments
         //(S-cfc-237 teacher.mind.record · SC-T-25 舌诊/情绪测评录入(规划师)统一录入链路承载表)
         //(S-cfc-237 teacher.mind.record · SC-T-25 舌诊/情绪测评录入(规划师)统一录入链路承载表)
         createMindPsychologicalAssessmentTable();
         createMindPsychologicalAssessmentTable();
+
+        // 迁移469: five_dimension_scores.source_type 注释补充 selfcheck 来源(SC-T-32 五维自评桥接)
+        // 契约「五维自评 → 变化落 EnergyController」:自评分数写入五维分数变化(source_type=selfcheck),
+        // 同步 schema.sql 与 迁移68 建表注释;此处仅校准存量表列注释(幂等,无数据变更)。
+        try {
+            jdbcTemplate.execute("ALTER TABLE five_dimension_scores MODIFY COLUMN source_type "
+                    + "VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual/selfcheck'");
+            log.info("迁移469: five_dimension_scores.source_type 注释已补充 selfcheck 来源");
+        } catch (Exception e) {
+            log.warn("迁移469: five_dimension_scores.source_type 注释校准失败(幂等容忍): {}", e.getMessage());
+        }
     }
     }
 
 
     /**
     /**
@@ -6404,7 +6415,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
                     "dimension_code VARCHAR(20) NOT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth', " +
                     "dimension_code VARCHAR(20) NOT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth', " +
                     "score INT NOT NULL COMMENT '分数(0-100)', " +
                     "score INT NOT NULL COMMENT '分数(0-100)', " +
                     "assessed_at DATETIME NOT NULL COMMENT '评估时间', " +
                     "assessed_at DATETIME NOT NULL COMMENT '评估时间', " +
-                    "source_type VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual', " +
+                    "source_type VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual/selfcheck', " +
                     "source_id BIGINT COMMENT '来源记录ID', " +
                     "source_id BIGINT COMMENT '来源记录ID', " +
                     "detail_json TEXT COMMENT '详细数据JSON', " +
                     "detail_json TEXT COMMENT '详细数据JSON', " +
                     "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
                     "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java

@@ -6,8 +6,10 @@ import com.etotem.cfc.dto.SelfCheckResultVO;
 import com.etotem.cfc.dto.SubmitSelfCheckDTO;
 import com.etotem.cfc.dto.SubmitSelfCheckDTO;
 import com.etotem.cfc.dto.WuxingSourcingAdviceVO;
 import com.etotem.cfc.dto.WuxingSourcingAdviceVO;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FiveDimensionScore;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
 import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
 import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
 import com.etotem.cfc.entity.SelfCheckIgnore;
 import com.etotem.cfc.entity.SelfCheckIgnore;
 import com.etotem.cfc.mapper.SelfCheckIgnoreMapper;
 import com.etotem.cfc.mapper.SelfCheckIgnoreMapper;
@@ -63,6 +65,9 @@ public class FiveDimensionSelfCheckService {
     @Resource
     @Resource
     private SelfCheckTrendService selfCheckTrendService;
     private SelfCheckTrendService selfCheckTrendService;
 
 
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
     private final ObjectMapper objectMapper = new ObjectMapper();
 
 
     /** 维度中文名与五行 */
     /** 维度中文名与五行 */
@@ -498,6 +503,9 @@ public class FiveDimensionSelfCheckService {
         record.setCreatedAt(new Date());
         record.setCreatedAt(new Date());
         selfCheckMapper.insert(record);
         selfCheckMapper.insert(record);
 
 
+        // 五维自评 → 五维分数变化桥接(SC-T-32:自评分数写为五维分数变化,source_type=selfcheck)
+        bridgeSelfCheckToDimensionScores(record, scoreMap);
+
         // 新手引导联动:完成五维自检任务(老用户无该任务记录时静默跳过)
         // 新手引导联动:完成五维自检任务(老用户无该任务记录时静默跳过)
         try {
         try {
             onboardingService.completeTask(userId, "COMPLETE_SELF_CHECK");
             onboardingService.completeTask(userId, "COMPLETE_SELF_CHECK");
@@ -516,6 +524,49 @@ public class FiveDimensionSelfCheckService {
         return vo;
         return vo;
     }
     }
 
 
+    /**
+     * 五维自评 → 五维分数变化桥接(SC-T-32)。
+     *
+     * <p>契约「五维自评 → 变化落 EnergyController」:自评提交后,将各维度得分
+     * (0-9 制)等比例换算为 0-100,写入 {@code five_dimension_scores}
+     * (source_type=selfcheck),使自评分数可参与五维能量变化归因。
+     * 静默兜底:任何异常仅记录,不阻断自检主流程。</p>
+     */
+    private void bridgeSelfCheckToDimensionScores(FiveDimensionSelfCheck record, Map<String, Integer> scoreMap) {
+        if (record == null || record.getFamilyMemberId() == null || scoreMap == null || scoreMap.isEmpty()) {
+            return;
+        }
+        try {
+            FamilyMember member = familyMemberMapper.selectById(record.getFamilyMemberId());
+            if (member == null || member.getFamilyId() == null) {
+                return;
+            }
+            Date assessedAt = record.getCreatedAt() != null ? record.getCreatedAt() : new Date();
+            for (Map.Entry<String, Integer> entry : scoreMap.entrySet()) {
+                String dim = entry.getKey();
+                Integer raw = entry.getValue();
+                if (dim == null || raw == null) {
+                    continue;
+                }
+                FiveDimensionScore score = new FiveDimensionScore();
+                score.setMemberId(record.getFamilyMemberId());
+                score.setMemberType("member");
+                score.setFamilyId(member.getFamilyId());
+                score.setDimensionCode(dim);
+                // 自检维度分 0-9 制 → 0-100 制(四舍五入)
+                score.setScore(Math.round(raw * 100.0f / 9.0f));
+                score.setAssessedAt(assessedAt);
+                score.setSourceType("selfcheck");
+                score.setSourceId(record.getId());
+                score.setDetailJson("{}");
+                score.setCreatedAt(assessedAt);
+                fiveDimensionScoreMapper.insert(score);
+            }
+        } catch (Exception e) {
+            log.warn("五维自评桥接五维分数变化失败(静默容忍): {}", e.getMessage());
+        }
+    }
+
     /** 获取最新一次自检结果 */
     /** 获取最新一次自检结果 */
     public SelfCheckResultVO getLatest(Long userId) {
     public SelfCheckResultVO getLatest(Long userId) {
         LambdaQueryWrapper<FiveDimensionSelfCheck> wrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()
         LambdaQueryWrapper<FiveDimensionSelfCheck> wrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()

+ 1 - 1
cfc-backend/src/main/resources/schema.sql

@@ -3206,7 +3206,7 @@ CREATE TABLE IF NOT EXISTS five_dimension_scores (
     dimension_code VARCHAR(20) NOT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth',
     dimension_code VARCHAR(20) NOT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth',
     score INT NOT NULL COMMENT '分数(0-100)',
     score INT NOT NULL COMMENT '分数(0-100)',
     assessed_at DATETIME NOT NULL COMMENT '评估时间',
     assessed_at DATETIME NOT NULL COMMENT '评估时间',
-    source_type VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual',
+    source_type VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual/selfcheck',
     source_id BIGINT COMMENT '来源记录ID',
     source_id BIGINT COMMENT '来源记录ID',
     detail_json TEXT COMMENT '详细数据JSON',
     detail_json TEXT COMMENT '详细数据JSON',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

+ 383 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildEnergyGainContractTest.java

@@ -0,0 +1,383 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.EnergyController;
+import com.etotem.cfc.entity.EnergyBalance;
+import com.etotem.cfc.entity.EnergyDimension;
+import com.etotem.cfc.entity.EnergyLog;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.GrowthTask;
+import com.etotem.cfc.entity.GrowthTaskLog;
+import com.etotem.cfc.entity.MiniGame;
+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.EnergySourceConfigMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GameRecordMapper;
+import com.etotem.cfc.mapper.GrowthTaskLogMapper;
+import com.etotem.cfc.mapper.GrowthTaskMapper;
+import com.etotem.cfc.mapper.MiniGameMapper;
+import com.etotem.cfc.mapper.PointsLogMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.mapper.WishMapper;
+import com.etotem.cfc.service.CfPathwayEarnService;
+import com.etotem.cfc.service.ContentDimensionThresholdService;
+import com.etotem.cfc.service.EnergyRuleEngine;
+import com.etotem.cfc.service.EnergyService;
+import com.etotem.cfc.service.FiveDimensionScoreService;
+import com.etotem.cfc.service.GameRecordService;
+import com.etotem.cfc.service.GrowthTaskService;
+import com.etotem.cfc.service.MiniGameService;
+import com.etotem.cfc.service.NotificationDispatchService;
+import com.etotem.cfc.service.UnlockGateService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ZSetOperations;
+import org.springframework.web.bind.annotation.RequestAttribute;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 能力键 {@code child.energy.gain}(任务 S-cfc-224,TDD-RED 阶段)契约级单测。
+ *
+ * <p><b>需求/设计溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-24(小游戏/任务得分 → 五维能量)+
+ *       SC-C-25(方案任务打卡 → 五维能量);契约条款 {@code tests/test-cases/child.md}
+ *       TC-C-24 / TC-C-24-E(异常分数拦截、重复上报幂等)/ TC-C-24-P(他人能量 403、孩子端禁
+ *       grant/deduct)、TC-C-25 / TC-C-25-E(重复打卡幂等)/ TC-C-25-P。</li>
+ *   <li>设计:{@code docs/superpowers/specs/2026-11-07-child-zone-contract.md} §5
+ *       G-09(小游戏上报无幂等、无分数上限,{@code /api/game/record} 的 {@code pointsEarned}
+ *       前端可控——建议加 {@code (memberId,gameCode,date)} 幂等键、{@code score} 限 0~100、
+ *       {@code pointsEarned} 改服务端计算)/
+ *       G-10({@code /api/energy/overview}、{@code /logs} 无成员级/家庭级归属校验,可按传入
+ *       {@code memberId} 跨家庭读取)。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为(TDD-RED 锚点,当前实现未落地 → 红)</b></p>
+ * <ul>
+ *   <li><b>异常流(SC-C-24-E / G-09)</b>:{@link MiniGameService#completeGame} 对超上限(如
+ *       {@code score=999999})/ 负分必须拦截(抛异常且不写能量)——当前 {@code calculatePoints}
+ *       无上限校验、所有分数均被折算发放;</li>
+ *   <li><b>异常流(SC-C-24-E / G-09)</b>:{@link GameRecordService#saveRecord} 将客户端可控
+ *       {@code pointsEarned} 经 {@code Math.abs} 原样作为能量额发放——必须改服务端计算并封顶;</li>
+ *   <li><b>幂等流(SC-C-24-E / G-09)</b>:同 {@code (memberId, gameCode)} 连续上报只发一次能量——
+ *       当前每次上报均触发 {@code awardEnergy("game")};</li>
+ *   <li><b>权限流(TC-C-24-P / G-10)</b>:{@code /api/energy/overview} 对跨家庭 {@code memberId}
+ *       必须 403——当前直接返回 200(同一缺口面由 ChildEnergyViewContractTest SC-C-06 钉住);</li>
+ *   <li><b>权限流(TC-C-24-P)</b>:{@code grantEnergy} / {@code deductForWish} 必须含
+ *       {@code @RequestAttribute("role")} 角色门禁(孩子端直接调被拒)——当前无(反射结构探测);</li>
+ *   <li><b>守卫钩子(G-09)</b>:{@link EnergyRuleEngine} 应有分数校验/幂等守卫方法
+ *       ({@code validate/guard/score} 命名)——当前无(反射结构探测)。</li>
+ * </ul>
+ *
+ * <p><b>正常流锚点(已实现 → 绿,如实记录非 RED)</b></p>
+ * <ul>
+ *   <li>小游戏完成 → {@code awardEnergy(memberId,"game")} 触发链已存在({@link GameRecordService#saveRecord});</li>
+ *   <li>{@link GrowthTaskService#claimReward} → {@code awardEnergy(childId,"growth_day")} 触发链已存在;
+ *       SC-C-25 的 {@code TaskService.completeTask → awardEnergy("task")} 正常/幂等流已由
+ *       PlanActivationExecutionContractTest 覆盖,本类不重复。</li>
+ * </ul>
+ *
+ * <p><b>RED 预期</b>:8 条契约缺口断言失败(红)、2 条正常流锚点通过(绿);失败原文含
+ * {@code RED[child.energy.gain] …}。GREEN 阶段按 G-09/G-10 建议落地后自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯 Mockito / 无 Spring 上下文 / 无 DB / 无外部服务;直接实例化 Service/
+ * Controller 并注入 mock,行为断言 + 反射结构探测(复用
+ * {@link EnergyContractTestBase#inject}/{@link EnergyContractTestBase#initTable}/
+ * {@link EnergyContractTestBase#fiveDims})。</p>
+ */
+class ChildEnergyGainContractTest extends EnergyContractTestBase {
+
+    // ==================== 契约 fixture ====================
+
+    private static final Long MEMBER_ID = 50L;
+    private static final String GAME_CODE = "schulte";
+    private static final Long CALLER_FAMILY_ID = 1L;
+    /** 越权目标:不属于调用方家庭的另一成员。 */
+    private static final Long FOREIGN_MEMBER_ID = 999999L;
+
+    // ---- SC-C-24 小游戏链(真实 MiniGameService + 真实 GameRecordService + mock EnergyService)----
+    private MiniGameService miniGameService;
+    private GameRecordService gameRecordService;
+    private EnergyService gameEnergyService;
+    private MiniGameMapper miniGameMapper;
+    private FamilyMemberMapper gameFamilyMemberMapper;
+    private PointsLogMapper pointsLogMapper;
+
+    // ---- SC-C-25 成长任务链(真实 GrowthTaskService + mock 依赖)----
+    private GrowthTaskService growthTaskService;
+    private GrowthTaskMapper growthTaskMapper;
+    private GrowthTaskLogMapper growthTaskLogMapper;
+    private FamilyMemberMapper growthFamilyMemberMapper;
+    private EnergyService growthEnergyService;
+
+    // ---- EnergyController(真实 EnergyService + mock 依赖)----
+    private EnergyController controller;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        buildGameChain();
+        buildGrowthChain();
+        buildController();
+    }
+
+    private void buildGameChain() throws Exception {
+        gameEnergyService = mock(EnergyService.class);
+        gameRecordService = new GameRecordService();
+        GameRecordMapper gameRecordMapper = mock(GameRecordMapper.class);
+        miniGameMapper = mock(MiniGameMapper.class);
+        RedisTemplate<String, Object> redisTemplate = mock(RedisTemplate.class);
+        ZSetOperations<String, Object> zsetOps = mock(ZSetOperations.class);
+        when(redisTemplate.opsForZSet()).thenReturn(zsetOps);
+
+        inject(gameRecordService, "gameRecordMapper", gameRecordMapper);
+        inject(gameRecordService, "miniGameMapper", miniGameMapper);
+        inject(gameRecordService, "energyService", gameEnergyService);
+        inject(gameRecordService, "redisTemplate", redisTemplate);
+
+        miniGameService = new MiniGameService();
+        gameFamilyMemberMapper = mock(FamilyMemberMapper.class);
+        pointsLogMapper = mock(PointsLogMapper.class);
+        inject(miniGameService, "miniGameMapper", miniGameMapper);
+        inject(miniGameService, "familyMemberMapper", gameFamilyMemberMapper);
+        inject(miniGameService, "pointsLogMapper", pointsLogMapper);
+        inject(miniGameService, "gameRecordService", gameRecordService);
+        inject(miniGameService, "fiveDimensionScoreService", mock(FiveDimensionScoreService.class));
+        inject(miniGameService, "contentDimensionThresholdService", mock(ContentDimensionThresholdService.class));
+
+        MiniGame game = new MiniGame();
+        game.setId(1L);
+        game.setGameCode(GAME_CODE);
+        game.setGameName("舒尔特方格");
+        game.setDefaultPoints(5);
+        game.setSortOrder(1);
+        game.setStatus(1);
+        when(miniGameMapper.selectOne(any())).thenReturn(game);
+
+        FamilyMember child = new FamilyMember();
+        child.setId(MEMBER_ID);
+        child.setUserId(12L);
+        child.setTotalPoints(0);
+        when(gameFamilyMemberMapper.selectById(any())).thenReturn(child);
+
+        initTable(MiniGame.class);
+        initTable(FamilyMember.class);
+    }
+
+    private void buildGrowthChain() throws Exception {
+        growthTaskService = new GrowthTaskService();
+        growthTaskMapper = mock(GrowthTaskMapper.class);
+        growthTaskLogMapper = mock(GrowthTaskLogMapper.class);
+        growthFamilyMemberMapper = mock(FamilyMemberMapper.class);
+        growthEnergyService = mock(EnergyService.class);
+        CfPathwayEarnService cfPathwayEarnService = mock(CfPathwayEarnService.class);
+
+        inject(growthTaskService, "growthTaskMapper", growthTaskMapper);
+        inject(growthTaskService, "growthTaskLogMapper", growthTaskLogMapper);
+        inject(growthTaskService, "familyMemberMapper", growthFamilyMemberMapper);
+        inject(growthTaskService, "cfPathwayEarnService", cfPathwayEarnService);
+        inject(growthTaskService, "energyService", growthEnergyService);
+        inject(growthTaskService, "taskService", mock(com.etotem.cfc.service.TaskService.class));
+    }
+
+    private void buildController() throws Exception {
+        EnergyService service = new EnergyService();
+        EnergyDimensionMapper dimensionMapper = mock(EnergyDimensionMapper.class);
+        EnergyBehaviorConfigMapper behaviorConfigMapper = mock(EnergyBehaviorConfigMapper.class);
+        EnergyLogMapper logMapper = mock(EnergyLogMapper.class);
+        EnergyBalanceMapper balanceMapper = mock(EnergyBalanceMapper.class);
+        FamilyMemberMapper familyMemberMapper = mock(FamilyMemberMapper.class);
+
+        inject(service, "energyDimensionMapper", dimensionMapper);
+        inject(service, "energyBehaviorConfigMapper", behaviorConfigMapper);
+        inject(service, "energyLogMapper", logMapper);
+        inject(service, "energyBalanceMapper", balanceMapper);
+        inject(service, "energySourceConfigMapper", mock(EnergySourceConfigMapper.class));
+        inject(service, "wishMapper", mock(WishMapper.class));
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "notificationDispatchService", mock(NotificationDispatchService.class));
+
+        when(dimensionMapper.selectList(any())).thenReturn(Arrays.asList(fiveDims()));
+        when(behaviorConfigMapper.selectOne(any())).thenReturn(null);
+        when(logMapper.selectList(any())).thenReturn(Collections.emptyList());
+        when(balanceMapper.selectOne(any())).thenReturn(null);
+        FamilyMember member = new FamilyMember();
+        member.setId(FOREIGN_MEMBER_ID);
+        member.setUserId(888L);
+        when(familyMemberMapper.selectById(any())).thenReturn(member);
+
+        controller = new EnergyController();
+        inject(controller, "energyService", service);
+        inject(controller, "fiveDimensionScoreService", mock(FiveDimensionScoreService.class));
+        inject(controller, "userMapper", mock(UserMapper.class));
+        inject(controller, "unlockGateService", mock(UnlockGateService.class));
+
+        initTable(EnergyBalance.class);
+        initTable(EnergyDimension.class);
+        initTable(EnergyLog.class);
+    }
+
+    // ==================== 异常流(SC-C-24-E / G-09,RED) ====================
+
+    @Test
+    @DisplayName("SC-C-24-E RED:completeGame 超上限分数(999999)应拦截且不写能量")
+    void 异常流_超上限分数_应拦截() {
+        assertThrows(RuntimeException.class,
+                () -> miniGameService.completeGame(MEMBER_ID, GAME_CODE, 10, 999999),
+                "RED[child.energy.gain] SC-C-24-E/G-09:score>100 必须拦截(当前 calculatePoints 无上限,"
+                        + "999999 分被直接折算成上万能量发放)");
+        verify(gameEnergyService, never()).awardEnergy(any(), eq("game"), any(), any(), anyString(), isNull());
+    }
+
+    @Test
+    @DisplayName("SC-C-24-E RED:completeGame 负分(-5)应拦截且不写能量")
+    void 异常流_负分_应拦截() {
+        assertThrows(RuntimeException.class,
+                () -> miniGameService.completeGame(MEMBER_ID, GAME_CODE, 10, -5),
+                "RED[child.energy.gain] SC-C-24-E/G-09:score<0 必须拦截(当前负分被折算为保底积分照常发放)");
+        verify(gameEnergyService, never()).awardEnergy(any(), eq("game"), any(), any(), anyString(), isNull());
+    }
+
+    @Test
+    @DisplayName("SC-C-24-E RED:saveRecord 客户端 pointsEarned 直入能量必须封顶")
+    void 异常流_pointsEarned直入能量_应封顶() {
+        // 契约现场:客户端报文 pointsEarned=999999 直入能量(当前实现 Math.abs 原样发放)
+        gameRecordService.saveRecord(MEMBER_ID, GAME_CODE, 10, 999999, "medium", 999999);
+
+        // 契约:能量额必须由服务端计算并封顶(多发放 1000 即为违约)
+        verify(gameEnergyService, never()).awardEnergy(any(), eq("game"), any(),
+                argThat((Integer amt) -> amt != null && amt > 1000), anyString(), isNull());
+    }
+
+    // ==================== 幂等流(SC-C-24-E / G-09,RED) ====================
+
+    @Test
+    @DisplayName("SC-C-24-E RED:同 (memberId,gameCode) 连续上报只发一次能量(幂等键缺失)")
+    void 幂等流_同游戏重复上报_只发一次() {
+        miniGameService.completeGame(MEMBER_ID, GAME_CODE, 10, 80);
+        miniGameService.completeGame(MEMBER_ID, GAME_CODE, 10, 80);
+
+        // 契约:(memberId,gameCode,date) 幂等键下同一游戏同日仅发一次能量
+        verify(gameEnergyService, times(1)).awardEnergy(any(), eq("game"), any(), any(), anyString(), isNull());
+    }
+
+    // ==================== 权限流(TC-C-24-P / G-10,RED) ====================
+
+    @Test
+    @DisplayName("TC-C-24-P RED:/api/energy/overview 跨家庭 memberId 应 403(归属校验缺失)")
+    void 权限流_overview跨家庭_应403() {
+        Map<String, Object> params = new HashMap<>();
+        params.put("memberId", FOREIGN_MEMBER_ID);
+        Result<Map<String, Object>> r = controller.getEnergyOverview(params, CALLER_FAMILY_ID, MEMBER_ID);
+
+        assertEquals(Integer.valueOf(403), r.getCode(),
+                "RED[child.energy.gain] TC-C-24-P/G-10:跨家庭 memberId 必须 403(当前无归属校验直接返回 200)");
+    }
+
+    @Test
+    @DisplayName("TC-C-24-P RED:grantEnergy 应含 @RequestAttribute(\"role\") 角色门禁")
+    void 权限流_grantEnergy_应含role门禁() throws Exception {
+        Method m = EnergyController.class.getDeclaredMethod("grantEnergy", Map.class, Long.class, Long.class);
+        boolean hasRoleGate = false;
+        for (Parameter p : m.getParameters()) {
+            RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+            if (ra != null && "role".equals(ra.value())) hasRoleGate = true;
+        }
+        assertTrue(hasRoleGate,
+                "RED[child.energy.gain] TC-C-24-P:grantEnergy 必须含 @RequestAttribute(\"role\") 角色门禁"
+                        + "(孩子端直接调被拒),当前无角色参数");
+    }
+
+    @Test
+    @DisplayName("TC-C-24-P RED:deductForWish 应含 @RequestAttribute(\"role\") 角色门禁")
+    void 权限流_deductForWish_应含role门禁() throws Exception {
+        Method m = EnergyController.class.getDeclaredMethod("deductForWish", Map.class, Long.class, Long.class);
+        boolean hasRoleGate = false;
+        for (Parameter p : m.getParameters()) {
+            RequestAttribute ra = p.getAnnotation(RequestAttribute.class);
+            if (ra != null && "role".equals(ra.value())) hasRoleGate = true;
+        }
+        assertTrue(hasRoleGate,
+                "RED[child.energy.gain] TC-C-24-P:deductForWish 必须含 @RequestAttribute(\"role\") 角色门禁"
+                        + "(孩子端直接调被拒),当前无角色参数");
+    }
+
+    // ==================== 守卫钩子(G-09,RED) ====================
+
+    @Test
+    @DisplayName("守卫钩子 RED:EnergyRuleEngine 应有分数校验/幂等守卫方法")
+    void 守卫钩子_EnergyRuleEngine_应含守卫方法() {
+        boolean hasGuard = false;
+        for (Method m : EnergyRuleEngine.class.getDeclaredMethods()) {
+            if (m.getName().matches("(?i).*(validate|guard|score|idempot|dedup).*")) hasGuard = true;
+        }
+        assertTrue(hasGuard,
+                "RED[child.energy.gain] G-09:EnergyRuleEngine 应提供分数校验/幂等守卫方法"
+                        + "(validate/guard/score 命名),当前无");
+    }
+
+    // ==================== 正常流锚点(已实现,绿) ====================
+
+    @Test
+    @DisplayName("SC-C-24 正常锚点:completeGame 完成 → awardEnergy(memberId,\"game\") 触发链存在")
+    void 正常流_game触发链存在() {
+        miniGameService.completeGame(MEMBER_ID, GAME_CODE, 10, 80);
+
+        verify(gameEnergyService, atLeastOnce()).awardEnergy(any(), eq("game"), any(), any(), anyString(), isNull());
+    }
+
+    @Test
+    @DisplayName("SC-C-25 正常锚点:claimReward 领取 → awardEnergy(childId,\"growth_day\") 触发链存在")
+    void 正常流_growthDay触发链存在() {
+        Long userId = 100L;
+        GrowthTaskLog taskLog = new GrowthTaskLog();
+        taskLog.setId(88L);
+        taskLog.setUserId(userId);
+        taskLog.setTaskId(1L);
+        taskLog.setCompleted(1);
+        taskLog.setClaimed(0);
+        when(growthTaskLogMapper.selectById(88L)).thenReturn(taskLog);
+
+        GrowthTask task = new GrowthTask();
+        task.setId(1L);
+        task.setTaskKey("DAILY_TEST");
+        task.setRewardPoints(0);
+        task.setRewardEnergy(10);
+        when(growthTaskMapper.selectById(1L)).thenReturn(task);
+
+        FamilyMember child = new FamilyMember();
+        child.setId(MEMBER_ID);
+        child.setUserId(userId);
+        when(growthFamilyMemberMapper.selectOne(any())).thenReturn(child);
+
+        String r = growthTaskService.claimReward(userId, 88L);
+
+        assertEquals("领取成功", r);
+        verify(growthEnergyService).awardEnergy(eq(MEMBER_ID), eq("growth_day"), any(), any(), anyString(), any());
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-b5df0512751b21f62b24da31d1bdec8485e43e09
+8038be3288291745bec9999b551952d7ab5c0295

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.2450",
+  "version": "1.0.2451",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.2450",
+      "version": "1.0.2451",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.2451",
+  "version": "1.0.2452",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "vue-cli-service serve",
     "dev": "vue-cli-service serve",

+ 18 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,24 @@
 
 
 ---
 ---
 
 
+## v1.0.2452 (2026-11-13)
+
+### 其他
+- S-cfc-237 teacher.mind.record: 规划师舌诊/情绪测评统一录入链路落地(SC-T-25)
+-   代录受评客户按 memberId 归属,录入规划师按 guideId 归属)
+- - 新增 MindAssessmentService(createAssessment 入库 / getAssessments 按
+-   assessmentDate 倒序)
+- - 新增 MindPsychologicalAssessment 实体 + Mapper(memberId/guideId/
+-   assessmentType tongue|emotion /status 默认 draft→confirmed /assessmentDate)
+- - 迁移468 建表 mind_psychological_assessments + 同步 schema.sql
+- - 红单测 TeacherMindRecordContractTest 13/13 转绿,mvn clean compile 通过
+- 
+
+### 测试
+- S-cfc-241 supplier.activity.attend RED 契约测试(SC-S-07 营销活动报名,5红/4绿锚:商品打标载体缺位·运行期失效放行·服务商维度/跨商户隔离缺失三缺口)
+- child.energy.gain TDD-RED contract tests (SC-C-24/SC-C-25, 8 red + 2 green anchors)
+
+
 ## v1.0.2451 (2026-11-13)
 ## v1.0.2451 (2026-11-13)
 
 
 ### 其他
 ### 其他

+ 19 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.2451
+> 当前版本: v1.0.2452
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,24 @@
 
 
 ---
 ---
 
 
+## v1.0.2452 (2026-11-13)
+
+### 其他
+- S-cfc-237 teacher.mind.record: 规划师舌诊/情绪测评统一录入链路落地(SC-T-25)
+-   代录受评客户按 memberId 归属,录入规划师按 guideId 归属)
+- - 新增 MindAssessmentService(createAssessment 入库 / getAssessments 按
+-   assessmentDate 倒序)
+- - 新增 MindPsychologicalAssessment 实体 + Mapper(memberId/guideId/
+-   assessmentType tongue|emotion /status 默认 draft→confirmed /assessmentDate)
+- - 迁移468 建表 mind_psychological_assessments + 同步 schema.sql
+- - 红单测 TeacherMindRecordContractTest 13/13 转绿,mvn clean compile 通过
+- 
+
+### 测试
+- S-cfc-241 supplier.activity.attend RED 契约测试(SC-S-07 营销活动报名,5红/4绿锚:商品打标载体缺位·运行期失效放行·服务商维度/跨商户隔离缺失三缺口)
+- child.energy.gain TDD-RED contract tests (SC-C-24/SC-C-25, 8 red + 2 green anchors)
+
+
 ## v1.0.2451 (2026-11-13)
 ## v1.0.2451 (2026-11-13)
 
 
 ### 其他
 ### 其他