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

merge(origin/cfclub): 追平 cfclub 主线后合并 supplier.afterSales.handle RED (S-cfc-240)

E2E Test Bot 3 часов назад
Родитель
Сommit
b0e025963b
24 измененных файлов с 2573 добавлено и 27 удалено
  1. 48 1
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 15 6
      cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java
  3. 7 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietRecommendationController.java
  4. 23 4
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java
  5. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PlanReviewController.java
  6. 76 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/mind/MindAssessmentController.java
  7. 65 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/MindPsychologicalAssessment.java
  8. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/MindPsychologicalAssessmentMapper.java
  9. 106 8
      cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java
  10. 51 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FiveDimensionSelfCheckService.java
  11. 63 0
      cfc-backend/src/main/java/com/etotem/cfc/service/MindAssessmentService.java
  12. 22 1
      cfc-backend/src/main/resources/schema.sql
  13. 383 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ChildEnergyGainContractTest.java
  14. 357 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ChildFeedbackSubmitContractTest.java
  15. 378 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/ChildShopPointsExchangeContractTest.java
  16. 405 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierActivityAttendContractTest.java
  17. 348 0
      cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierNutritionSupplyContractTest.java
  18. 1 1
      cfc-web/.last_build_commit
  19. 2 2
      cfc-web/package-lock.json
  20. 1 1
      cfc-web/package.json
  21. 76 0
      cfc-web/public/CHANGELOG-v1.0.md
  22. 77 1
      cfc-web/public/CHANGELOG.md
  23. 33 0
      docs/tdd/impl/impl-2026-11-13-S-cfc-226-teacher-plan-activate.md
  24. 1 1
      docs/tdd/orchestration/sc-key-map.md

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

@@ -3504,6 +3504,21 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 本列仅记录「某条服务角色申请挂在哪个邀请人名下」,与 users.referrerId(用户级推荐人)并存互不替代,
         // 且服务端写入时不触发任何分销/佣金副作用(物化推荐树/directCount/F24 CF 等)。
         addServiceRoleApplicationInviterColumn();
+
+        // 迁移468: 创建心理测评记录表 mind_psychological_assessments
+        //(S-cfc-237 teacher.mind.record · SC-T-25 舌诊/情绪测评录入(规划师)统一录入链路承载表)
+        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());
+        }
     }
 
     /**
@@ -6400,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', " +
                     "score INT NOT NULL COMMENT '分数(0-100)', " +
                     "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', " +
                     "detail_json TEXT COMMENT '详细数据JSON', " +
                     "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
@@ -15740,6 +15755,38 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    /**
+     * 迁移468: 创建心理测评记录表(S-cfc-237 teacher.mind.record · SC-T-25 舌诊/情绪测评录入(规划师))。
+     *
+     * <p>统一承载规划师代客户录入的舌诊(tongue)/情绪(emotion)测评记录:受评客户按 memberId 归属
+     * (用户模型统一口径)、录入规划师按 guideId 归属、status 默认 draft → confirmed 状态机(对齐舌诊链)、
+     * assessmentDate 为倒序查询排序键。幂等(CREATE TABLE IF NOT EXISTS)。</p>
+     */
+    private void createMindPsychologicalAssessmentTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS mind_psychological_assessments ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "member_id BIGINT NULL COMMENT '受评客户家庭成员ID(memberId 归属,用户模型统一口径)', "
+                    + "guide_id BIGINT NULL COMMENT '录入规划师ID(guideId 归属,代录链路审计)', "
+                    + "assessment_type VARCHAR(32) NULL COMMENT '测评类型: tongue(舌诊)/emotion(情绪) 双类', "
+                    + "status VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态机: draft(新建未确认)->confirmed(确认入库)', "
+                    + "assessment_date DATETIME NULL COMMENT '测评时间(倒序查询排序键)', "
+                    + "total_score INT NULL COMMENT '测评总分(可空)', "
+                    + "result_level VARCHAR(32) NULL COMMENT '结果等级(可空)', "
+                    + "result_summary VARCHAR(500) NULL COMMENT '结果摘要(可空)', "
+                    + "result_detail TEXT NULL COMMENT '结果明细(可空)', "
+                    + "report_url VARCHAR(500) NULL COMMENT '报告URL(可空)', "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', "
+                    + "INDEX idx_member_date (member_id, assessment_date), "
+                    + "INDEX idx_guide (guide_id) "
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='心理测评记录(舌诊/情绪统一录入,SC-T-25)'");
+            log.info("迁移468: 已创建 mind_psychological_assessments 表");
+        } catch (Exception e) {
+            log.warn("迁移468: 创建 mind_psychological_assessments 表失败(可能已存在): {}", e.getMessage());
+        }
+    }
+
     /** 幂等 upsert 维度种子:按 dimension_code 存在则补 source_system/legacy_alias,不存在则插入。 */
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
                                      int sort, String sourceSystem, String nodeKind,

+ 15 - 6
cfc-backend/src/main/java/com/etotem/cfc/controller/MembershipController.java

@@ -196,7 +196,8 @@ public class MembershipController {
             return Result.error("用户不存在");
         }
 
-        Long familyId = membershipService.getUserFamilyId(userId);
+        // S-cfc-232 family.family.autoCreateForPurchase: 无家庭新用户购买时自动创建初始家庭
+        Long familyId = membershipService.getOrCreateUserFamilyId(userId);
         String levelCode = params.getOrDefault("levelCode", "FAMILY").toString();
         String paymentType = params.getOrDefault("paymentType", "yearly").toString();
         String period = params.getOrDefault("period", "yearly").toString();
@@ -221,7 +222,8 @@ public class MembershipController {
             return Result.error("用户不存在");
         }
 
-        Long familyId = membershipService.getUserFamilyId(userId);
+        // S-cfc-232 family.family.autoCreateForPurchase: 无家庭新用户激活试用时自动创建初始家庭
+        Long familyId = membershipService.getOrCreateUserFamilyId(userId);
         PaymentOrderDTO order = membershipService.createOrder(userId, familyId, "FAMILY", "trial", null, null);
         return Result.success(order);
     }
@@ -242,7 +244,8 @@ public class MembershipController {
             return Result.error("用户不存在");
         }
 
-        Long familyId = membershipService.getUserFamilyId(userId);
+        // S-cfc-232 family.family.autoCreateForPurchase: 无家庭新用户下单时自动创建初始家庭
+        Long familyId = membershipService.getOrCreateUserFamilyId(userId);
         String levelCode = params.get("levelCode").toString();
         String paymentType = params.get("paymentType").toString();
         String period = params.get("period") != null ? params.get("period").toString() : "yearly";
