Kaynağa Gözat

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

E2E Test Bot 3 saat önce
ebeveyn
işleme
b0e025963b
24 değiştirilmiş dosya ile 2573 ekleme ve 27 silme
  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(用户级推荐人)并存互不替代,
         // 本列仅记录「某条服务角色申请挂在哪个邀请人名下」,与 users.referrerId(用户级推荐人)并存互不替代,
         // 且服务端写入时不触发任何分销/佣金副作用(物化推荐树/directCount/F24 CF 等)。
         // 且服务端写入时不触发任何分销/佣金副作用(物化推荐树/directCount/F24 CF 等)。
         addServiceRoleApplicationInviterColumn();
         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', " +
                     "dimension_code VARCHAR(20) NOT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth', " +
                     "score INT NOT NULL COMMENT '分数(0-100)', " +
                     "score INT NOT NULL COMMENT '分数(0-100)', " +
                     "assessed_at DATETIME NOT NULL COMMENT '评估时间', " +
                     "assessed_at DATETIME NOT NULL COMMENT '评估时间', " +
-                    "source_type VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual', " +
+                    "source_type VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual/selfcheck', " +
                     "source_id BIGINT COMMENT '来源记录ID', " +
                     "source_id BIGINT COMMENT '来源记录ID', " +
                     "detail_json TEXT COMMENT '详细数据JSON', " +
                     "detail_json TEXT COMMENT '详细数据JSON', " +
                     "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
                     "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
@@ -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,不存在则插入。 */
     /** 幂等 upsert 维度种子:按 dimension_code 存在则补 source_system/legacy_alias,不存在则插入。 */
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
     private void upsertDimensionSeed(String code, String name, String dimensionType, Long parentId,
                                      int sort, String sourceSystem, String nodeKind,
                                      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("用户不存在");
             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 levelCode = params.getOrDefault("levelCode", "FAMILY").toString();
         String paymentType = params.getOrDefault("paymentType", "yearly").toString();
         String paymentType = params.getOrDefault("paymentType", "yearly").toString();
         String period = params.getOrDefault("period", "yearly").toString();
         String period = params.getOrDefault("period", "yearly").toString();
@@ -221,7 +222,8 @@ public class MembershipController {
             return Result.error("用户不存在");
             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);
         PaymentOrderDTO order = membershipService.createOrder(userId, familyId, "FAMILY", "trial", null, null);
         return Result.success(order);
         return Result.success(order);
     }
     }