@@ -280,7 +283,9 @@ public class MembershipController {
 
     /**
      * 购买会员档位(SC-P-58 一段式购买入口:选档位 → 待支付订单 + 支付参数)。
-     * 无家庭时 fail-fast 拒绝(与 createOrder 同源文案),家庭维度重复购买幂等在
+     * 无家庭新用户购买时自动创建初始家庭(family.family.autoCreateForPurchase;
+     * 与 /upgrade|/trial|/orders 同语义);get-or-create 仍无法获得家庭时
+     * noFamily 守卫兜底拒绝。家庭维度重复购买幂等在
      * {@code MembershipService#createOrder} 落单前守卫。
      */
     @Operation(summary = "购买会员档位")
@@ -295,7 +300,11 @@ public class MembershipController {
         if (user == null) {
             return Result.error("用户不存在");
         }
-        if (user.getFamilyId() == null) {
+
+        // S-cfc-232 family.family.autoCreateForPurchase: 无家庭新用户自动创建初始家庭后继续购买;
+        // 兜底:get-or-create 仍无法获得家庭时才 noFamily(5001) 守卫拒绝
+        Long familyId = membershipService.getOrCreateUserFamilyId(userId);
+        if (familyId == null) {
             return Result.noFamily("请先创建或加入家庭后再购买会员");
         }
 
@@ -307,7 +316,7 @@ public class MembershipController {
         Long couponId = ParamUtils.getLong(params.get("couponId"));
 
         Map<String, Object> result = membershipService.purchasePlan(
-                userId, user.getFamilyId(), levelCode, period, couponId);
+                userId, familyId, levelCode, period, couponId);
         if (result == null) {
             return Result.error(46001, "该商品暂未开放购买");
         }

+ 7 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/diet/DietRecommendationController.java

@@ -46,7 +46,13 @@ public class DietRecommendationController {
         if (familyId == null) {
             return Result.noFamily("请先创建或加入家庭");
         }
-        return Result.success(dietRecommendationService.generateRecommendation(familyId, request));
+        // [teacher.nutrition.plan / SC-T-26] 营养目标非法(负/超上限)→ 400 拒绝;
+        // 单测直调 Controller 不经 GlobalExceptionHandler,故在此捕获(与全局 400 语义一致)。
+        try {
+            return Result.success(dietRecommendationService.generateRecommendation(familyId, request));
+        } catch (IllegalArgumentException e) {
+            return Result.badRequest(e.getMessage());
+        }
     }
 
     @PostMapping("/update")

+ 23 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyTaskController.java

@@ -89,8 +89,7 @@ public class GuideFamilyTaskController {
     /**
      * 会员任务校验:task.memberOnly==1 且家庭非会员 → 拒绝(提示开通会员)
      */
-    private Result<?> checkMemberOnly(Long taskId) {
-        Task task = taskMapper.selectById(taskId);
+    private Result<?> checkMemberOnly(Task task) {
         if (task == null) {
             return Result.error("任务不存在");
         }
@@ -329,7 +328,16 @@ public class GuideFamilyTaskController {
         if (!RolesUtil.hasTeacherRole(role, roles)) { return Result.error("Access denied"); }
         Result<Void> denied = requireBound(guideId, familyId);
         if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
-        Result<?> memberCheck = checkMemberOnly(taskId);
+
+        // SC-T-06/TC-T-09-P 数据隔离:被审任务必须属于路径 familyId,否则拒绝(防跨家庭越权审核 IDOR)
+        Task task = taskMapper.selectById(taskId);
+        if (task == null) {
+            return Result.error("任务不存在");
+        }
+        if (!familyId.equals(task.getFamilyId())) {
+            return Result.error(403, "任务不属于该家庭");
+        }
+        Result<?> memberCheck = checkMemberOnly(task);
         if (memberCheck != null) {
             return Result.error(memberCheck.getMessage());
         }
@@ -373,8 +381,19 @@ public class GuideFamilyTaskController {
 
         for (Long taskId : taskIds) {
             try {
+                Task task = taskMapper.selectById(taskId);
+                if (task == null) {
+                    failCount++;
+                    continue;
+                }
+                // SC-T-06/TC-T-09-P 数据隔离:被审任务必须属于路径 familyId,
+                // 跨家庭任务跳过且不进入审核(TC-T-18-E 部分失败不影响其他,各自独立不落库)
+                if (!familyId.equals(task.getFamilyId())) {
+                    failCount++;
+                    continue;
+                }
                 // 会员任务校验:审核通过会员任务需家庭为会员
-                Result<?> memberCheck = checkMemberOnly(taskId);
+                Result<?> memberCheck = checkMemberOnly(task);
                 if (memberCheck != null) {
                     failCount++;
                     continue;

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PlanReviewController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.PlanActivationService;
+import com.etotem.cfc.service.GuideFamilyAccessGuard;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.slf4j.Logger;
@@ -40,6 +41,10 @@ public class PlanReviewController {
     @Resource
     private com.etotem.cfc.service.PlannerCertificationGuard plannerCertificationGuard;
 
+    /** 规划师-家庭绑定统一授权判定点(全项目唯一,SC-T-13 数据隔离) */
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
     @Operation(summary = "查看家庭的方案列表")
     @PostMapping("/family-list")
     public Result<List<TaskPlanInstance>> listFamilyPlans(@RequestBody Map<String, Object> body,
@@ -62,6 +67,23 @@ public class PlanReviewController {
                 return Result.noFamily("请先创建或加入家庭");
             }
         }
+        // SC-T-13 数据隔离(teacher.data.isolation):teacher 仅可访问其绑定家庭
+        // (guide_families status='binding' isBound 校验,统一授权判定点 GuideFamilyAccessGuard)。
+        // 非绑定 familyId(含 body.familyId 越权目标)在触达方案查询层前 403 拒绝,防止 IDOR 跨家庭泄漏。
+        if (!"admin".equals(role)) {
+            Result<Void> denied;
+            if (guideFamilyAccessGuard != null) {
+                denied = guideFamilyAccessGuard.checkBinding(userId, filterFamilyId);
+            } else {
+                // 守卫不可用(如无 Spring 上下文的契约单测夹具)→ 失败关闭:收敛到 JWT 家庭,
+                // 请求家庭 ≠ JWT 家庭视为越权拒绝(与 isBound 对越权目标的判定一致,仅测试环境触发)
+                denied = filterFamilyId != null && filterFamilyId.equals(jwtFamilyId)
+                        ? null : Result.error(403, "无权访问该家庭数据");
+            }
+            if (denied != null) {
+                return Result.error(denied.getCode(), denied.getMessage());
+            }
+        }
         LambdaQueryWrapper<TaskPlanInstance> w = new LambdaQueryWrapper<TaskPlanInstance>()
                 .orderByDesc(TaskPlanInstance::getCreatedAt);
         if (filterFamilyId != null) {

+ 76 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/MindAssessmentController.java

@@ -0,0 +1,76 @@
+package com.etotem.cfc.controller.mind;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.MindPsychologicalAssessment;
+import com.etotem.cfc.service.MindAssessmentService;
+import com.etotem.cfc.util.ParamUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 规划师舌诊/情绪测评录入统一入口(能力键 {@code teacher.mind.record},SC-T-25)。
+ *
+ * <p>承载规划师代客户录入舌诊(tongue)/情绪(emotion)测评的统一链路:
+ * <ul>
+ *   <li>{@code POST /api/mind/assessment/create}:代录,受评客户按 {@code memberId} 归属,
+ *       录入规划师按当前登录 {@code userId} 归属(guideId),不落 userId/childId 表达受评主体;</li>
+ *   <li>{@code POST /api/mind/assessment/list}:按受评客户成员查看测评记录(assessmentDate 倒序)。</li>
+ * </ul>
+ *
+ * <p>契约溯源:docs/superpowers/specs/2026-11-07-planner-zone-contract.md §2(SC-T-25)/ §5.5;
+ * API_REFERENCE §4.57;设计 docs/backup/superpowers/plans/2026-06-02-care-family-club-design.md
+ * (controller/mind/MindAssessmentController)。权限流(仅绑定家庭客户可查)由 JwtInterceptor 角色隔离
+ * + SC-T-13 契约承担,本控制器不重复断言。
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/mind/assessment")
+public class MindAssessmentController {
+
+    @Resource
+    private MindAssessmentService mindAssessmentService;
+
+    /**
+     * 规划师代录:受评客户按 memberId 归属,录入规划师按当前登录 userId 归属(guideId)。
+     */
+    @PostMapping("/create")
+    public Result<?> create(@RequestBody MindPsychologicalAssessment assessment,
+                            @RequestAttribute("userId") Long userId) {
+        try {
+            if (assessment == null || assessment.getMemberId() == null) {
+                return Result.badRequest("受评客户 memberId 不能为空");
+            }
+            // 代录链路:录入规划师 = 当前登录账号(role=teacher)
+            if (assessment.getGuideId() == null) {
+                assessment.setGuideId(userId);
+            }
+            Object result = mindAssessmentService.createAssessment(assessment);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("创建心理测评记录失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 按受评客户成员查看测评记录(assessmentDate 倒序)。
+     */
+    @PostMapping("/list")
+    public Result<?> list(@RequestBody Map<String, Object> params,
+                          @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        if (memberId == null) {
+            return Result.badRequest("受评客户 memberId 不能为空");
+        }
+        List<MindPsychologicalAssessment> result = mindAssessmentService.getAssessments(memberId);
+        return Result.success(result);
+    }
+}

+ 65 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MindPsychologicalAssessment.java

@@ -0,0 +1,65 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 心理测评记录载体(能力键 {@code teacher.mind.record},SC-T-25 舌诊/情绪测评录入·规划师)。
+ *
+ * <p>统一承载规划师代客户录入的舌诊(tongue)/情绪(emotion)测评记录:
+ * <ul>
+ *   <li>{@code memberId}:受评客户家庭成员归属(遵循用户模型统一口径,禁止回退 userId/childId 表达受评主体);</li>
+ *   <li>{@code guideId}:录入规划师(role=teacher)归属,代录链路审计必备;</li>
+ *   <li>{@code assessmentType}:tongue(舌诊)/ emotion(情绪)双类;非 tongue 类型不得落入舌诊链;</li>
+ *   <li>{@code status}:状态机 draft(新建未确认)→ confirmed(规划师确认入库),默认 draft,对齐舌诊链语义;</li>
+ *   <li>{@code assessmentDate}:测评时间,{@code getAssessments} 按此倒序返回。</li>
+ * </ul>
+ *
+ * <p>契约溯源:docs/superpowers/specs/2026-11-07-planner-zone-contract.md §2(SC-T-25)/ §5.5;
+ * API_REFERENCE §4.57;设计 docs/backup/superpowers/plans/2026-06-02-care-family-club-design.md(mind 域实体)。
+ */
+@Data
+@TableName("mind_psychological_assessments")
+public class MindPsychologicalAssessment {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 受评客户家庭成员归属(用户模型统一口径,不用 userId/childId)。 */
+    private Long memberId;
+
+    /** 录入规划师(role=teacher)归属。 */
+    private Long guideId;
+
+    /** 测评类型:tongue(舌诊)/ emotion(情绪)双类(SC-T-25)。 */
+    private String assessmentType;
+
+    /** 测评记录状态机:draft(新建未确认)→ confirmed(规划师确认入库),默认 draft。 */
+    private String status = "draft";
+
+    /** 测评时间(getAssessments 倒序排序键)。 */
+    private LocalDateTime assessmentDate;
+
+    /** 测评总分(情绪自评等,可空)。 */
+    private Integer totalScore;
+
+    /** 结果等级(可空)。 */
+    private String resultLevel;
+
+    /** 结果摘要(可空)。 */
+    private String resultSummary;
+
+    /** 结果明细(可空)。 */
+    private String resultDetail;
+
+    /** 报告 URL(可空)。 */
+    private String reportUrl;
+
+    private LocalDateTime createdAt;
+
+    private LocalDateTime updatedAt;
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/MindPsychologicalAssessmentMapper.java

@@ -0,0 +1,13 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.MindPsychologicalAssessment;
+
+/**
+ * 心理测评记录 Mapper(能力键 {@code teacher.mind.record},SC-T-25)。
+ *
+ * <p>由 {@code @MapperScan("com.etotem.cfc.mapper")} 扫描注册,无需 {@code @Mapper} 注解。
+ * 对应表 {@code mind_psychological_assessments}(迁移468)。
+ */
+public interface MindPsychologicalAssessmentMapper extends BaseMapper<MindPsychologicalAssessment> {
+}

+ 106 - 8
cfc-backend/src/main/java/com/etotem/cfc/service/DietRecommendationService.java

@@ -55,6 +55,19 @@ public class DietRecommendationService {
     private static final List<String> TODAY_STATUS_WHITELIST =
             Collections.unmodifiableList(Arrays.asList("pending", "accepted", "completed"));
 
+    /**
+     * 营养目标上限(teacher.nutrition.plan / SC-T-26 精准营养方案制定)。
+     * 日口径:热量 kcal、宏量营养素 g;任一字段超出视为非法目标(超上限拦截)。
+     * 取值远高于正常人体日需(热量 ~1800-3000 kcal),仅作「不可能值」闸门而非医学建议。
+     */
+    private static final Map<String, Integer> NUTRITION_GOAL_MAX = Collections.unmodifiableMap(
+            new HashMap<String, Integer>() {{
+                put("calories", 20000);
+                put("protein", 1000);
+                put("carbs", 2000);
+                put("fat", 500);
+            }});
+
     @Resource
     private DietRecommendationMapper dietRecommendationMapper;
 
@@ -131,6 +144,9 @@ public class DietRecommendationService {
      * @return {@code id / menu / nutritionSummary / message / participantCount / dishCount}
      */
     public Map<String, Object> generateRecommendation(Long familyId, Map<String, Object> request) {
+        // [teacher.nutrition.plan / SC-T-26] 营养目标(热量/宏量营养素)先校验:非法(负/超上限)→ 拒绝
+        validateNutritionGoals(request == null ? null : request.get("nutrition_goals"));
+
         String dateStr = asString(request.get("date"));
         if (!hasText(dateStr)) {
             dateStr = LocalDate.now().toString();
@@ -335,6 +351,60 @@ public class DietRecommendationService {
         return value != null && !value.trim().isEmpty();
     }
 
+    /**
+     * 营养目标校验(teacher.nutrition.plan / SC-T-26):{@code nutrition_goals} 形如
+     * {@code {calories, protein, carbs, fat}},支持原生 Map 与 JSON 字符串双形态。
+     * 未传视为未设目标(放行);任一字段为负 / 超上限 / 非数字 → IllegalArgumentException
+     * (Controller 映射为 badRequest 400),而非静默忽略。
+     */
+    private static void validateNutritionGoals(Object goalsObj) {
+        if (goalsObj == null) {
+            return;
+        }
+        Map<?, ?> goals;
+        if (goalsObj instanceof Map) {
+            goals = (Map<?, ?>) goalsObj;
+        } else {
+            String json = String.valueOf(goalsObj);
+            if (!hasText(json) || "null".equals(json)) {
+                return;
+            }
+            try {
+                JsonNode node = OBJECT_MAPPER.readTree(json);
+                if (node == null || !node.isObject()) {
+                    throw new IllegalArgumentException("nutrition_goals 格式错误");
+                }
+                goals = OBJECT_MAPPER.convertValue(node, Map.class);
+            } catch (IllegalArgumentException e) {
+                throw e;
+            } catch (Exception e) {
+                throw new IllegalArgumentException("nutrition_goals 格式错误");
+            }
+        }
+        for (Map.Entry<String, Integer> limit : NUTRITION_GOAL_MAX.entrySet()) {
+            Object raw = goals.get(limit.getKey());
+            if (raw == null) {
+                continue;
+            }
+            long val;
+            if (raw instanceof Number) {
+                val = ((Number) raw).longValue();
+            } else {
+                try {
+                    val = Long.parseLong(String.valueOf(raw).trim());
+                } catch (NumberFormatException e) {
+                    throw new IllegalArgumentException("营养目标[" + limit.getKey() + "]必须是数字");
+                }
+            }
+            if (val < 0) {
+                throw new IllegalArgumentException("营养目标[" + limit.getKey() + "]不能为负数");
+            }
+            if (val > limit.getValue()) {
+                throw new IllegalArgumentException("营养目标[" + limit.getKey() + "]超上限(" + limit.getValue() + ")");
+            }
+        }
+    }
+
     /** Map<String,Object> 下保持 date / meal_type / selected_foods 的原 String 语义(K3 / R-8)。 */
     private static String asString(Object value) {
         if (value == null) {
@@ -655,6 +725,11 @@ public class DietRecommendationService {
         } else {
             dish.put("calories", 0);
         }
+        // [teacher.nutrition.plan / SC-T-26] 保留 AI 菜品的宏量营养素(缺 nutrition 的占位菜/汤贡献 0),
+        // 使 aggregateNutrition 能真实聚合、方案 nutritionSummary 可承载「热量/宏量营养素」目标。
+        putNutrient(dish, "protein", nutrition, dishNode);
+        putNutrient(dish, "carbs", nutrition, dishNode);
+        putNutrient(dish, "fat", nutrition, dishNode);
         List<Map<String, Object>> ingredients = new ArrayList<>();
         JsonNode ingNode = dishNode.get("ingredients");
         if (ingNode != null && ingNode.isArray()) {
@@ -682,6 +757,17 @@ public class DietRecommendationService {
         return dish;
     }
 
+    /** 从 nutrition(或 dishNode 顶层)读取单个宏量营养素字段;缺失时置 0。 */
+    private static void putNutrient(Map<String, Object> dish, String key, JsonNode nutrition, JsonNode dishNode) {
+        if (nutrition != null && nutrition.has(key)) {
+            dish.put(key, nutrition.get(key).asInt());
+        } else if (dishNode.has(key)) {
+            dish.put(key, dishNode.get(key).asInt());
+        } else {
+            dish.put(key, 0);
+        }
+    }
+
     /** 占位菜/汤:结构与 toDish 输出一致(归一化后结构),calories=0,带可识别的 type。 */
     private static Map<String, Object> placeholderDish(List<String> names, String type, int seq, String defaultName) {
         Map<String, Object> dish = new LinkedHashMap<>();
@@ -708,10 +794,17 @@ public class DietRecommendationService {
         return dish;
     }
 
-    /** 聚合营养汇总(仅回归:汤也计入热量;protein/carbs/fat 恒 0 不在本轮范围)。 */
+    /**
+     * 聚合营养汇总(仅回归:汤也计入热量)。
+     * [teacher.nutrition.plan / SC-T-26] protein/carbs/fat 由 {@link #toDish} 保留后在此真实聚合;
+     * 缺营养数据的占位菜/汤贡献 0。
+     */
     private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
         Map<String, Object> summary = new LinkedHashMap<>();
         int calories = 0;
+        int protein = 0;
+        int carbs = 0;
+        int fat = 0;
         @SuppressWarnings("unchecked")
         List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
         if (meals != null) {
@@ -722,20 +815,25 @@ public class DietRecommendationService {
                     continue;
                 }
                 for (Map<String, Object> dish : dishes) {
-                    Object cal = dish.get("calories");
-                    if (cal instanceof Number) {
-                        calories += ((Number) cal).intValue();
-                    }
+                    calories += asInt(dish.get("calories"));
+                    protein += asInt(dish.get("protein"));
+                    carbs += asInt(dish.get("carbs"));
+                    fat += asInt(dish.get("fat"));
                 }
             }
         }
         summary.put("calories", calories);
-        summary.put("protein", 0);
-        summary.put("carbs", 0);
-        summary.put("fat", 0);
+        summary.put("protein", protein);
+        summary.put("carbs", carbs);
+        summary.put("fat", fat);
         return summary;
     }
 
+    /** Number → int,非数字(含占位菜缺失字段)按 0 计。 */
+    private static int asInt(Object v) {
+        return v instanceof Number ? ((Number) v).intValue() : 0;
+    }
+
     /** 兼容旧签名(类图约定):自行读取可用食材。 */
     private String buildFallbackMenu(Long familyId, LocalDate date, int participantCount) {
         return buildFallbackMenu(familyId, date, participantCount, availableIngredientNames(familyId, date));

+ 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.WuxingSourcingAdviceVO;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FiveDimensionScore;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
 import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
 import com.etotem.cfc.entity.SelfCheckIgnore;
 import com.etotem.cfc.mapper.SelfCheckIgnoreMapper;
@@ -63,6 +65,9 @@ public class FiveDimensionSelfCheckService {
     @Resource
     private SelfCheckTrendService selfCheckTrendService;
 
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     /** 维度中文名与五行 */
@@ -498,6 +503,9 @@ public class FiveDimensionSelfCheckService {
         record.setCreatedAt(new Date());
         selfCheckMapper.insert(record);
 
+        // 五维自评 → 五维分数变化桥接(SC-T-32:自评分数写为五维分数变化,source_type=selfcheck)
+        bridgeSelfCheckToDimensionScores(record, scoreMap);
+
         // 新手引导联动:完成五维自检任务(老用户无该任务记录时静默跳过)
         try {
             onboardingService.completeTask(userId, "COMPLETE_SELF_CHECK");
@@ -516,6 +524,49 @@ public class FiveDimensionSelfCheckService {
         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) {
         LambdaQueryWrapper<FiveDimensionSelfCheck> wrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()

+ 63 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MindAssessmentService.java

@@ -0,0 +1,63 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.MindPsychologicalAssessment;
+import com.etotem.cfc.mapper.MindPsychologicalAssessmentMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 统一录入服务(能力键 {@code teacher.mind.record},SC-T-25 舌诊/情绪测评录入·规划师)。
+ *
+ * <p>规划师端舌诊/情绪测评录入统一入口的服务载体:桥接既有双类载体语义
+ * (舌诊链 {@code TongueDiagnosisService} + 情绪链 {@code EmotionCheckinService}),
+ * 统一落库 {@link MindPsychologicalAssessment}(memberId 受评客户归属 + guideId 录入规划师归属 +
+ * assessmentType tongue/emotion 双类 + status draft→confirmed 状态机 + assessmentDate 测评时间)。
+ *
+ * <p>契约溯源:docs/superpowers/specs/2026-11-07-planner-zone-contract.md §2(SC-T-25)/ §5.5;
+ * API_REFERENCE §4.57;设计 docs/backup/superpowers/plans/2026-06-02-care-family-club-design.md
+ * (MindAssessmentService.createAssessment → mapper.insert / getAssessments orderByDesc(assessmentDate))。
+ */
+@Service
+public class MindAssessmentService {
+
+    @Resource
+    private MindPsychologicalAssessmentMapper mindPsychologicalAssessmentMapper;
+
+    /**
+     * 测评记录入库:默认 status=draft、assessmentDate=now,落库后回填自增 id。
+     */
+    public MindPsychologicalAssessment createAssessment(MindPsychologicalAssessment assessment) {
+        if (assessment == null) {
+            throw new IllegalArgumentException("assessment 不能为空");
+        }
+        if (assessment.getStatus() == null || assessment.getStatus().isEmpty()) {
+            assessment.setStatus("draft");
+        }
+        if (assessment.getAssessmentDate() == null) {
+            assessment.setAssessmentDate(LocalDateTime.now());
+        }
+        if (assessment.getCreatedAt() == null) {
+            assessment.setCreatedAt(LocalDateTime.now());
+        }
+        if (assessment.getUpdatedAt() == null) {
+            assessment.setUpdatedAt(LocalDateTime.now());
+        }
+        mindPsychologicalAssessmentMapper.insert(assessment);
+        return assessment;
+    }
+
+    /**
+     * 按受评客户成员查询测评记录,{@code assessmentDate} 倒序。
+     */
+    public List<MindPsychologicalAssessment> getAssessments(Long memberId) {
+        return mindPsychologicalAssessmentMapper.selectList(
+                new LambdaQueryWrapper<MindPsychologicalAssessment>()
+                        .eq(MindPsychologicalAssessment::getMemberId, memberId)
+                        .orderByDesc(MindPsychologicalAssessment::getAssessmentDate)
+        );
+    }
+}

+ 22 - 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',
     score INT NOT NULL COMMENT '分数(0-100)',
     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',
     detail_json TEXT COMMENT '详细数据JSON',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -7218,3 +7218,24 @@ CREATE TABLE IF NOT EXISTS service_cost_config (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     UNIQUE KEY uk_service_cost (service_type, product_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务成本价配置';
+
+-- ============================================================
+-- 心理测评记录表(迁移468,S-cfc-237 teacher.mind.record · SC-T-25 舌诊/情绪测评录入(规划师)统一录入链路)
+-- ============================================================
+CREATE TABLE IF NOT EXISTS mind_psychological_assessments (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NULL COMMENT '受评客户家庭成员ID(memberId 归属,用户模型统一口径)',
+    guide_id BIGINT NULL COMMENT '录入规划师ID(guideId 归属,代录链路审计)',
+    assessment_type VARCHAR(32) NULL COMMENT '测评类型: tongue(舌诊)/emotion(情绪) 双类',
+    status VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT '状态机: draft(新建未确认)->confirmed(确认入库)',
+    assessment_date DATETIME NULL COMMENT '测评时间(倒序查询排序键)',
+    total_score INT NULL COMMENT '测评总分(可空)',
+    result_level VARCHAR(32) NULL COMMENT '结果等级(可空)',
+    result_summary VARCHAR(500) NULL COMMENT '结果摘要(可空)',
+    result_detail TEXT NULL COMMENT '结果明细(可空)',
+    report_url VARCHAR(500) NULL COMMENT '报告URL(可空)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_member_date (member_id, assessment_date),
+    INDEX idx_guide (guide_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='心理测评记录(舌诊/情绪统一录入,SC-T-25)';

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

+ 357 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildFeedbackSubmitContractTest.java

@@ -0,0 +1,357 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.controller.DailyFeedbackController;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * 契约级单测:孩子/家庭成员提交意见反馈(能力键 {@code child.feedback.submit})。
+ *
+ * <p>契约来源(requirements + design):</p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-23「意见反馈(孩子端)」——
+ *       <b>主流程</b>:孩子(不能登录用户,memberType=local)提交反馈(内容/类型)→ 进入客服队列;
+ *       <b>异常/边界</b>:内容适龄过滤;敏感信息脱敏;附件大小限制。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.61.7 缺口 G-06——
+ *       「无通用意见反馈端点/表/管理端;现有反馈均为专用方向」,修复建议:
+ *       <b>新增 {@code user_feedback} 表 + {@code POST /api/user/feedback/submit}
+ *       (避开 {@code /api/feedback} 前缀冲突)+ 管理端受理</b>。
+ *       现有 {@link DailyFeedbackController}({@code /api/feedback/*},家长→孩子)、
+ *       {@code ActivityFeedbackController}({@code /api/activity/feedback/*},活动评价)、
+ *       {@code HealthReportController.submitFeedback}({@code /api/health/feedback/submit},报告维度)
+ *       均不是「孩子→客服队列」的通用意见反馈通道。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(本分支 src/main 核实)</b>:契约面全部缺失——
+ * <ul>
+ *   <li>无 {@code user_feedback} 表(schema.sql 0 命中);</li>
+ *   <li>无 {@code UserFeedback} 实体 / {@code UserFeedbackService} / {@code UserFeedbackController}
+ *       (src/main 0 命中);</li>
+ *   <li>无针对意见反馈的内容适龄过滤 / 敏感信息脱敏 / 附件大小限制。
+ *       (现库内「敏感信息」文案均为文章/图片/语音等审核位点,非反馈通道。)</li>
+ * </ul>
+ * 孩子(不能登录用户)无 userId,反馈须以家庭成员 {@code memberId} 维度承载(同 SC-C-15/C-19 口径)。</p>
+ *
+ * <p><b>测试策略</b>:纯反射结构契约 + schema.sql 文本探测(无 Spring / 无 DB / 无外部服务),
+ * 能编译、能运行、断言失败(红),失败信息可读可贴。GREEN 阶段实现后本测试自动转绿。</p>
+ *
+ * <p><b>RED 预期</b>:下列断言在实现落地前应全部失败(红);其中「正常流-1」为既有
+ * {@code /api/feedback/today} 绿锚点——证明 DailyFeedbackController 方向(家长→孩子)
+ * 与契约要求(孩子提交→客服队列)相反,不作为本能力载体。</p>
+ */
+class ChildFeedbackSubmitContractTest {
+
+    /** 契约建议的表(§4.61.7 G-06):通用意见反馈表 */
+    private static final String FEEDBACK_TABLE = "user_feedback";
+
+    /** 契约建议的端点全路径(避开 /api/feedback 前缀冲突) */
+    private static final String SUBMIT_PATH = "/api/user/feedback/submit";
+
+    /** 孩子(不能登录用户)的家庭成员标识 */
+    private static final Long MEMBER_ID = 88L;
+
+    // ------------------------------------------------------------------
+    // 正常流:通用意见反馈通道(POST /api/user/feedback/submit)三件套
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("正常流-1(绿锚点):DailyFeedbackController 为家长→孩子方向,非孩子提交通道")
+    void dailyFeedback_isParentToChildDirection() {
+        Class<?> clazz = DailyFeedbackController.class;
+        String classPath = resolveRequestMapping(clazz);
+        boolean hasToday = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            Map<String, String> mapping = resolveMappingPath(m);
+            if ("/today".equals(mapping.get("path")) && "POST".equals(mapping.get("httpMethod"))) {
+                hasToday = true;
+                break;
+            }
+        }
+        assertNotNull(classPath, "[child.feedback.submit] DailyFeedbackController 应保留 /api/feedback 类映射。");
+        assertTrue(("/api/feedback".equals(classPath) && hasToday),
+                "[child.feedback.submit] DailyFeedbackController 应为 /api/feedback/today(家长→孩子即时反馈),"
+                        + "与人「孩子→客服队列」的意见反馈方向相反,不能作为 child.feedback.submit 载体。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:UserFeedback 实体应存在(涉及 user_feedback 表)")
+    void entity_userFeedback_exists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.entity.UserFeedback");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedback 实体不存在:契约要求通用意见反馈落库 user_feedback"
+                        + "(§4.61.7 G-06;SC-C-23 主流程)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:UserFeedback 实体应承载内容/类型/队列状态")
+    void entity_userFeedback_carriesSumbitFields() {
+        Class<?> clazz = loadClass("com.etotem.cfc.entity.UserFeedback");
+        if (clazz == null) {
+            fail("[child.feedback.submit] UserFeedback 实体不存在,无法验证提交字段(SC-C-23 主流程)。");
+        }
+        for (String required : new String[]{"content", "type", "status", "attachmentUrl"}) {
+            boolean has = false;
+            for (Field f : clazz.getDeclaredFields()) {
+                if (f.getName().equals(required)) {
+                    has = true;
+                    break;
+                }
+            }
+            assertTrue(has,
+                    "[child.feedback.submit] UserFeedback 应含字段 " + required + "(内容/类型/客服队列状态/"
+                            + "附件),契约要点:提交反馈(内容/类型)→ 进入客服队列(SC-C-23)。实现未落地。");
+        }
+    }
+
+    @Test
+    @DisplayName("正常流-4:UserFeedbackService 应存在并提供 submit 入口")
+    void service_userFeedback_serviceAndSubmitExists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.service.UserFeedbackService");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackService 不存在:契约要求通用意见反馈提交入口"
+                        + "(§4.61.7 G-06;SC-C-23 主流程)。实现未落地。");
+        boolean hasSubmit = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("submit")) {
+                hasSubmit = true;
+                break;
+            }
+        }
+        assertTrue(hasSubmit,
+                "[child.feedback.submit] UserFeedbackService 应提供 submit(...) 入口(内容/类型 → 客服队列),"
+                        + "接受成员级 memberId 维度(孩子不能登录无 userId;SC-C-23 主流程)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:UserFeedbackController 应存在并映射 POST /api/user/feedback/submit")
+    void controller_userFeedback_submitMappingExists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.controller.UserFeedbackController");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackController 不存在:契约要求 POST /api/user/feedback/submit"
+                        + " 意见反馈端点(§4.61.7 G-06,避开 /api/feedback 前缀冲突;SC-C-23 主流程)。实现未落地。");
+        boolean hasSubmit = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            Map<String, String> mapping = resolveMappingPath(m);
+            if (SUBMIT_PATH.equals(mapping.get("path")) && "POST".equals(mapping.get("httpMethod"))) {
+                hasSubmit = true;
+                break;
+            }
+        }
+        assertTrue(hasSubmit,
+                "[child.feedback.submit] UserFeedbackController 应映射 " + SUBMIT_PATH
+                        + "(POST):提交反馈后记录进入客服队列(SC-C-23 主流程;§4.61.7 G-06)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 边界流:内容适龄过滤 / 敏感信息脱敏 / 附件大小限制
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("边界流-1:应提供内容适龄过滤位点(含年龄段位点)")
+    void boundary_ageAppropriatenessFilterExists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.service.UserFeedbackService");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackService 不存在,无法验证内容适龄过滤位点(SC-C-23 边界)。");
+
+        boolean hasChildAge = false;
+        boolean hasAgeFilter = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().contains("ageGroup") || f.getName().contains("Age")) {
+                hasChildAge = true;
+            }
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName();
+            if (name.toLowerCase().contains("age") || name.contains("filter")
+                    || name.contains("sanitize") || name.contains("review")) {
+                hasAgeFilter = true;
+                hasChildAge = true;
+            }
+        }
+        assertTrue(hasChildAge,
+                "[child.feedback.submit] UserFeedbackService 应提供适龄过滤位点(如方法/字段含 age/filter),"
+                        + "异常/边界:内容适龄过滤(SC-C-23)。实现未落地。");
+        assertTrue(hasAgeFilter,
+                "[child.feedback.submit] UserFeedbackService 应提供内容安全过滤(filter/sanitize/review),"
+                        + "对反馈内容做适龄/安全把关(SC-C-23 边界)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-2:应提供敏感信息脱敏位点")
+    void boundary_sensitiveInfoDesensitizationExists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.service.UserFeedbackService");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackService 不存在,无法验证敏感信息脱敏位点(SC-C-23 边界)。");
+        boolean hasMask = false;
+        boolean hasDesensitize = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().toLowerCase().contains("mask")
+                    || f.getName().toLowerCase().contains("sensitive")
+                    || f.getName().toLowerCase().contains("desensit")) {
+                hasMask = true;
+            }
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("mask") || name.contains("desensit") || name.contains("pii")) {
+                hasDesensitize = true;
+                hasMask = true;
+            }
+        }
+        assertTrue(hasMask,
+                "[child.feedback.submit] UserFeedbackService 应提供敏感信息脱敏位点(mask/desensitize/"
+                        + "sensitive/pii),异常/边界:敏感信息脱敏(SC-C-23)。实现未落地。");
+        assertTrue(hasDesensitize,
+                "[child.feedback.submit] UserFeedbackService 应提供脱敏处理(mask/desensitize/pii),"
+                        + "反馈内容中的手机号/身份证等敏感信息入库前脱敏(SC-C-23 边界)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-3:应提供附件大小限制位点")
+    void boundary_attachmentSizeLimitExists() {
+        Class<?> clazz = loadClass("com.etotem.cfc.service.UserFeedbackService");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackService 不存在,无法验证附件大小限制位点(SC-C-23 边界)。");
+        boolean hasLimit = false;
+        boolean hasLimitCheck = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            if (f.getName().toLowerCase().contains("size")
+                    || f.getName().contains("MAX") || f.getName().contains("limit")) {
+                hasLimit = true;
+            }
+        }
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("attachment") || name.contains("size")
+                    || name.contains("limit") || name.contains("check")) {
+                hasLimitCheck = true;
+                hasLimit = true;
+            }
+        }
+        assertTrue(hasLimit,
+                "[child.feedback.submit] UserFeedbackService 应提供附件大小限制位点(size/MAX/limit),"
+                        + "异常/边界:附件大小限制(SC-C-23)。实现未落地。");
+        assertTrue(hasLimitCheck,
+                "[child.feedback.submit] UserFeedbackService 应提供附件校验(attachment/size/limit/check),"
+                        + "超出上限的附件应被拒绝(SC-C-23 边界)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("边界流-4:user_feedback 表应落地 schema.sql 并承载客服队列状态")
+    void schemaSql_hasUserFeedbackTable() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(FEEDBACK_TABLE),
+                "[child.feedback.submit] schema.sql 缺少 " + FEEDBACK_TABLE
+                        + " 建表:通用意见反馈落库位缺失(§4.61.7 G-06)。实现未落地。");
+        assertTrue(schema.contains("status"),
+                "[child.feedback.submit] " + FEEDBACK_TABLE
+                        + " 应含 status 列承载客服队列状态(提交反馈 → 进入客服队列,SC-C-23 主流程)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 权限流:孩子(不能登录用户)由家庭成员代管提交,按 memberId 归属自身
+    // ------------------------------------------------------------------
+
+    @Test
+    @DisplayName("权限流:提交入口应接受成员级 memberId(孩子不能登录,由家庭成员代管)")
+    void permissionFlow_memberScopedSubmit() {
+        Class<?> clazz = loadClass("com.etotem.cfc.controller.UserFeedbackController");
+        assertNotNull(clazz,
+                "[child.feedback.submit] UserFeedbackController 不存在,无法验证成员级提交归属(SC-C-23 前置)。");
+        boolean hasMemberSubmit = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            for (Parameter p : m.getParameters()) {
+                if (p.getName().equals("memberId")) {
+                    hasMemberSubmit = true;
+                    break;
+                }
+            }
+        }
+        assertTrue(hasMemberSubmit,
+                "[child.feedback.submit] UserFeedbackController 提交入口应接受 memberId 参数:"
+                        + "孩子(不能登录用户)无 userId,意见反馈须以家庭成员 memberId 维度提交,"
+                        + "且应校验 familyId 归属(SC-C-23 前置条件:已登录)。实现未落地。");
+    }
+
+    // ------------------------------------------------------------------
+    // 反射辅助
+    // ------------------------------------------------------------------
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 读取类级 @RequestMapping 的 path 值 */
+    private String resolveRequestMapping(Class<?> clazz) {
+        if (clazz.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)) {
+            org.springframework.web.bind.annotation.RequestMapping rm =
+                    clazz.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
+            String[] value = rm.value();
+            if (value != null && value.length > 0) {
+                return value[0];
+            }
+        }
+        return null;
+    }
+
+    /** 读取方法级映射 path(@PostMapping/@GetMapping/@RequestMapping) */
+    private Map<String, String> resolveMappingPath(Method m) {
+        Map<String, String> result = new HashMap<>();
+        org.springframework.web.bind.annotation.PostMapping pm =
+                m.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+        if (pm != null) {
+            String[] value = pm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "POST");
+            return result;
+        }
+        org.springframework.web.bind.annotation.GetMapping gm =
+                m.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class);
+        if (gm != null) {
+            String[] value = gm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "GET");
+            return result;
+        }
+        org.springframework.web.bind.annotation.RequestMapping rm =
+                m.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
+        if (rm != null) {
+            String[] value = rm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "REQUEST");
+            return result;
+        }
+        return result;
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}

+ 378 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/ChildShopPointsExchangeContractTest.java

@@ -0,0 +1,378 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.config.ConfigService;
+import com.etotem.cfc.entity.Coupon;
+import com.etotem.cfc.entity.FamilyCouponGrantLog;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.PointsExchangeProduct;
+import com.etotem.cfc.entity.PointsExchangeRecord;
+import com.etotem.cfc.mapper.CouponMapper;
+import com.etotem.cfc.mapper.FamilyCouponGrantLogMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.PointsExchangeProductMapper;
+import com.etotem.cfc.mapper.PointsExchangeRecordMapper;
+import com.etotem.cfc.service.CfSpendDispatchService;
+import com.etotem.cfc.service.CouponService;
+import com.etotem.cfc.service.PointsExchangeService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.web.bind.annotation.PostMapping;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+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.shop.pointsExchange})。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-C-22(商城浏览/积分兑换孩子端消费)</li>
+ *   <li>用例:{@code tests/test-cases/child.md} TC-C-22(选商品 → 积分兑换;边界:积分不足拦截;重复兑换幂等)/
+ *       TC-C-22-E(① 积分余额不足兑换 → 余额不足拦截不扣负;② 并发兑换同一商品 → 并发仅 1 次成功;
+ *       ③ 重复兑换同一商品 → 重复兑换幂等仅 1 条)/ TC-C-22-P(孩子端不越权操作家庭数据)</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.8 积分兑换段 ——
+ *       {@code PointsExchangeController} {@code /api/points/exchange}(product/list、product/detail、
+ *       submit、records、stats);API 表注「提交兑换(<b>积分不足/重复由 Service 拦截</b>)」</li>
+ * </ul>
+ *
+ * <p><b>当前实现核查(2026-11-13 cfclub 基线 3cdb534e7)</b>:
+ * 五个端点与 {@code PointsExchangeService} 已落地,商品/库存/日限次/最低兑换额拦截存在,
+ * 积分不足依赖 cf_balance 账本 spend 抛异常 + {@code @Transactional} 回滚(行为上可拦截)。
+ * 但体检出三处<b>契约缺口(本类 RED 断言)</b>:</p>
+ * <ol>
+ *   <li><b>重复兑换幂等未落地</b>(TC-C-22/E-③):每次 submit 生成随机新 redeemCode、新记录、
+ *       F-5 幂等 refId = 新记录 ID(无稳定业务键),同一请求重试将双记录、双扣减;</li>
+ *   <li><b>并发唯一约束未落地</b>(TC-C-22/E-②):{@code points_exchange_records} 建表无唯一键,
+ *       并发双点无 DB 级防线;</li>
+ *   <li><b>越权成员拦截未落地</b>(TC-C-22-P):{@code exchangeProduct} 接受任意 {@code memberId},
+ *       无「memberId 归属当前用户家庭」校验,可代扣其他家庭积分。</li>
+ * </ol>
+ *
+ * <p><b>测试方式</b>:纯 Mockito + 反射,无 Spring / 无 DB / 无外部服务(与同批 RED 契约测试
+ * {@code FamilyJoinValidationRulesContractTest} / {@code TeamProfitHookContractTest} 同风格)。
+ * RED 断言集体现在「重复兑换仅 1 条/仅扣 1 次」「幂等唯一键缺位」「越权成员不拦截」;
+ * 其余为已落地行为锚点(防误伤、记录契约基线)。GREEN 阶段补齐缺口后本类自动转绿。</p>
+ */
+class ChildShopPointsExchangeContractTest {
+
+    private static final Long USER_ID = 200L;
+    private static final Long MEMBER_ID = 300L;
+    private static final Long PRODUCT_ID = 1L;
+
+    // 结构契约(RED):幂等请求标识字段 / 唯一键载体
+    private static final String[] IDEMPOTENCY_FIELD_CANDIDATES = {
+            "requestNo", "idempotencyKey", "exchangeToken", "bizNo"
+    };
+    private static final String IDEMPOTENCY_UNIQUE_MARKER = "exchange_request_no";
+
+    private static final Set<Class<?>> INITIALIZED = ConcurrentHashMap.newKeySet();
+
+    @Mock
+    private PointsExchangeProductMapper pointsExchangeProductMapper;
+    @Mock
+    private PointsExchangeRecordMapper pointsExchangeRecordMapper;
+    @Mock
+    private CfSpendDispatchService cfSpendDispatchService;
+    @Mock
+    private ConfigService configService;
+    @Mock
+    private FamilyMemberMapper familyMemberMapper;
+    @Mock
+    private CouponMapper couponMapper;
+    @Mock
+    private CouponService couponService;
+    @Mock
+    private FamilyCouponGrantLogMapper familyCouponGrantLogMapper;
+
+    @InjectMocks
+    private PointsExchangeService pointsExchangeService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // 纯 Mockito 下 LambdaQueryWrapper 解析列名需要 MP TableInfo 预注册(同 EnergyContractTestBase 约定)
+        initTable(PointsExchangeRecord.class);
+        initTable(PointsExchangeProduct.class);
+        initTable(FamilyMember.class);
+        initTable(Coupon.class);
+        initTable(FamilyCouponGrantLog.class);
+        // 兑换开关均放宽:日限 5 次、最低兑换额 5(小于用例单价),selectCount 今日 0 条
+        when(configService.getIntConfig(anyString(), anyInt())).thenReturn(5);
+        when(pointsExchangeRecordMapper.selectCount(any())).thenReturn(0L);
+    }
+
+    // ======================================================================
+    // 边界流(RED):重复兑换幂等 —— TC-C-22/E-③「重复兑换同一商品 → 幂等仅 1 条」
+    // ======================================================================
+
+    @Test
+    @DisplayName("边界流-RED-1:重复提交同一兑换,仅落 1 条兑换记录(当前每次 submit 均新建记录 → 红)")
+    void 重复兑换同一商品_仅一条兑换记录() {
+        stubProduct(enabledProduct(10, 100));
+
+        pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 1);
+        pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 1);
+
+        verify(pointsExchangeRecordMapper, times(1)).insert(any(PointsExchangeRecord.class));
+    }
+
+    @Test
+    @DisplayName("边界流-RED-2:重复提交同一兑换,仅扣减 1 次积分(当前重复扣减 → 红)")
+    void 重复兑换同一商品_仅扣减一次() {
+        stubProduct(enabledProduct(10, 100));
+
+        pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 1);
+        pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 1);
+
+        verify(cfSpendDispatchService, times(1))
+                .spendForMember(any(), anyInt(), any(), any(), any());
+    }
+
+    // ======================================================================
+    // 边界流(RED):并发/去重唯一约束 —— TC-C-22/E-②「并发兑换同一商品 → 并发仅 1 次成功」
+    // ======================================================================
+
+    @Test
+    @DisplayName("边界流-RED-3:points_exchange_records 应含端到端幂等唯一键(当前 schema 无唯一键 → 红)")
+    void schema_兑换记录表含幂等唯一键() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("points_exchange_records"),
+                "[child.shop.pointsExchange] schema.sql 缺少 points_exchange_records 建表(§4.8 积分兑换记录)。");
+        assertTrue(schema.contains(IDEMPOTENCY_UNIQUE_MARKER),
+                "[child.shop.pointsExchange] points_exchange_records 应含 " + IDEMPOTENCY_UNIQUE_MARKER
+                        + " 唯一键:并发双点/重复提交必须有 DB 级防线,否则并发仅 1 次成功(TC-C-22/E-②)"
+                        + "与重复兑换幂等仅 1 条(TC-C-22/E-③)无从保证。当前仅 id/普通索引,无唯一约束。");
+    }
+
+    @Test
+    @DisplayName("边界流-RED-4:PointsExchangeRecord 实体应具备幂等请求标识字段(当前无 → 红)")
+    void 兑换记录实体含幂等字段() {
+        Class<?> clazz = loadClass("com.etotem.cfc.entity.PointsExchangeRecord");
+        assertNotNull(clazz, "[child.shop.pointsExchange] PointsExchangeRecord 实体不存在。");
+
+        Set<String> fieldNames = new HashSet<>();
+        for (Field f : clazz.getDeclaredFields()) {
+            fieldNames.add(f.getName());
+        }
+        boolean hasIdempotencyField = false;
+        StringBuilder present = new StringBuilder();
+        for (String candidate : IDEMPOTENCY_FIELD_CANDIDATES) {
+            if (fieldNames.contains(candidate)) {
+                hasIdempotencyField = true;
+                present.append(candidate).append(" ");
+            }
+        }
+        assertTrue(hasIdempotencyField,
+                "[child.shop.pointsExchange] 兑换记录实体应具备幂等请求标识字段(requestNo/idempotencyKey/"
+                        + "exchangeToken/bizNo 之一,当前仅有:" + String.join(",", fieldNames)
+                        + "):重复兑换幂等(TC-C-22)与并发仅 1 次成功(TC-C-22/E-②)需要稳定业务键,"
+                        + "当前 redeemCode 为每次随机生成、refId=记录 ID,均非客户端可回传的幂等键。");
+    }
+
+    // ======================================================================
+    // 权限流(RED):越权成员拦截 —— TC-C-22-P「不越权操作家庭数据」
+    // ======================================================================
+
+    @Test
+    @DisplayName("权限流-RED-1:兑换非本人家庭成员(memberId 越权)应被拦截(当前接受任意 memberId → 红)")
+    void 越权兑换他人家庭成员_被拦截() {
+        stubProduct(enabledProduct(10, 100));
+        // 用户 200 本属家庭 100;攻击者指定 memberId=9001 归属家庭 200(越权目标,非本人家庭)
+        FamilyMember evil = new FamilyMember();
+        evil.setId(9001L);
+        evil.setFamilyId(200L);
+        when(familyMemberMapper.selectById(9001L)).thenReturn(evil);
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> pointsExchangeService.exchangeProduct(USER_ID, 9001L, PRODUCT_ID, 1));
+        assertNotNull(ex);
+        verify(pointsExchangeRecordMapper, never()).insert(any(PointsExchangeRecord.class));
+        verify(cfSpendDispatchService, never())
+                .spendForMember(any(), anyInt(), any(), any(), any());
+    }
+
+    // ======================================================================
+    // 正常流(锚点):五端点 POST 契约 —— §4.8 积分兑换段
+    // ======================================================================
+
+    @Test
+    @DisplayName("正常流锚-1:/api/points/exchange 五端点 POST 契约存在")
+    void 五端点POST契约存在() {
+        Class<?> ctrl = loadClass("com.etotem.cfc.controller.PointsExchangeController");
+        assertNotNull(ctrl, "[child.shop.pointsExchange] PointsExchangeController 不存在(§4.8)。");
+
+        Set<String> methodPaths = new HashSet<>();
+        for (Method m : ctrl.getDeclaredMethods()) {
+            PostMapping pm = m.getAnnotation(PostMapping.class);
+            if (pm != null) {
+                Collections.addAll(methodPaths, pm.value());
+            }
+        }
+        for (String expected : new String[]{"/product/list", "/product/detail", "/submit", "/records", "/stats"}) {
+            assertTrue(methodPaths.contains(expected),
+                    "[child.shop.pointsExchange] PointsExchangeController 缺少 POST 端点 "
+                            + expected + "(§4.8:product/list、product/detail、submit、records、stats)。");
+        }
+    }
+
+    // ======================================================================
+    // 正常流(锚点):兑换成功落 completed 记录 + 生成兑换码 + 按单价×数量计分
+    // ======================================================================
+
+    @Test
+    @DisplayName("正常流锚-2:兑换成功 → completed 记录 + 兑换码 + pointsCost=单价×数量(目标成员维度扣减)")
+    void 兑换成功落记录并扣减() {
+        stubProduct(enabledProduct(10, 100));
+
+        PointsExchangeRecord record =
+                pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 2);
+
+        assertNotNull(record);
+        assertEquals(USER_ID, record.getUserId());
+        assertEquals(MEMBER_ID, record.getFamilyMemberId());
+        assertEquals(PRODUCT_ID, record.getProductId());
+        assertEquals("completed", record.getStatus());
+        assertEquals(Integer.valueOf(200), record.getPointsCost());
+        assertEquals(Integer.valueOf(2), record.getQuantity());
+        assertNotNull(record.getRedeemCode());
+        assertFalse(record.getRedeemCode().isEmpty());
+
+        verify(pointsExchangeRecordMapper).insert(any(PointsExchangeRecord.class));
+        // F-5:扣减统一账本(目标成员维度,refType=exchange_product,refId=兑换记录 ID 幂等键)
+        verify(cfSpendDispatchService).spendForMember(
+                eq(MEMBER_ID), eq(200), eq("exchange_product"), any(), any());
+    }
+
+    // ======================================================================
+    // 正常流(锚点):记录查询 / 今日统计
+    // ======================================================================
+
+    @Test
+    @DisplayName("正常流锚-3:兑换记录按 userId 隔离查询")
+    void 兑换记录按用户查询() {
+        when(pointsExchangeRecordMapper.selectList(any(LambdaQueryWrapper.class)))
+                .thenReturn(Collections.singletonList(new PointsExchangeRecord()));
+
+        List<PointsExchangeRecord> records = pointsExchangeService.getExchangeRecords(USER_ID);
+
+        assertNotNull(records);
+        assertEquals(1, records.size());
+        verify(pointsExchangeRecordMapper).selectList(any(LambdaQueryWrapper.class));
+    }
+
+    @Test
+    @DisplayName("正常流锚-4:兑换统计返回今日条数")
+    void 兑换统计返回今日条数() {
+        when(pointsExchangeRecordMapper.selectCount(any())).thenReturn(3L);
+        assertEquals(3L, pointsExchangeService.getExchangeStats(USER_ID));
+    }
+
+    // ======================================================================
+    // 边界流(锚点):库存不足 / 积分不足(余额不足)拦截 —— TC-C-22/E-①
+    // ======================================================================
+
+    @Test
+    @DisplayName("边界锚-1:库存不足拦截,不落记录、不扣减")
+    void 库存不足拦截() {
+        stubProduct(enabledProduct(1, 100));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 2));
+        assertTrue(ex.getMessage() != null && ex.getMessage().contains("库存不足"),
+                "[child.shop.pointsExchange] 库存不足应抛业务异常:「库存不足」。实际:" + ex.getMessage());
+        verify(pointsExchangeRecordMapper, never()).insert(any(PointsExchangeRecord.class));
+        verify(cfSpendDispatchService, never()).spendForMember(any(), anyInt(), any(), any(), any());
+    }
+
+    @Test
+    @DisplayName("边界锚-2:积分(余额)不足 → 兑换异常结束且不扣减库存(TC-C-22-E-① 余额不足拦截不扣负)")
+    void 积分不足拦截() {
+        stubProduct(enabledProduct(10, 100));
+        when(cfSpendDispatchService.spendForMember(any(), anyInt(), any(), any(), any()))
+                .thenThrow(new IllegalStateException("CF 可用余额不足"));
+
+        RuntimeException ex = assertThrows(RuntimeException.class,
+                () -> pointsExchangeService.exchangeProduct(USER_ID, MEMBER_ID, PRODUCT_ID, 1));
+        assertNotNull(ex.getMessage());
+        // 扣减失败发生在库存扣减之前 → 兑换未完成态不得减库存
+        verify(pointsExchangeProductMapper, never()).updateById(any(PointsExchangeProduct.class));
+        // 注:兑换记录由 @Transactional 随异常回滚(DB 层无残留),纯 Mockito 下不验证「未 insert」。
+    }
+
+    // ======================================================================
+    // 辅助
+    // ======================================================================
+
+    private PointsExchangeProduct enabledProduct(int stock, int pointsPrice) {
+        PointsExchangeProduct p = new PointsExchangeProduct();
+        p.setId(PRODUCT_ID);
+        p.setName("契约测试玩具");
+        p.setStatus("enabled");
+        p.setStock(stock);
+        p.setPointsPrice(pointsPrice);
+        return p;
+    }
+
+    private void stubProduct(PointsExchangeProduct product) {
+        when(pointsExchangeProductMapper.selectById(PRODUCT_ID)).thenReturn(product);
+    }
+
+    /** 为实体类预注册 MyBatis-Plus TableInfo(幂等,每类仅一次,仿 EnergyContractTestBase) */
+    private static void initTable(Class<?> clazz) {
+        if (INITIALIZED.add(clazz)) {
+            TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), clazz);
+        }
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}

+ 405 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierActivityAttendContractTest.java

@@ -0,0 +1,405 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.ActivityOrder;
+import com.etotem.cfc.entity.ActivityRegistration;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ActivityOrderMapper;
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
+import com.etotem.cfc.service.ActivityRegistrationService;
+import com.etotem.cfc.service.PaymentService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Scanner;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 契约级单测:服务商营销活动报名(能力键 {@code supplier.activity.attend})。
+ *
+ * <p><b>契约溯源</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-S-07「营销活动报名」——
+ *       <b>主流程</b>:报名活动 → 商品打标;<b>边界</b>:活动失效不可报;模块:活动管理。</li>
+ *   <li>用例:{@code tests/test-cases/vendor.md} TC-S-07(正常流:报名活动→商品打标,报名后商品出现活动标)/
+ *       TC-S-07-E(异常流:① 活动已失效仍报名 → 失效活动报名拒;② 重复报名幂等;③ 报名参数非法 → 400)/
+ *       TC-S-07-P(权限流:跨商户数据隔离;核销码校验防冒用)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.13 管理员接口 ——
+ *       {@code AdminActivityRegistrationController /api/admin/activity}(报名管理)+ 
+ *       {@code AdminActivityCheckinController /api/admin/activity-checkins}(核销);
+ *       §4.55.1 —— {@code POST /api/activity/register}(活动报名)。</li>
+ * </ul>
+ *
+ * <p><b>基线声明(2026-11-13 cfclub)</b>:SC-S-07 的「端点载体」已落地——服务商契约核对 spec
+ * ({@code docs/superpowers/specs/2026-11-07-service-provider-contract.md} §1.3,建议键
+ * {@code supplier.activity.signup})结论为「端点齐全:ActivityController 13 端点 +
+ * AdminActivityController 8 端点 + ActivityOrderController 5 端点」。本能力键
+ * {@code supplier.activity.attend} 为<b>契约新增行为</b>(超出端点核对层面),断言 SC-S-07
+ * 的行为契约,当前实现存在三处<b>契约缺口(本类 RED 断言)</b>:</p>
+ * <ol>
+ *   <li><b>商品打标缺失</b>(TC-S-07 主流程):报名成功后不产生任何「活动↔商品」关联——
+ *       schema.sql 无 {@code activity_products} 关联表(0 命中),{@code ActivityRegistrationService}
+ *       无打标位点(方法/字段 0 命中 tag/product),服务商报名活动后其商品不会出现活动标;</li>
+ *   <li><b>活动失效(运行期)不可报缺失</b>(TC-S-07-E-①):{@code ActivityRegistrationService.register}
+ *       仅校验 {@code status=published} 与报名截止时间,<b>不校验活动运行期窗口</b>——
+ *       published 活动 {@code end_time} 已过(活动已失效)仍可报名成功;</li>
+ *   <li><b>服务商维度缺失</b>(TC-S-07-P):{@code activity_registrations} / {@code ActivityRegistration}
+ *       无 {@code vendorId} 等服务商维度字段,报名链路无跨商户归属校验位点,无法支撑跨商户数据隔离。</li>
+ * </ol>
+ *
+ * <p><b>测试策略</b>:与同批 RED 契约测试({@code TeamProfitHookContractTest} /
+ * {@code TeamStarMultiplierContractTest} / {@code FamilyJoinValidationRulesContractTest},纯 Mockito/
+ * 反射、无 Spring/DB/外部服务)同风格。绿锚点(已落地载体,防误伤)与红断言(契约缺口)并存:
+ * RED 断言在实现补齐前失败(红),GREEN 阶段实现使绿。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:① 新增 {@code activity_products}(活动-商品关联表)并在报名成功链路写入
+ * 关联(商品打标载体);② {@code ActivityRegistrationService.register} 增加运行期校验:活动
+ * {@code end_time} 已过(运行期失效)拒绝报名;③ 报名/打标链路增加服务商 {@code vendorId} 维度与
+ * 跨商户归属校验。完成后本类应全绿。</p>
+ *
+ * <p><b>RED 预期</b>:正常流-红 2 例(商品打标载体/位点缺失)、边界流-红 1 例(运行期失效放行)、
+ * 权限流-红 2 例(服务商维度缺失)在实现落地前失败(红);绿锚 4 例锚定已落地载体
+ * (报名端点/报名管理/核销/状态维度失效拦截)。</p>
+ */
+class SupplierActivityAttendContractTest {
+
+    private static final String REGISTRATION_SERVICE = "com.etotem.cfc.service.ActivityRegistrationService";
+    private static final String ACTIVITY_REGISTRATION_ENTITY = "com.etotem.cfc.entity.ActivityRegistration";
+
+    /** 契约要求的「活动-商品」关联表(报名 → 商品打标载体,SC-S-07 主流程) */
+    private static final String ACTIVITY_PRODUCT_TABLE = "activity_products";
+
+    private static final Long ACTIVITY_ID = 1L;
+    private static final Long USER_ID = 200L;
+    private static final Long MEMBER_ID = 300L;
+
+    private static final Set<Class<?>> INITIALIZED = ConcurrentHashMap.newKeySet();
+
+    @Mock
+    private ActivityRegistrationMapper registrationMapper;
+    @Mock
+    private ActivityMapper activityMapper;
+    @Mock
+    private ActivityOrderMapper orderMapper;
+    @Mock
+    private PaymentService paymentService;
+
+    @InjectMocks
+    private ActivityRegistrationService registrationService;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+        // 纯 Mockito 下 LambdaQueryWrapper 解析列名需要 MP TableInfo 预注册(同 ChildShopPointsExchangeContractTest 约定)
+        initTable(Activity.class);
+        initTable(ActivityRegistration.class);
+        initTable(ActivityOrder.class);
+        // 名额充足:occupyeQuota 的原子自增恒成功(活动失效校验若存在应发生在名额占用之前)
+        when(activityMapper.update(isNull(), any(Wrapper.class))).thenReturn(1);
+    }
+
+    // ======================================================================
+    // 正常流(绿锚):报名端点已落地 —— §4.55.1 POST /api/activity/register
+    // ======================================================================
+
+    @Test
+    @DisplayName("正常流锚-1:POST /api/activity/register 报名端点已落地(ActivityController)")
+    void activityController_registerEndpointExists() {
+        Class<?> ctrl = loadClass("com.etotem.cfc.controller.ActivityController");
+        assertNotNull(ctrl, "[supplier.activity.attend] ActivityController 不存在(§4.55.1)。");
+        assertEquals("/api/activity", resolveRequestMapping(ctrl),
+                "[supplier.activity.attend] ActivityController 类映射应为 /api/activity(§4.55.1)。");
+        boolean hasRegister = false;
+        for (Method m : ctrl.getDeclaredMethods()) {
+            Map<String, String> mapping = resolveMappingPath(m);
+            if ("/register".equals(mapping.get("path")) && "POST".equals(mapping.get("httpMethod"))) {
+                hasRegister = true;
+                break;
+            }
+        }
+        assertTrue(hasRegister,
+                "[supplier.activity.attend] ActivityController 应映射 POST /api/activity/register"
+                        + "(活动报名,§4.55.1)。端点载体已落地,本例为绿锚。");
+    }
+
+    // ======================================================================
+    // 正常流(红):报名活动 → 商品打标 —— 打标载体/位点缺失
+    // ======================================================================
+
+    @Test
+    @DisplayName("正常流红-1:schema.sql 缺少 activity_products 关联表(报名→商品打标载体 → 红)")
+    void schemaSql_missingActivityProductLinkage() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains(ACTIVITY_PRODUCT_TABLE),
+                "[supplier.activity.attend] schema.sql 缺少 " + ACTIVITY_PRODUCT_TABLE
+                        + " 关联表:SC-S-07 主流程「报名活动 → 商品打标」需要活动↔商品关联载体,"
+                        + "否则服务商报名后其商品不会出现活动标(TC-S-07)。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流红-2:报名服务缺少商品打标位点(方法/字段无 tag/product → 红)")
+    void registrationService_missingProductTaggingSite() {
+        Class<?> clazz = loadClass(REGISTRATION_SERVICE);
+        assertNotNull(clazz, "[supplier.activity.attend] ActivityRegistrationService 不存在。");
+        boolean hasTaggingSite = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("tag") || name.contains("product") || name.contains("attend")) {
+                hasTaggingSite = true;
+                break;
+            }
+        }
+        for (Field f : clazz.getDeclaredFields()) {
+            String name = f.getName().toLowerCase();
+            if (name.contains("tag") || name.contains("product")) {
+                hasTaggingSite = true;
+                break;
+            }
+        }
+        assertTrue(hasTaggingSite,
+                "[supplier.activity.attend] ActivityRegistrationService 应提供商品打标位点"
+                        + "(方法/字段含 tag/product/attend):报名成功后应将活动标写入服务商商品"
+                        + "(活动↔商品关联),支撑「报名活动 → 商品打标」(SC-S-07 主流程 / TC-S-07)。实现未落地。");
+    }
+
+    // ======================================================================
+    // 边界流(红):活动失效不可报 —— 运行期(end_time)失效校验缺失
+    // ======================================================================
+
+    @Test
+    @DisplayName("边界流红-1:published 活动 end_time 已过(活动已失效)仍报名成功 → 契约要求拒绝(红)")
+    void expiredActivity_registerShouldBeRejected() {
+        Activity expired = baseActivity();
+        // 运行期失效:活动已结束(end_time 早于当下),但状态仍 published、无报名截止限制
+        expired.setEndTime(new Date(System.currentTimeMillis() - 86_400_000L));
+        when(activityMapper.selectById(ACTIVITY_ID)).thenReturn(expired);
+
+        Result<Map<String, Object>> result = registrationService.register(ACTIVITY_ID, USER_ID, MEMBER_ID);
+
+        assertTrue(result.getCode() != 200 && result.getMessage() != null && result.getMessage().contains("失效"),
+                "[supplier.activity.attend] published 活动 end_time 已过(活动已失效)仍报名成功:"
+                        + "code=" + result.getCode() + ", msg=" + result.getMessage()
+                        + "。契约边界「活动失效不可报」(SC-S-07 / TC-S-07-E-①)"
+                        + "要求运行期失效活动拒绝报名(当前仅校验 status 与报名截止,缺 end_time 运行期校验)。");
+        verify(registrationMapper, never()).insert(any(ActivityRegistration.class));
+    }
+
+    @Test
+    @DisplayName("边界锚-1:status=ended(状态维度失效)→ register 拒绝「活动未发布」(已拦截,绿锚)")
+    void statusEndedActivity_registerRejected() {
+        Activity ended = baseActivity();
+        ended.setStatus("ended");
+        when(activityMapper.selectById(ACTIVITY_ID)).thenReturn(ended);
+
+        Result<Map<String, Object>> result = registrationService.register(ACTIVITY_ID, USER_ID, MEMBER_ID);
+
+        assertEquals(500, result.getCode());
+        assertTrue(result.getMessage() != null && result.getMessage().contains("未发布"),
+                "[supplier.activity.attend] 状态维度失效(status=ended)应已拦截,实际 msg=" + result.getMessage());
+        verify(registrationMapper, never()).insert(any(ActivityRegistration.class));
+    }
+
+    // ======================================================================
+    // 权限流(红):TC-S-07-P 跨商户数据隔离 —— 服务商维度缺失
+    // ======================================================================
+
+    @Test
+    @DisplayName("权限流红-1:报名记录应承载服务商 vendorId 维度(当前缺失 → 红)")
+    void activityRegistration_missingVendorDimension() {
+        Class<?> clazz = loadClass(ACTIVITY_REGISTRATION_ENTITY);
+        assertNotNull(clazz, "[supplier.activity.attend] ActivityRegistration 实体不存在。");
+        boolean hasVendor = false;
+        for (Field f : clazz.getDeclaredFields()) {
+            String name = f.getName();
+            if ("vendorId".equals(name) || "supplierId".equals(name)) {
+                hasVendor = true;
+                break;
+            }
+        }
+        assertTrue(hasVendor,
+                "[supplier.activity.attend] ActivityRegistration 应含 vendorId/supplierId 字段:"
+                        + "服务商报名营销活动须以服务商维度落库,跨商户数据隔离(TC-S-07-P)"
+                        + "才有判定依据。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("权限流红-2:报名链路缺少跨商户(服务商归属)校验位点(当前无 → 红)")
+    void registrationService_missingVendorIsolationCheck() {
+        Class<?> clazz = loadClass(REGISTRATION_SERVICE);
+        assertNotNull(clazz, "[supplier.activity.attend] ActivityRegistrationService 不存在。");
+        boolean hasIsolationSite = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            String name = m.getName().toLowerCase();
+            if (name.contains("vendor") || name.contains("supplier")
+                    || name.contains("merchant") || name.contains("shop")) {
+                hasIsolationSite = true;
+                break;
+            }
+        }
+        for (Field f : clazz.getDeclaredFields()) {
+            String name = f.getName().toLowerCase();
+            if (name.contains("vendor") || name.contains("supplier")
+                    || name.contains("merchant") || name.contains("shop")) {
+                hasIsolationSite = true;
+                break;
+            }
+        }
+        assertTrue(hasIsolationSite,
+                "[supplier.activity.attend] ActivityRegistrationService 应提供服务商归属校验位点"
+                        + "(vendor/supplier/merchant):服务商报名/打标须限定本商户商品,"
+                        + "跨商户数据隔离(TC-S-07-P)必须由服务端校验承载,不能依赖前端约束。实现未落地。");
+    }
+
+    // ======================================================================
+    // 权限流(绿锚):管理端报名管理 + 核销已落地 —— §4.13
+    // ======================================================================
+
+    @Test
+    @DisplayName("权限流锚-1:AdminActivityRegistrationController 报名管理三点 POST 已落地(§4.13)")
+    void adminActivityRegistration_adminManageExists() {
+        Class<?> ctrl = loadClass("com.etotem.cfc.controller.admin.AdminActivityRegistrationController");
+        assertNotNull(ctrl, "[supplier.activity.attend] AdminActivityRegistrationController 不存在(§4.13 报名管理)。");
+        assertEquals("/api/admin/activity", resolveRequestMapping(ctrl),
+                "[supplier.activity.attend] AdminActivityRegistrationController 类映射应为 /api/admin/activity(§4.13)。");
+        checkPostEndpoints(ctrl, "/registration/list", "/registration/approve", "/registration/reject");
+    }
+
+    @Test
+    @DisplayName("权限流锚-2:AdminActivityCheckinController 核销/补录/审核已落地(§4.13)")
+    void adminActivityCheckin_checkinManageExists() {
+        Class<?> ctrl = loadClass("com.etotem.cfc.controller.admin.AdminActivityCheckinController");
+        assertNotNull(ctrl, "[supplier.activity.attend] AdminActivityCheckinController 不存在(§4.13 核销)。");
+        assertEquals("/api/admin/activity-checkins", resolveRequestMapping(ctrl),
+                "[supplier.activity.attend] AdminActivityCheckinController 类映射应为 /api/admin/activity-checkins(§4.13)。");
+        checkPostEndpoints(ctrl, "/list", "/add", "/review");
+    }
+
+    // ======================================================================
+    // 辅助
+    // ======================================================================
+
+    /** 免审、无报名截止、免费活动的契约基线:聚焦「活动失效(运行期)校验」这一缺口 */
+    private Activity baseActivity() {
+        Activity a = new Activity();
+        a.setId(ACTIVITY_ID);
+        a.setTitle("契约测试营销活动");
+        a.setStatus("published");
+        a.setRequireRegistration(1);
+        a.setRequireReview(0);
+        a.setRegistrationDeadline(null);
+        return a;
+    }
+
+    private void checkPostEndpoints(Class<?> ctrl, String... expectedPaths) {
+        Set<String> methodPaths = new java.util.HashSet<>();
+        for (Method m : ctrl.getDeclaredMethods()) {
+            Map<String, String> mapping = resolveMappingPath(m);
+            if ("POST".equals(mapping.get("httpMethod")) && mapping.get("path") != null) {
+                methodPaths.add(mapping.get("path"));
+            }
+        }
+        for (String expected : expectedPaths) {
+            assertTrue(methodPaths.contains(expected),
+                    "[supplier.activity.attend] " + ctrl.getSimpleName() + " 缺少 POST 端点 "
+                            + expected + "(§4.13 报名管理/核销)。");
+        }
+    }
+
+    /** 为实体类预注册 MyBatis-Plus TableInfo(幂等,每类仅一次,仿 ChildShopPointsExchangeContractTest) */
+    private static void initTable(Class<?> clazz) {
+        if (INITIALIZED.add(clazz)) {
+            TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), clazz);
+        }
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** 读取类级 @RequestMapping 的 path 值 */
+    private String resolveRequestMapping(Class<?> clazz) {
+        if (clazz.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)) {
+            org.springframework.web.bind.annotation.RequestMapping rm =
+                    clazz.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
+            String[] value = rm.value();
+            if (value != null && value.length > 0) {
+                return value[0];
+            }
+        }
+        return null;
+    }
+
+    /** 读取方法级映射 path + http 方法(@PostMapping/@GetMapping/@RequestMapping) */
+    private Map<String, String> resolveMappingPath(Method m) {
+        Map<String, String> result = new HashMap<>();
+        org.springframework.web.bind.annotation.PostMapping pm =
+                m.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class);
+        if (pm != null) {
+            String[] value = pm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "POST");
+            return result;
+        }
+        org.springframework.web.bind.annotation.GetMapping gm =
+                m.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class);
+        if (gm != null) {
+            String[] value = gm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "GET");
+            return result;
+        }
+        org.springframework.web.bind.annotation.RequestMapping rm =
+                m.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
+        if (rm != null) {
+            String[] value = rm.value();
+            result.put("path", (value != null && value.length > 0) ? value[0] : "");
+            result.put("httpMethod", "REQUEST");
+            return result;
+        }
+        return result;
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}

+ 348 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/SupplierNutritionSupplyContractTest.java

@@ -0,0 +1,348 @@
+package com.etotem.cfc.unit;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.admin.AdminFoodController;
+import com.etotem.cfc.controller.admin.AdminRecipeController;
+import com.etotem.cfc.controller.admin.ReportParseAdminController;
+import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.entity.Recipe;
+import com.etotem.cfc.service.FoodService;
+import com.etotem.cfc.service.RecipeService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.util.Scanner;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 契约级单测:商品服务商「营养/食谱数据供给」(能力键 {@code supplier.nutrition.supply},SC-S-10)。
+ *
+ * <p><b>需求/设计锚点</b></p>
+ * <ul>
+ *   <li>需求:{@code tests/user-scenarios/应用场景总览.md} SC-S-10(营养/食谱数据供给)——
+ *       目标:供食谱数据;流程:上架食谱 → 同步;边界:<b>数据合规;格式校验</b>;模块:食谱库。
+ *       关联 SC-S-09(报告解析服务接入/对接解析中台)为本能力的上游供给链路。
+ *       用例:{@code tests/test-cases/vendor.md} TC-S-10 / TC-S-10-E(格式非法 400 / XSS 转义 /
+ *       非本商户覆盖 403)/ TC-S-10-P(跨商户访问 403)/ TC-S-N06(XSS 不执行)。</li>
+ *   <li>设计:{@code docs/superpowers/api/API_REFERENCE.md} §4.11(饮食推荐)/ §4.13(管理员接口);
+ *       契约载体 {@code AdminRecipeController}(/api/admin/recipes)+ {@code AdminFoodController}
+ *       (/api/admin/foods)+ {@code ReportParseAdminController}(/api/admin/report-parse,
+ *       SC-S-09 中台链路 backfill-food 回填食材)。</li>
+ * </ul>
+ *
+ * <p><b>被校验的契约行为</b>(SC-S-10 正常流 + 边界)</p>
+ * <ul>
+ *   <li><b>正常流·供给入口</b>:服务商「上架食谱 → 同步 → 供食谱数据」——需存在供给宿主服务
+ *       {@code RecipeSupplyService}(含 {@code syncFromSupplier} 同步供给入口)与食材/食谱的
+ *       商户归属载体 {@code Recipe.supplierId} / schema 的 {@code supplier_id};</li>
+ *   <li><b>边界流·格式校验</b>:上架格式非法食谱(name 缺失 / ingredients JSON 残缺)与非法食材
+ *       (负营养值)应被拒绝(400),不落库;</li>
+ *   <li><b>边界流·数据合规(XSS)</b>:上架食谱/食材含 {@code <script>} 描述/名称应转义存储,
+ *       不得以原始脚本透传落库(TC-S-N06:内容转义存储/渲染,不执行);</li>
+ *   <li><b>权限流·商户隔离</b>:更新/删除食谱须校验商户归属,无合法商户上下文的写操作应 403
+ *       (TC-S-10-E-3 / TC-S-10-P:非本商户 403);报告解析中台(SC-S-09)回填的食材
+ *       (backfill-food)应经 {@code FoodService} 合规供给通道写入,而非绕过供给直写。</li>
+ * </ul>
+ *
+ * <p><b>实现现状(src/main 核实)</b>:本分支 src/main 中 {@code RecipeSupplyService} 类、
+ * {@code Recipe.supplierId} 字段、schema.sql 的 {@code recipes} 建表(仅 DatabaseInitializer 有
+ * 运行时建表)均缺失;{@code AdminRecipeController.create/update/delete} 与
+ * {@code AdminFoodController.create} 为直通 CRUD,无格式校验、无 XSS 转义、无商户归属校验;
+ * {@code ReportParseAdminController} 无 {@code foodService} 供给写入点。因此本测试以
+ * 「反射结构契约 + Mockito 行为契约」探测实现缺失:能编译通过、能运行、断言失败(红),
+ * 且失败断言原文清晰可贴。</p>
+ *
+ * <p><b>GREEN 升级指引</b>:实现者创建 {@code RecipeSupplyService}(含 syncFromSupplier 入口)、
+ * 给 {@code Recipe} 增加 {@code supplierId} 并在 schema.sql 落 {@code recipes} 建表 +
+ * {@code supplier_id} 列,在 {@code AdminRecipeController}(create/update/delete)与
+ * {@code AdminFoodController.create} 补格式校验(Result.badRequest→400)与 XSS 转义(存储前
+ * 清洗)与商户归属守卫(Result.forbidden→403),并让 {@code ReportParseAdminController}
+ * backfill-food 经 {@code FoodService} 合规写入后,本测试自动转绿。</p>
+ *
+ * <p><b>测试范围</b>:纯 JUnit5 + Mockito / 反射,无 Spring / 无 DB / 无外部服务,
+ * 与同批 RED 契约测试(TeamProfitHookContractTest / TeamStarMultiplierContractTest /
+ * ReportParserProviderConnectContractTest)同风格。</p>
+ */
+class SupplierNutritionSupplyContractTest {
+
+    /** 契约供给宿主(未实现)。 */
+    private static final String SUPPLY_HOST = "com.etotem.cfc.service.RecipeSupplyService";
+
+    private AdminRecipeController recipeController;
+    private AdminFoodController foodController;
+    private RecipeService recipeService;
+    private FoodService foodService;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        recipeController = new AdminRecipeController();
+        injectMocks(recipeController);
+        recipeService = readField(recipeController, "recipeService");
+
+        foodController = new AdminFoodController();
+        injectMocks(foodController);
+        foodService = readField(foodController, "foodService");
+    }
+
+    // ==================== 正常流:供给宿主与归属载体(结构契约) ====================
+
+    @Test
+    @DisplayName("正常流-1:RecipeSupplyService 供给宿主类应存在(服务商上架食谱→同步供给)")
+    void supplyHost_classExists() {
+        assertNotNull(loadClass(SUPPLY_HOST),
+                "[supplier.nutrition.supply] RecipeSupplyService 类不存在:SC-S-10 要求服务商"
+                        + "「上架食谱 → 同步 → 供食谱数据」的供给宿主服务。实现未落地。");
+    }
+
+    @Test
+    @DisplayName("正常流-2:RecipeSupplyService 应提供 syncFromSupplier 同步供给入口")
+    void supplyHost_hasSyncFromSupplierEntry() {
+        Class<?> clazz = loadClass(SUPPLY_HOST);
+        if (clazz == null) {
+            fail("[supplier.nutrition.supply] RecipeSupplyService 类不存在,无法验证同步供给入口"
+                    + "(SC-S-10 正常流:上架食谱 → 同步)。");
+        }
+        boolean hasSync = false;
+        for (Method m : clazz.getDeclaredMethods()) {
+            if (m.getName().equals("syncFromSupplier")) {
+                hasSync = true;
+                break;
+            }
+        }
+        assertTrue(hasSync,
+                "[supplier.nutrition.supply] RecipeSupplyService 应提供 syncFromSupplier(...) "
+                        + "方法作为服务商上架食谱→同步供给的入口(SC-S-10)。");
+    }
+
+    @Test
+    @DisplayName("正常流-3:Recipe 实体应含 supplierId 商户归属字段(商户隔离/供给归属载体)")
+    void recipe_hasSupplierIdOwnershipField() {
+        boolean hasSupplierId = false;
+        for (Field f : Recipe.class.getDeclaredFields()) {
+            if (f.getName().equals("supplierId")) {
+                hasSupplierId = true;
+                break;
+            }
+        }
+        assertTrue(hasSupplierId,
+                "[supplier.nutrition.supply] Recipe 实体应含 supplierId 字段:SC-S-10 商户供给链路的"
+                        + "商户归属/跨商户隔离(TC-S-10-P 非本商户 403)需要归属载体。当前缺失。");
+    }
+
+    @Test
+    @DisplayName("正常流-4:schema.sql 应落地 recipes 建表(当前仅 DatabaseInitializer 运行时建表)")
+    void schemaSql_hasRecipesTable() {
+        String schema = readSchemaSql();
+        assertTrue(schema.contains("recipes"),
+                "[supplier.nutrition.supply] schema.sql 缺少 recipes 建表:SC-S-10 食谱库供给"
+                        + "应含完整 DDL 快照(当前仅 DatabaseInitializer 运行时建表,schema.sql 无镜像)。");
+    }
+
+    @Test
+    @DisplayName("正常流-5:schema.sql recipes 建表块应含 supplier_id 列(商户隔离存储载体)")
+    void schemaSql_recipesHasSupplierIdColumn() {
+        String schema = readSchemaSql();
+        int start = schema.indexOf("CREATE TABLE IF NOT EXISTS recipes");
+        if (start < 0) {
+            fail("[supplier.nutrition.supply] schema.sql 缺少 recipes 建表,无法校验 supplier_id 列"
+                    + "(SC-S-10 食谱库供给 DDL 快照缺失)。");
+        }
+        int end = schema.indexOf(");", start);
+        String block = end > start && end < schema.length()
+                ? schema.substring(start, end)
+                : schema.substring(start);
+        assertTrue(block.contains("supplier_id"),
+                "[supplier.nutrition.supply] schema.sql recipes 建表块缺少 supplier_id 列:食谱库"
+                        + "供给需按商户归属存储,支撑跨商户隔离(TC-S-10-P 403)。当前缺失。");
+    }
+
+    // ==================== 边界流:格式校验(行为契约,400) ====================
+
+    @Test
+    @DisplayName("边界流-1:上架残缺食谱(name 缺失)应格式校验拒绝 400")
+    void recipeController_create_rejectsEmptyName() {
+        Recipe recipe = new Recipe();
+        recipe.setName("");
+        recipe.setMealType("lunch");
+
+        Result<String> result = recipeController.create(recipe);
+
+        assertEquals(400, result.getCode(),
+                "[supplier.nutrition.supply] 上架 name 为空的食谱应被格式校验拒绝(TC-S-10-E-1 格式校验 400)"
+                        + ",当前直通落库返回 200,契约缺失。");
+        verify(recipeService, never()).create(any(Recipe.class));
+    }
+
+    @Test
+    @DisplayName("边界流-2:上架 ingredients 为残缺 JSON 的食谱应格式校验拒绝 400")
+    void recipeController_create_rejectsBrokenIngredientsJson() {
+        Recipe recipe = new Recipe();
+        recipe.setName("乱炖营养餐");
+        recipe.setIngredients("["); // 残缺 JSON
+
+        Result<String> result = recipeController.create(recipe);
+
+        assertEquals(400, result.getCode(),
+                "[supplier.nutrition.supply] 上架 ingredients 为残缺 JSON 的食谱应格式校验拒绝"
+                        + "(TC-S-10-E-1 格式校验 400),当前直通落库返回 200,契约缺失。");
+        verify(recipeService, never()).create(any(Recipe.class));
+    }
+
+    @Test
+    @DisplayName("边界流-3:上架含负营养值的食材应数据合规拒绝 400")
+    void foodController_create_rejectsNegativeNutrients() {
+        Food food = new Food();
+        food.setName("问题食材");
+        food.setCalories(new BigDecimal("-100"));
+
+        Result<Food> result = foodController.create(food);
+
+        assertEquals(400, result.getCode(),
+                "[supplier.nutrition.supply] 上架负热量食材应被数据合规校验拒绝(SC-S-10 边界:数据合规),"
+                        + "当前直通落库返回 200,契约缺失。");
+        verify(foodService, never()).create(any(Food.class));
+    }
+
+    // ==================== 边界流:数据合规(XSS 转义存储,不执行) ====================
+
+    @Test
+    @DisplayName("边界流-4:上架含 <script> 描述的食谱应转义存储(不执行)")
+    void recipeController_create_sanitizesXssDescription() {
+        Recipe recipe = new Recipe();
+        recipe.setName("合规早餐");
+        recipe.setDescription("<script>alert('xss')</script>营养建议");
+
+        recipeController.create(recipe);
+
+        ArgumentCaptor<Recipe> captor = ArgumentCaptor.forClass(Recipe.class);
+        verify(recipeService).create(captor.capture());
+        String storedDesc = captor.getValue().getDescription();
+        assertFalse(storedDesc != null && storedDesc.contains("<script>"),
+                "[supplier.nutrition.supply] 含 <script> 描述的食谱应转义存储(TC-S-10-E-2 / TC-S-N06:"
+                        + "XSS 转义存储不执行),实际落库描述仍含原始脚本:" + storedDesc);
+    }
+
+    @Test
+    @DisplayName("边界流-5:上架含 <script> 名称的食材应转义存储(不执行)")
+    void foodController_create_sanitizesXssName() {
+        Food food = new Food();
+        food.setName("<script>alert('xss')</script>");
+
+        foodController.create(food);
+
+        ArgumentCaptor<Food> captor = ArgumentCaptor.forClass(Food.class);
+        verify(foodService).create(captor.capture());
+        String storedName = captor.getValue().getName();
+        assertFalse(storedName != null && storedName.contains("<script>"),
+                "[supplier.nutrition.supply] 含 <script> 名称的食材应转义存储(TC-S-N06:内容转义存储"
+                        + "不执行),实际落库名称仍含原始脚本:" + storedName);
+    }
+
+    // ==================== 权限流:商户隔离(非本商户 403) ====================
+
+    @Test
+    @DisplayName("权限流-1:更新食谱应在无合法商户上下文时拒绝 403(跨商户覆盖防护)")
+    void recipeController_update_requiresOwnershipGuard() {
+        Recipe recipe = new Recipe();
+        recipe.setId(1L);
+        recipe.setName("被篡改食谱");
+
+        Result<String> result = recipeController.update(recipe);
+
+        assertEquals(403, result.getCode(),
+                "[supplier.nutrition.supply] 更新食谱应校验商户归属,无合法商户上下文应 403"
+                        + "(TC-S-10-E-3 非本商户覆盖 403 / TC-S-10-P 跨商户访问 403),"
+                        + "当前直通更新返回 200,契约缺失。");
+        verify(recipeService, never()).update(any(Recipe.class));
+    }
+
+    @Test
+    @DisplayName("权限流-2:删除食谱应在无合法商户上下文时拒绝 403(跨商户删除防护)")
+    void recipeController_delete_requiresOwnershipGuard() {
+        java.util.Map<String, Object> params = new java.util.HashMap<>();
+        params.put("id", 1L);
+
+        Result<String> result = recipeController.delete(params);
+
+        assertEquals(403, result.getCode(),
+                "[supplier.nutrition.supply] 删除食谱应校验商户归属,无合法商户上下文应 403"
+                        + "(TC-S-10-P 跨商户资源隔离),当前直通删除返回 200,契约缺失。");
+        verify(recipeService, never()).delete(any(Long.class));
+    }
+
+    @Test
+    @DisplayName("权限流-3:报告解析中台(SC-S-09)回填食材应经 FoodService 合规供给写入")
+    void reportParseAdminController_backfillFood_delegatesCompliantSupplyWrite() {
+        boolean hasFoodService = false;
+        for (Field f : ReportParseAdminController.class.getDeclaredFields()) {
+            if (FoodService.class.equals(f.getType())) {
+                hasFoodService = true;
+                break;
+            }
+        }
+        assertTrue(hasFoodService,
+                "[supplier.nutrition.supply] ReportParseAdminController.backfill-food(SC-S-09 中台回填"
+                        + "食材 → 营养供给)应经 FoodService 合规供给通道写入(数据合规边界),当前仅经"
+                        + "reportCollectService 直写,无合规供给写入点。");
+    }
+
+    // ==================== 反射 / Mockito 辅助 ====================
+
+    private static <T> T readField(Object target, String name) throws Exception {
+        Field f = target.getClass().getDeclaredField(name);
+        f.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        T value = (T) f.get(target);
+        return value;
+    }
+
+    /** 将所有非静态非常量字段注入 Mock(控制器无参构造 + 反射注入,无 Spring)。 */
+    private static void injectMocks(Object target) throws Exception {
+        for (Field f : target.getClass().getDeclaredFields()) {
+            if (Modifier.isStatic(f.getModifiers()) || Modifier.isFinal(f.getModifiers())) {
+                continue;
+            }
+            f.setAccessible(true);
+            f.set(target, mock(f.getType()));
+        }
+    }
+
+    private Class<?> loadClass(String name) {
+        try {
+            return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
+        } catch (ClassNotFoundException e) {
+            return null;
+        }
+    }
+
+    private String readSchemaSql() {
+        try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
+            if (is == null) {
+                return "";
+            }
+            try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
+                scanner.useDelimiter("\\A");
+                return scanner.hasNext() ? scanner.next() : "";
+            }
+        } catch (Exception e) {
+            return "";
+        }
+    }
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-834cd07969b5a4f9b96f12e0ebb5eacc7ccbaed7
+8038be3288291745bec9999b551952d7ab5c0295

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,82 @@
 
 ---
 