@@ -242,7 +244,8 @@ public class MembershipController {
             return Result.error("用户不存在");
             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 levelCode = params.get("levelCode").toString();
         String paymentType = params.get("paymentType").toString();
         String paymentType = params.get("paymentType").toString();
         String period = params.get("period") != null ? params.get("period").toString() : "yearly";
         String period = params.get("period") != null ? params.get("period").toString() : "yearly";
@@ -280,7 +283,9 @@ public class MembershipController {
 
 
     /**
     /**
      * 购买会员档位(SC-P-58 一段式购买入口:选档位 → 待支付订单 + 支付参数)。
      * 购买会员档位(SC-P-58 一段式购买入口:选档位 → 待支付订单 + 支付参数)。
-     * 无家庭时 fail-fast 拒绝(与 createOrder 同源文案),家庭维度重复购买幂等在
+     * 无家庭新用户购买时自动创建初始家庭(family.family.autoCreateForPurchase;
+     * 与 /upgrade|/trial|/orders 同语义);get-or-create 仍无法获得家庭时
+     * noFamily 守卫兜底拒绝。家庭维度重复购买幂等在
      * {@code MembershipService#createOrder} 落单前守卫。
      * {@code MembershipService#createOrder} 落单前守卫。
      */
      */
     @Operation(summary = "购买会员档位")
     @Operation(summary = "购买会员档位")
@@ -295,7 +300,11 @@ public class MembershipController {
         if (user == null) {
         if (user == null) {
             return Result.error("用户不存在");
             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("请先创建或加入家庭后再购买会员");
             return Result.noFamily("请先创建或加入家庭后再购买会员");
         }
         }
 
 
@@ -307,7 +316,7 @@ public class MembershipController {
         Long couponId = ParamUtils.getLong(params.get("couponId"));
         Long couponId = ParamUtils.getLong(params.get("couponId"));
 
 
         Map<String, Object> result = membershipService.purchasePlan(
         Map<String, Object> result = membershipService.purchasePlan(
-                userId, user.getFamilyId(), levelCode, period, couponId);
+                userId, familyId, levelCode, period, couponId);
         if (result == null) {
         if (result == null) {
             return Result.error(46001, "该商品暂未开放购买");
             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) {
         if (familyId == null) {
             return Result.noFamily("请先创建或加入家庭");
             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")
     @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 且家庭非会员 → 拒绝(提示开通会员)
      * 会员任务校验:task.memberOnly==1 且家庭非会员 → 拒绝(提示开通会员)
      */
      */
-    private Result<?> checkMemberOnly(Long taskId) {
-        Task task = taskMapper.selectById(taskId);
+    private Result<?> checkMemberOnly(Task task) {
         if (task == null) {
         if (task == null) {
             return Result.error("任务不存在");
             return Result.error("任务不存在");
         }
         }
@@ -329,7 +328,16 @@ public class GuideFamilyTaskController {
         if (!RolesUtil.hasTeacherRole(role, roles)) { return Result.error("Access denied"); }
         if (!RolesUtil.hasTeacherRole(role, roles)) { return Result.error("Access denied"); }
         Result<Void> denied = requireBound(guideId, familyId);
         Result<Void> denied = requireBound(guideId, familyId);
         if (denied != null) return Result.error(denied.getCode(), denied.getMessage());
         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) {
         if (memberCheck != null) {
             return Result.error(memberCheck.getMessage());
             return Result.error(memberCheck.getMessage());
         }
         }
@@ -373,8 +381,19 @@ public class GuideFamilyTaskController {
 
 
         for (Long taskId : taskIds) {
         for (Long taskId : taskIds) {
             try {
             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) {
                 if (memberCheck != null) {
                     failCount++;
                     failCount++;
                     continue;
                     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.TaskPlanInstanceMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.PlanActivationService;
 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.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
@@ -40,6 +41,10 @@ public class PlanReviewController {
     @Resource
     @Resource
     private com.etotem.cfc.service.PlannerCertificationGuard plannerCertificationGuard;
     private com.etotem.cfc.service.PlannerCertificationGuard plannerCertificationGuard;
 
 
+    /** 规划师-家庭绑定统一授权判定点(全项目唯一,SC-T-13 数据隔离) */
+    @Resource
+    private GuideFamilyAccessGuard guideFamilyAccessGuard;
+
     @Operation(summary = "查看家庭的方案列表")
     @Operation(summary = "查看家庭的方案列表")
     @PostMapping("/family-list")
     @PostMapping("/family-list")
     public Result<List<TaskPlanInstance>> listFamilyPlans(@RequestBody Map<String, Object> body,
     public Result<List<TaskPlanInstance>> listFamilyPlans(@RequestBody Map<String, Object> body,
@@ -62,6 +67,23 @@ public class PlanReviewController {
                 return Result.noFamily("请先创建或加入家庭");
                 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>()
         LambdaQueryWrapper<TaskPlanInstance> w = new LambdaQueryWrapper<TaskPlanInstance>()
                 .orderByDesc(TaskPlanInstance::getCreatedAt);
                 .orderByDesc(TaskPlanInstance::getCreatedAt);
         if (filterFamilyId != null) {
         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 =
     private static final List<String> TODAY_STATUS_WHITELIST =
             Collections.unmodifiableList(Arrays.asList("pending", "accepted", "completed"));
             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
     @Resource
     private DietRecommendationMapper dietRecommendationMapper;
     private DietRecommendationMapper dietRecommendationMapper;
 
 
@@ -131,6 +144,9 @@ public class DietRecommendationService {
      * @return {@code id / menu / nutritionSummary / message / participantCount / dishCount}
      * @return {@code id / menu / nutritionSummary / message / participantCount / dishCount}
      */
      */
     public Map<String, Object> generateRecommendation(Long familyId, Map<String, Object> request) {
     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"));
         String dateStr = asString(request.get("date"));
         if (!hasText(dateStr)) {
         if (!hasText(dateStr)) {
             dateStr = LocalDate.now().toString();
             dateStr = LocalDate.now().toString();
@@ -335,6 +351,60 @@ public class DietRecommendationService {
         return value != null && !value.trim().isEmpty();
         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)。 */
     /** Map<String,Object> 下保持 date / meal_type / selected_foods 的原 String 语义(K3 / R-8)。 */
     private static String asString(Object value) {
     private static String asString(Object value) {
         if (value == null) {
         if (value == null) {
@@ -655,6 +725,11 @@ public class DietRecommendationService {
         } else {
         } else {
             dish.put("calories", 0);
             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<>();
         List<Map<String, Object>> ingredients = new ArrayList<>();
         JsonNode ingNode = dishNode.get("ingredients");
         JsonNode ingNode = dishNode.get("ingredients");
         if (ingNode != null && ingNode.isArray()) {
         if (ingNode != null && ingNode.isArray()) {
@@ -682,6 +757,17 @@ public class DietRecommendationService {
         return dish;
         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。 */
     /** 占位菜/汤:结构与 toDish 输出一致(归一化后结构),calories=0,带可识别的 type。 */
     private static Map<String, Object> placeholderDish(List<String> names, String type, int seq, String defaultName) {
     private static Map<String, Object> placeholderDish(List<String> names, String type, int seq, String defaultName) {
         Map<String, Object> dish = new LinkedHashMap<>();
         Map<String, Object> dish = new LinkedHashMap<>();
@@ -708,10 +794,17 @@ public class DietRecommendationService {
         return dish;
         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) {
     private Map<String, Object> aggregateNutrition(Map<String, Object> normalized) {
         Map<String, Object> summary = new LinkedHashMap<>();
         Map<String, Object> summary = new LinkedHashMap<>();
         int calories = 0;
         int calories = 0;
+        int protein = 0;
+        int carbs = 0;
+        int fat = 0;
         @SuppressWarnings("unchecked")
         @SuppressWarnings("unchecked")
         List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
         List<Map<String, Object>> meals = (List<Map<String, Object>>) normalized.get("meals");
         if (meals != null) {
         if (meals != null) {
@@ -722,20 +815,25 @@ public class DietRecommendationService {
                     continue;
                     continue;
                 }
                 }
                 for (Map<String, Object> dish : dishes) {
                 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("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;
         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) {
     private String buildFallbackMenu(Long familyId, LocalDate date, int participantCount) {
         return buildFallbackMenu(familyId, date, participantCount, availableIngredientNames(familyId, date));
         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.SubmitSelfCheckDTO;
 import com.etotem.cfc.dto.WuxingSourcingAdviceVO;
 import com.etotem.cfc.dto.WuxingSourcingAdviceVO;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.FiveDimensionScore;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
 import com.etotem.cfc.entity.FiveDimensionSelfCheck;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
 import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
 import com.etotem.cfc.mapper.FiveDimensionSelfCheckMapper;
 import com.etotem.cfc.entity.SelfCheckIgnore;
 import com.etotem.cfc.entity.SelfCheckIgnore;
 import com.etotem.cfc.mapper.SelfCheckIgnoreMapper;
 import com.etotem.cfc.mapper.SelfCheckIgnoreMapper;
@@ -63,6 +65,9 @@ public class FiveDimensionSelfCheckService {
     @Resource
     @Resource
     private SelfCheckTrendService selfCheckTrendService;
     private SelfCheckTrendService selfCheckTrendService;
 
 
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
     private final ObjectMapper objectMapper = new ObjectMapper();
 
 
     /** 维度中文名与五行 */
     /** 维度中文名与五行 */
@@ -498,6 +503,9 @@ public class FiveDimensionSelfCheckService {
         record.setCreatedAt(new Date());
         record.setCreatedAt(new Date());
         selfCheckMapper.insert(record);
         selfCheckMapper.insert(record);
 
 
+        // 五维自评 → 五维分数变化桥接(SC-T-32:自评分数写为五维分数变化,source_type=selfcheck)
+        bridgeSelfCheckToDimensionScores(record, scoreMap);
+
         // 新手引导联动:完成五维自检任务(老用户无该任务记录时静默跳过)
         // 新手引导联动:完成五维自检任务(老用户无该任务记录时静默跳过)
         try {
         try {
             onboardingService.completeTask(userId, "COMPLETE_SELF_CHECK");
             onboardingService.completeTask(userId, "COMPLETE_SELF_CHECK");
@@ -516,6 +524,49 @@ public class FiveDimensionSelfCheckService {
         return vo;
         return vo;
     }
     }
 
 
+    /**
+     * 五维自评 → 五维分数变化桥接(SC-T-32)。
+     *
+     * <p>契约「五维自评 → 变化落 EnergyController」:自评提交后,将各维度得分
+     * (0-9 制)等比例换算为 0-100,写入 {@code five_dimension_scores}
+     * (source_type=selfcheck),使自评分数可参与五维能量变化归因。
+     * 静默兜底:任何异常仅记录,不阻断自检主流程。</p>
+     */
+    private void bridgeSelfCheckToDimensionScores(FiveDimensionSelfCheck record, Map<String, Integer> scoreMap) {
+        if (record == null || record.getFamilyMemberId() == null || scoreMap == null || scoreMap.isEmpty()) {
+            return;
+        }
+        try {
+            FamilyMember member = familyMemberMapper.selectById(record.getFamilyMemberId());
+            if (member == null || member.getFamilyId() == null) {
+                return;
+            }
+            Date assessedAt = record.getCreatedAt() != null ? record.getCreatedAt() : new Date();
+            for (Map.Entry<String, Integer> entry : scoreMap.entrySet()) {
+                String dim = entry.getKey();
+                Integer raw = entry.getValue();
+                if (dim == null || raw == null) {
+                    continue;
+                }
+                FiveDimensionScore score = new FiveDimensionScore();
+                score.setMemberId(record.getFamilyMemberId());
+                score.setMemberType("member");
+                score.setFamilyId(member.getFamilyId());
+                score.setDimensionCode(dim);
+                // 自检维度分 0-9 制 → 0-100 制(四舍五入)
+                score.setScore(Math.round(raw * 100.0f / 9.0f));
+                score.setAssessedAt(assessedAt);
+                score.setSourceType("selfcheck");
+                score.setSourceId(record.getId());
+                score.setDetailJson("{}");
+                score.setCreatedAt(assessedAt);
+                fiveDimensionScoreMapper.insert(score);
+            }
+        } catch (Exception e) {
+            log.warn("五维自评桥接五维分数变化失败(静默容忍): {}", e.getMessage());
+        }
+    }
+
     /** 获取最新一次自检结果 */
     /** 获取最新一次自检结果 */
     public SelfCheckResultVO getLatest(Long userId) {
     public SelfCheckResultVO getLatest(Long userId) {
         LambdaQueryWrapper<FiveDimensionSelfCheck> wrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()
         LambdaQueryWrapper<FiveDimensionSelfCheck> wrapper = new LambdaQueryWrapper<FiveDimensionSelfCheck>()

+ 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',
     dimension_code VARCHAR(20) NOT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth',
     score INT NOT NULL COMMENT '分数(0-100)',
     score INT NOT NULL COMMENT '分数(0-100)',
     assessed_at DATETIME NOT NULL COMMENT '评估时间',
     assessed_at DATETIME NOT NULL COMMENT '评估时间',
-    source_type VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual',
+    source_type VARCHAR(50) COMMENT '来源类型: periodic/task/assessment/manual/selfcheck',
     source_id BIGINT COMMENT '来源记录ID',
     source_id BIGINT COMMENT '来源记录ID',
     detail_json TEXT COMMENT '详细数据JSON',
     detail_json TEXT COMMENT '详细数据JSON',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
@@ -7218,3 +7218,24 @@ CREATE TABLE IF NOT EXISTS service_cost_config (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     UNIQUE KEY uk_service_cost (service_type, product_id)
     UNIQUE KEY uk_service_cost (service_type, product_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务成本价配置';
 ) 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",
   "name": "cfc-web",
-  "version": "1.0.2445",
+  "version": "1.0.2451",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.2445",
+      "version": "1.0.2451",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.2446",
+  "version": "1.0.2452",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "dev": "vue-cli-service serve",
     "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)
 ## 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)
 ## 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-16 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 客户健康总览 | teacher.family.overview | RED | 未跑通 | — |
 | SC-T-17 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 成长计划进度跟踪 | teacher.family.overview | RED | 未跑通 | GrowthPlanController /child/{memberId}/active |
 | 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-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-20 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 退出/切换账号 | teacher.login.switch | RED | 未跑通 | — |
 | SC-T-21 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 报告对比分析 | teacher.family.overview | RED | 未跑通 | AssessmentComparisonController |
 | SC-T-21 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 报告对比分析 | teacher.family.overview | RED | 未跑通 | AssessmentComparisonController |
 | SC-T-22 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 方案归档 | teacher.plan.create | RED | 未跑通 | ⚠ 缺口 G-2:GrowthPlan/HealthPlan 均无 archive 专用端点,待后端补齐后回归补录 |
 | SC-T-22 | 成长规划师 | 业务域 L:交易延伸 / 内容 / 多端 | 方案归档 | teacher.plan.create | RED | 未跑通 | ⚠ 缺口 G-2:GrowthPlan/HealthPlan 均无 archive 专用端点,待后端补齐后回归补录 |