+## 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)
+
+### 其他
+- S-cfc-230: Implement teacher.data.isolation (SC-T-13) for family-list data isolation
+- - aggregateNutrition 真实聚合宏量营养素(此前恒置 0)
+- - generateRecommendation 读取 nutrition_goals,负/超上限目标返回 400 拒绝
+-   (Controller 捕获 IllegalArgumentException → Result.badRequest)
+- NutritionPlanTeacherContractTest: 3 红 → 4 绿
+- 
+- S-cfc-232: Implement family.family.autoCreateForPurchase to auto-create initial family on purchase
+- /upgrade、/trial、/orders 由 getUserFamilyId 改走 getOrCreateUserFamilyId;
+- /plan/purchase 改走 get-or-create,noFamily(5001) 守卫仅作兜底保留)。
+- 契约测试 FamilyAutoCreateForPurchaseContractTest 5/5 转绿(原 4F/1G)。
+- 
+
+### 新功能
+- teacher.task.review 规划师任务审核补家庭归属校验使绿——GuideFamilyTaskController.reviewTask/batchReviewTasks 增加被审任务 task.familyId==路径 familyId 校验(SC-T-06/TC-T-09-P 防跨家庭越权审核 IDOR;TC-T-18-E 批量含跨家任务跳过独立不落库),checkMemberOnly 改为接收 Task 避免二次查询;GuideFamilyTaskReviewContractTest 5 例 2红→全绿
+- S-cfc-235 teacher.nutrition.plan 营养目标校验 + 宏量营养素聚合
+
+
+## v1.0.2450 (2026-11-13)
+
+### 文档
+- S-cfc-226 teacher.plan.activate 实现闭环证据(base 已绿:实现由 EVD-8R 落地,48 JUnit 全绿,0 src/main 改动)
+
+
+## v1.0.2449 (2026-11-13)
+
+### 其他
+- S-cfc-223: Implement RED contract test for child.feedback.submit
+
+
+## v1.0.2448 (2026-11-13)
+
+### 测试
+- S-cfc-222 child.shop.pointsExchange RED 契约测试(SC-C-22 商城浏览/积分兑换,5红/6绿锚:重复兑换幂等·并发唯一键·越权成员拦截三缺口)
+
+
+## v1.0.2447 (2026-11-13)
+
+### 文档
+- 研发负责人定时巡检汇报 2026-11-13 1756(RED94/YELLOW93/GREEN8/DONE15 · 派发S-cfc-222/223 · 25在途 · R2满足)
+
+### 测试
+- TDD-RED child.mind.capture (SC-C-21) - S-cfc-221
+
+### 其他
+- 溯源:tests/user-scenarios 应用场景总览 SC-C-21 / tests/test-cases/child.md TC-C-21/21-E/21-P;
+- 设计:API_REFERENCE §4.10 + §4.61.8(EmotionRecognitionController /analyze、/analyze-url + HealthReportController tongue 链)。
+- 预期失败(红)15/16:
+- - MindCaptureService(analyzeFromImage/analyzeFromUrl/getRecordsByMember)未落地
+- - MindCaptureRecord 实体(memberId/captureType/confidenceScore/blurred/nonPhoto/privacyMarked/analysisResult/createdAt)未落地
+- - schema.sql 缺 mind_capture_record 建表
+- - EmotionRecognitionController 缺非照片/低置信度拒绝分支
+- - 权限流说明性标注(jwt 拦截域 + family.member.switch 契约覆盖)
+- 
+
+
 ## v1.0.2446 (2026-11-13)
 
 ### 其他

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.2446
+> 当前版本: v1.0.2452
 
 ## 历史版本
 
@@ -8,6 +8,82 @@
 
 ---
 
+## 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)
+
+### 其他
+- S-cfc-230: Implement teacher.data.isolation (SC-T-13) for family-list data isolation
+- - aggregateNutrition 真实聚合宏量营养素(此前恒置 0)
+- - generateRecommendation 读取 nutrition_goals,负/超上限目标返回 400 拒绝
+-   (Controller 捕获 IllegalArgumentException → Result.badRequest)
+- NutritionPlanTeacherContractTest: 3 红 → 4 绿
+- 
+- S-cfc-232: Implement family.family.autoCreateForPurchase to auto-create initial family on purchase
+- /upgrade、/trial、/orders 由 getUserFamilyId 改走 getOrCreateUserFamilyId;
+- /plan/purchase 改走 get-or-create,noFamily(5001) 守卫仅作兜底保留)。
+- 契约测试 FamilyAutoCreateForPurchaseContractTest 5/5 转绿(原 4F/1G)。
+- 
+
+### 新功能
+- teacher.task.review 规划师任务审核补家庭归属校验使绿——GuideFamilyTaskController.reviewTask/batchReviewTasks 增加被审任务 task.familyId==路径 familyId 校验(SC-T-06/TC-T-09-P 防跨家庭越权审核 IDOR;TC-T-18-E 批量含跨家任务跳过独立不落库),checkMemberOnly 改为接收 Task 避免二次查询;GuideFamilyTaskReviewContractTest 5 例 2红→全绿
+- S-cfc-235 teacher.nutrition.plan 营养目标校验 + 宏量营养素聚合
+
+
+## v1.0.2450 (2026-11-13)
+
+### 文档
+- S-cfc-226 teacher.plan.activate 实现闭环证据(base 已绿:实现由 EVD-8R 落地,48 JUnit 全绿,0 src/main 改动)
+
+
+## v1.0.2449 (2026-11-13)
+
+### 其他
+- S-cfc-223: Implement RED contract test for child.feedback.submit
+
+
+## v1.0.2448 (2026-11-13)
+
+### 测试
+- S-cfc-222 child.shop.pointsExchange RED 契约测试(SC-C-22 商城浏览/积分兑换,5红/6绿锚:重复兑换幂等·并发唯一键·越权成员拦截三缺口)
+
+
+## v1.0.2447 (2026-11-13)
+
+### 文档
+- 研发负责人定时巡检汇报 2026-11-13 1756(RED94/YELLOW93/GREEN8/DONE15 · 派发S-cfc-222/223 · 25在途 · R2满足)
+
+### 测试
+- TDD-RED child.mind.capture (SC-C-21) - S-cfc-221
+
+### 其他
+- 溯源:tests/user-scenarios 应用场景总览 SC-C-21 / tests/test-cases/child.md TC-C-21/21-E/21-P;
+- 设计:API_REFERENCE §4.10 + §4.61.8(EmotionRecognitionController /analyze、/analyze-url + HealthReportController tongue 链)。
+- 预期失败(红)15/16:
+- - MindCaptureService(analyzeFromImage/analyzeFromUrl/getRecordsByMember)未落地
+- - MindCaptureRecord 实体(memberId/captureType/confidenceScore/blurred/nonPhoto/privacyMarked/analysisResult/createdAt)未落地
+- - schema.sql 缺 mind_capture_record 建表
+- - EmotionRecognitionController 缺非照片/低置信度拒绝分支
+- - 权限流说明性标注(jwt 拦截域 + family.member.switch 契约覆盖)
+- 
+
+
 ## v1.0.2446 (2026-11-13)
 
 ### 其他

+ 33 - 0
docs/tdd/impl/impl-2026-11-13-S-cfc-226-teacher-plan-activate.md

@@ -0,0 +1,33 @@
+# S-cfc-226 teacher.plan.activate 实现闭环(实为已落,验证转绿)
+
+- **任务**:S-cfc-226(能力键 `teacher.plan.activate`,编排侧 GREEN 实现续建)
+- **结论**:目标红单测在 base `origin/cfclub`(@05b107309)即已全绿——**实现已存在,无需改动 src/main**。
+
+## 溯源证据(git 真理复核)
+
+| 项 | sha | 说明 |
+|----|-----|------|
+| 实现落地 | merge `7f9b978c2`(EVD-8R 批次) | `GuidePlanReviewController`/`PlanReviewController` 系列 + `PlanActivationService` + `PlannerCertificationGuard` 已含 `activatePlan`(POST `/api/guide/plans/activate`) |
+| 测试登记 | `e9e644c357`(S-cfc-180 契约证据) | `TeacherPlanActivateContractTest` 15 用例;实现已在链 → 未红 |
+| 测试基类批次 | `7f9b978c2` | `PlanReviewTeacherContractTest` 19 用例(三类流契约) |
+
+## 验证结果(干净 worktree,task/S-cfc-226 = origin/cfclub@05b107309)
+
+```
+mvn -Dtest=PlanReviewTeacherContractTest,TeacherPlanActivateContractTest,PlanActivationExecutionContractTest test
+Tests run: 48, Failures: 0, Errors: 0
+BUILD SUCCESS
+```
+
+- `PlanReviewTeacherContractTest`:19/19 绿(guide 路径审核/驳回 + guide/plans 激活三类流)
+- `TeacherPlanActivateContractTest`:15/15 绿(端点/认证闸门/状态守卫/幂等)
+- `PlanActivationExecutionContractTest`:14/14 绿(拆任务幂等)
+
+实现链现状(无改动):
+- `PlanReviewController.activatePlan`:teacher/admin 角色守卫 → teacher 走 `PlannerCertificationGuard.assertCertified` 认证闸门 → `PlanActivationService.activate` → `IllegalStateException` 状态守卫捕获。
+- 断言清单与实现语义逐一比对通过(无脆弱匹配、无删改测试)。
+
+## 处置
+
+- **未改 src/main**,未改测试断言/未删用例(测试侧 git 真理复核可证)。
+- 本提交仅为实现侧闭环证据记录(docs),若编排侧判定此类「base 已绿」键无需再派实现,可直接 mark-green。

+ 1 - 1
docs/tdd/orchestration/sc-key-map.md

@@ -226,7 +226,7 @@ SC-G-02 / SC-G-06 / SC-P-03 / SC-P-10 / SC-P-11 共 5 个 SC 已于 2026-11-06 O
 | SC-T-16 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 客户健康总览 | teacher.family.overview | RED | 未跑通 | — |
 | SC-T-17 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 成长计划进度跟踪 | teacher.family.overview | RED | 未跑通 | GrowthPlanController /child/{memberId}/active |
 | SC-T-18 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 批量任务下发 | teacher.task.review | RED | 未跑通 | TrainingPlanController /deploy、GuideFamilyTaskController /tasks/batch-review |
-| SC-T-19 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 消息推送客户 | teacher.message.push | RED | 未跑通 | — |
+| SC-T-19 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 消息推送客户 | teacher.message.push | YELLOW | 未跑通 | S-cfc-234 实测:TeacherMessagePushContractTest 20/20 全绿(正常/异常/权限三类流 + 结构契约,基线 3cdb534e7);载体 TeacherMessageController /api/guide/messages/*(send/reply/list/unread-count/my)+ UserNotificationController /api/notification/*;键阶段 RED→YELLOW(实现已在 origin/cfclub,三类流 e2e 证据待测试侧补证) |
 | SC-T-20 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 退出/切换账号 | teacher.login.switch | RED | 未跑通 | — |
 | SC-T-21 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 报告对比分析 | teacher.family.overview | RED | 未跑通 | AssessmentComparisonController |
 | SC-T-22 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 方案归档 | teacher.plan.create | RED | 未跑通 | ⚠ 缺口 G-2:GrowthPlan/HealthPlan 均无 archive 专用端点,待后端补齐后回归补录 |