Quellcode durchsuchen

chore: sync origin/cfclub 并行提交(行高修复部署后)

# Conflicts:
#	cfc-web/.last_build_commit
#	cfc-web/package.json
#	cfc-web/public/CHANGELOG-v1.0.md
#	cfc-web/public/CHANGELOG.md
Xiaogang Liao vor 1 Tag
Ursprung
Commit
0b887ec45e
49 geänderte Dateien mit 2547 neuen und 104 gelöschten Zeilen
  1. 95 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  3. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/GrowthController.java
  4. 45 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  5. 241 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
  6. 20 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentAppointmentController.java
  7. 67 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/butler/ButlerController.java
  8. 30 2
      cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java
  9. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java
  10. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerResourceFollowUp.java
  11. 51 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerResourceMatchLog.java
  12. 70 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerResourceMatchResult.java
  13. 11 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/EnergyLog.java
  14. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerResourceFollowUpMapper.java
  15. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerResourceMatchLogMapper.java
  16. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerResourceMatchResultMapper.java
  17. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentAppointmentService.java
  18. 290 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ButlerResourceMatchService.java
  19. 92 1
      cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java
  20. 42 2
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberNutritionProfileService.java
  21. 58 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  22. 88 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GrowthMilestoneService.java
  23. 36 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  24. 255 0
      cfc-backend/src/main/java/com/etotem/cfc/service/MealPlanService.java
  25. 14 2
      cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java
  26. 199 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportArchiveCompareService.java
  27. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
  28. 54 0
      cfc-backend/src/main/resources/schema.sql
  29. 6 3
      cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchMessageServiceTest.java
  30. 5 3
      cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchServiceImplTest.java
  31. 1 1
      cfc-web/.last_build_commit
  32. 2 2
      cfc-web/package-lock.json
  33. 1 1
      cfc-web/package.json
  34. 98 1
      cfc-web/public/CHANGELOG-v1.0.md
  35. 99 2
      cfc-web/public/CHANGELOG.md
  36. 2 2
      cfc-web/src/views/Layout.vue
  37. 1 1
      cfc-web/src/views/admin/ActivityEdit.vue
  38. 1 1
      cfc-web/src/views/admin/ArticleEdit.vue
  39. 1 1
      cfc-web/src/views/admin/InventoryInboundCreate.vue
  40. 1 1
      cfc-web/src/views/admin/ProductEdit.vue
  41. 4 4
      cfc-web/src/views/admin/ProductManage.vue
  42. 1 1
      cfc-web/src/views/admin/SpecialPriceManage.vue
  43. 0 0
      docs/superpowers/PROJECT-OVERVIEW.md
  44. 1 1
      docs/superpowers/api/API_REFERENCE.md
  45. 90 44
      docs/superpowers/plans/2026-11-14-cf-reward-model-v2-design.md
  46. 44 23
      docs/superpowers/plans/2026-11-14-cf-reward-model-v2.md
  47. 0 0
      docs/tdd/orchestration/pending.md
  48. 116 0
      docs/tdd/orchestration/report-dev-2026-11-14-1305.md
  49. 159 0
      docs/tdd/orchestration/report-dev-2026-11-14-1459.md

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -3542,6 +3542,101 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         // 迁移474: membership_levels 加会员成本价列 cost_price(F-3a 送会员商品P点口径:
         // 配置了「送会员」规则的商品订单,P 点 = 实付 − 商品成本 − 赠送等级会员成本价)
         addMembershipCostPriceColumn();
+
+        // 迁移475: 资源匹配结果表 butler_resource_match_results(SC-H-05 匹配结果携带距离/时间/适龄,仅已审核资源入候选)
+        createButlerResourceMatchResultTable();
+        // 迁移476: 资源匹配推送留痕表 butler_resource_match_log(TC-H-05-E-4 高频推送同类资源频次受控)
+        createButlerResourceMatchLogTable();
+        // 迁移477: 资源参与回访沉淀偏好表 butler_resource_follow_up(AC4 回访记录写入家长档案)
+        createButlerResourceFollowUpTable();
+    }
+
+    /**
+     * 迁移475: 创建资源匹配结果表(SC-H-05,能力键 butler.resource.match)。
+     *
+     * <p>匹配结果携带距离/时间/适龄信息(AC1),仅已审核资源入候选(reviewStatus=approved,TC-H-05-E-1);
+     * 家长端可查看并选择(AC2)。幂等:CREATE TABLE IF NOT EXISTS。</p>
+     */
+    private void createButlerResourceMatchResultTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_resource_match_results ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "butler_user_id BIGINT NOT NULL COMMENT '推荐管家用户 ID', "
+                    + "family_id BIGINT NOT NULL COMMENT '家庭 ID', "
+                    + "member_id BIGINT NULL COMMENT '目标成员 ID (family_members.id)', "
+                    + "resource_type VARCHAR(32) DEFAULT 'activity' COMMENT '资源类型: activity(线下活动)/offline_course(线下课程)', "
+                    + "resource_id BIGINT NOT NULL COMMENT '资源 ID (activity.id)', "
+                    + "resource_name VARCHAR(200) NULL COMMENT '资源名称', "
+                    + "distance_km DECIMAL(10,2) NULL COMMENT 'AC1: 距离(公里)', "
+                    + "start_time DATETIME NULL COMMENT 'AC1: 活动开始时间', "
+                    + "age_suitability VARCHAR(64) NULL COMMENT 'AC1: 适龄信息', "
+                    + "review_status VARCHAR(20) DEFAULT 'pending' COMMENT '审核门禁: approved/pending/rejected,仅 approved 入候选', "
+                    + "match_reason VARCHAR(500) NULL COMMENT '匹配理由', "
+                    + "match_score INT DEFAULT 0 COMMENT '匹配评分(0-100)', "
+                    + "status VARCHAR(20) DEFAULT 'matched' COMMENT '匹配状态: matched/pushed/booked', "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "INDEX idx_member (member_id), "
+                    + "INDEX idx_family (family_id), "
+                    + "INDEX idx_resource (resource_id)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源匹配结果(SC-H-05)'");
+            log.info("迁移475: butler_resource_match_results 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移475: butler_resource_match_results 创建失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移476: 创建资源匹配推送留痕表(TC-H-05-E-4,高频推送同类资源频次受控)。
+     *
+     * <p>记录推送时间戳与次数,触发阈值后限制同类资源推送。幂等:CREATE TABLE IF NOT EXISTS。</p>
+     */
+    private void createButlerResourceMatchLogTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_resource_match_log ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "butler_user_id BIGINT NOT NULL COMMENT '推送管家用户 ID', "
+                    + "member_id BIGINT NULL COMMENT '目标成员 ID', "
+                    + "resource_id BIGINT NOT NULL COMMENT '资源 ID', "
+                    + "resource_type VARCHAR(32) DEFAULT 'activity' COMMENT '资源类型: activity/offline_course', "
+                    + "resource_category VARCHAR(64) DEFAULT 'general' COMMENT '同类资源分类(频次控制维度)', "
+                    + "pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '最近推送时间戳', "
+                    + "push_count INT DEFAULT 1 COMMENT '累计推送次数(同类资源频次控制)', "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "INDEX idx_butler_member_cat (butler_user_id, member_id, resource_category)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源匹配推送留痕(TC-H-05-E-4)'");
+            log.info("迁移476: butler_resource_match_log 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移476: butler_resource_match_log 创建失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移477: 创建资源参与回访沉淀偏好表(AC4,参与后回访记录写入家长档案,后续推荐参考偏好)。
+     *
+     * <p>幂等:CREATE TABLE IF NOT EXISTS。</p>
+     */
+    private void createButlerResourceFollowUpTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS butler_resource_follow_up ("
+                    + "id BIGINT AUTO_INCREMENT PRIMARY KEY, "
+                    + "family_id BIGINT NOT NULL COMMENT '家庭 ID', "
+                    + "member_id BIGINT NULL COMMENT '目标成员 ID', "
+                    + "butler_user_id BIGINT NOT NULL COMMENT '回访管家用户 ID', "
+                    + "resource_id BIGINT NOT NULL COMMENT '资源 ID', "
+                    + "resource_type VARCHAR(32) DEFAULT 'activity' COMMENT '资源类型: activity/offline_course', "
+                    + "follow_up_content TEXT NULL COMMENT '回访内容(参与感受、反馈)', "
+                    + "preference_tags TEXT NULL COMMENT '沉淀偏好标签(JSON 数组字符串)', "
+                    + "follow_up_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '回访时间', "
+                    + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
+                    + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, "
+                    + "INDEX idx_family_member (family_id, member_id)"
+                    + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源参与回访沉淀偏好(AC4)'");
+            log.info("迁移477: butler_resource_follow_up 表已就绪");
+        } catch (Exception e) {
+            log.warn("迁移477: butler_resource_follow_up 创建失败: {}", e.getMessage());
+        }
     }
 
     /** 迁移473(FamilyWorkbench): tasks 加家庭+时间复合索引,支撑工作台按家庭聚合计数与近 30 天逐日趋势。 */

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -98,6 +98,8 @@ private static final String[] PUBLIC_PATHS = {
         // 杜绝匿名(userId=-1)被放行到 controller 后走"业务 500"而非安全 401 的口径漂移。
         "/api/auth/switch-role",            // 角色切换:属权限提升操作(可重签 token),必须登录
         "/api/family/user/switch-role",     // 角色切换(家庭侧):同上,必须登录
+        "/api/family/user/create-family",   // S-cfc-356(TC-P-01-P):建家庭/生成邀请属写操作,
+                                            // 无 token 匿名(userId=-1)不得放行,否则会建出无主家庭,必须 401
         "/api/family/member/list",          // 家庭成员列表:家庭私有数据,必须登录
         // SC-P-03-2 双角色切换(SC-P-03):切换视角属私有视角操作(仅本人家庭成员可切换),
         // 无 token 匿名(userId=-1)不得放行——必须 401,防业务 500 口径漂移。

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/GrowthController.java

@@ -1,7 +1,9 @@
 package com.etotem.cfc.controller;
 
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.GrowthMilestoneService;
 import com.etotem.cfc.service.GrowthRecommendationService;
+import com.etotem.cfc.util.ParamUtils;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -19,6 +21,9 @@ public class GrowthController {
     @Resource
     private GrowthRecommendationService growthRecommendationService;
 
+    @Resource
+    private GrowthMilestoneService growthMilestoneService;
+
     @Operation(summary = "获取成长板块推荐内容")
     @PostMapping("/recommendations")
     public Result<Map<String, Object>> getRecommendations(
@@ -45,4 +50,38 @@ public class GrowthController {
             @RequestParam(defaultValue = "20") int batchSize) {
         return Result.success(growthRecommendationService.autoClassifyProducts(batchSize));
     }
+
+    // ========== 成长里程碑(SC-P-51 / 缺口 G-2:growth.milestone.mark)==========
+
+    @Operation(summary = "标记成长里程碑(系统/手动标,返回里程碑ID,幂等)")
+    @PostMapping("/milestone/mark")
+    public Result<Long> markMilestone(@RequestBody Map<String, Object> body,
+                                      @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(body.get("memberId"), currentMemberId);
+        Long recordId = ParamUtils.getLong(body.get("recordId"));
+        String milestoneType = body.get("milestoneType") != null
+                ? body.get("milestoneType").toString() : "manual";
+        if (memberId == null || recordId == null) {
+            return Result.error("缺少必要参数: memberId, recordId");
+        }
+        Long milestoneId = growthMilestoneService.markMilestone(memberId, recordId, milestoneType);
+        return Result.success("标记成功", milestoneId);
+    }
+
+    @Operation(summary = "查询成长记录是否已标记里程碑(时间线高亮判定)")
+    @PostMapping("/milestone/marked")
+    public Result<Map<String, Object>> isMilestoneMarked(@RequestBody Map<String, Object> body,
+                                                        @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(body.get("memberId"), currentMemberId);
+        Long recordId = ParamUtils.getLong(body.get("recordId"));
+        String milestoneType = body.get("milestoneType") != null
+                ? body.get("milestoneType").toString() : "manual";
+        if (memberId == null || recordId == null) {
+            return Result.error("缺少必要参数: memberId, recordId");
+        }
+        boolean marked = growthMilestoneService.isMarked(memberId, recordId, milestoneType);
+        Map<String, Object> result = new HashMap<>();
+        result.put("marked", marked);
+        return Result.success(result);
+    }
 }

+ 45 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -39,6 +39,7 @@ import com.etotem.cfc.service.FoodService;
 import com.etotem.cfc.service.HealthAnalysisService;
 import com.etotem.cfc.service.HealthReportDraftService;
 import com.etotem.cfc.service.HealthReportService;
+import com.etotem.cfc.service.ReportArchiveCompareService;
 import com.etotem.cfc.service.MemberIndicatorCurrentService;
 import com.etotem.cfc.service.BasicQuotaService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
@@ -212,6 +213,9 @@ public class HealthReportController {
     @Resource
     private OnboardingService onboardingService;
 
+    @Resource
+    private ReportArchiveCompareService reportArchiveCompareService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -758,20 +762,58 @@ public class HealthReportController {
     }
 
     /**
-     * 删除健康报告(逻辑删除 + 清理关联数据)
+     * 删除健康报告(逻辑删除 + 清理关联数据)。
+     *
+     * <p>删除守卫(SC-P-24 / TC-P-24-E):报告已关联成长方案时拒绝删除并提示
+     * 「请先解除关联方案」;归属校验(SC-P-24-P / TC-P-24-P):报告须归属当前用户家庭,
+     * 横向越权删除他人报告统一被拒。</p>
      */
     @Operation(summary = "删除健康报告")
     @PostMapping("/report/delete")
     public Result<String> deleteReport(
-            @RequestBody Map<String, Object> params) {
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
         Long reportId = ParamUtils.getLong(params.get("reportId"));
         if (reportId == null) {
             return Result.error("reportId不能为空");
         }
-        healthReportService.deleteReport(reportId);
+        // 归属校验(TC-P-24-P):报告须归属当前用户家庭,横向越权删除被拒
+        HealthReport report = healthReportService.getReportById(reportId);
+        if (report == null) {
+            return Result.error("报告不存在");
+        }
+        if (familyId != null && report.getFamilyId() != null && !familyId.equals(report.getFamilyId())) {
+            return Result.error(403, "无权删除该报告");
+        }
+        try {
+            healthReportService.deleteReport(reportId);
+        } catch (IllegalStateException e) {
+            // 删除守卫命中(报告已关联成长方案)
+            return Result.error(409, e.getMessage());
+        }
         return Result.success("已删除");
     }
 
+    /**
+     * 报告对比(SC-P-24 / TC-P-24「对比展示差异」,§4.59.10 契约缺口落地)。
+     *
+     * <p>入参 {@code {reportAId, reportBId}};类型一致才可比对,返回差异标注。</p>
+     */
+    @Operation(summary = "报告对比")
+    @PostMapping("/report/compare")
+    public Result<Map<String, Object>> compareReports(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        Long reportAId = ParamUtils.getLong(params.get("reportAId"));
+        Long reportBId = ParamUtils.getLong(params.get("reportBId"));
+        if (reportAId == null || reportBId == null) {
+            return Result.error("reportAId 和 reportBId 不能为空");
+        }
+        Map<String, Object> result = reportArchiveCompareService.compareReports(reportAId, reportBId);
+        return Result.success(result);
+    }
+
     /**
      * 获取健康报告文件下载信息(小程序端"下载报告")
      * 返回完整可下载 URL 供 uni.downloadFile 使用;

+ 241 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -327,6 +327,31 @@ public class AIChatController {
         }
         Map<String, Object> inputs = buildChatInputs(userId, params);
 
+        // 3.5 报告可用性门禁(S-cfc-350 / TC-B-04-E)
+        //   - draftId 路径:草稿上下文必须已带解析后 payload(reportDetail),否则视为「报告解析中」
+        //   - reportId 路径:上下文已带 reportDetail 时按归属校验;缺 reportDetail 视为无报告 → 提示上传
+        if (draftIdStr != null && !draftIdStr.trim().isEmpty()) {
+            Object draftDetail = inputs.get("reportDetail");
+            if (!(draftDetail instanceof Map) || ((Map<?, ?>) draftDetail).isEmpty()) {
+                return Result.error("报告解析中,请稍后重试");
+            }
+        } else {
+            Object detailObj = inputs.get("reportDetail");
+            if (detailObj instanceof Map) {
+                Object reportObj = ((Map<?, ?>) detailObj).get("report");
+                if (reportObj == null) {
+                    return Result.error("尚未上传报告,请先上传报告后再发起解读");
+                }
+                Result<Void> accessDenied = checkReportAccess(user, reportObj);
+                if (accessDenied != null) {
+                    @SuppressWarnings("unchecked")
+                    Result<Map<String, Object>> denied = (Result<Map<String, Object>>) (Result<?>) accessDenied;
+                    return denied;
+                }
+            }
+            // detailObj 缺失/非 Map(如上下文未注入 reportDetail):保持原有宽容行为,交由 AI 侧兜底回答
+        }
+
         // 4. 调 LangGraph(经 aiService.sendMessage 含记忆层+镜像)
         Map<String, Object> difyResp = aiService.sendMessage(
                 query, String.valueOf(userId),
@@ -336,13 +361,228 @@ public class AIChatController {
             return Result.error("AI 服务暂不可用,请稍后重试");
         }
         String answer = (String) difyResp.getOrDefault("answer", "");
-        String cleanAnswer = taskParseService.stripTaskMarkers(answer);
+        String cleanAnswer = maskSensitiveInfo(taskParseService.stripTaskMarkers(answer));
+
+        // 5. 结构化三段输出(通俗解读 / 五维关联 / 行动建议)+ 类型一致性提示
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("answer", cleanAnswer);
+        result.put("plainSummary", cleanAnswer);
+        result.put("fiveDimAssociations", buildFiveDimAssociations(cleanAnswer));
+        result.put("actionSuggestions", buildActionSuggestions(cleanAnswer));
+        result.put("typeWarning", buildTypeWarning(inputs));
         result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
         return Result.success(result);
     }
 
+    /**
+     * 报告归属校验(TC-B-04-P):报告已带 familyId 且与当前登录用户家庭不一致 → 拒绝。
+     * 报告缺 familyId(历史数据/未归属)时 fail-open 放行,由上游 family 上下文保证隔离。
+     */
+    private Result<Void> checkReportAccess(com.etotem.cfc.entity.User user, Object reportObj) {
+        if (!(reportObj instanceof com.etotem.cfc.entity.HealthReport)) {
+            return null;
+        }
+        com.etotem.cfc.entity.HealthReport report = (com.etotem.cfc.entity.HealthReport) reportObj;
+        Long reportFamilyId = report.getFamilyId();
+        if (reportFamilyId == null || user == null) {
+            return null;
+        }
+        Long userFamilyId = user.getFamilyId();
+        if (userFamilyId != null && !reportFamilyId.equals(userFamilyId)) {
+            return Result.forbidden("无权解读该报告,报告不属于当前家庭");
+        }
+        return null;
+    }
+
+    /** 五维关键词表(身/心/智/行/富) */
+    private static final String[][] FIVE_DIM_KEYWORDS = {
+            // body 身
+            {"肠道", "菌群", "免疫", "营养", "代谢", "消化", "身体", "体质", "健康", "炎症", "睡眠", "体重"},
+            // mind 心
+            {"情绪", "压力", "焦虑", "心理", "心情", "情感", "抑郁", "心态"},
+            // intellect 智
+            {"认知", "学习", "记忆", "专注", "注意力", "智力", "思维", "大脑"},
+            // action 行
+            {"运动", "锻炼", "作息", "习惯", "行为", "活动", "执行", "坚持"},
+            // wealth 富
+            {"医疗", "保险", "费用", "花费", "支出", "财富", "理财", "预算", "经济"}
+    };
+
+    private static final String[] FIVE_DIM_FALLBACKS = {
+            "身体维度:建议结合本次报告关注身体基础指标,维持均衡饮食与规律作息。",
+            "心理维度:报告未直接覆盖情绪,可结合日常情绪变化观察身体状况对心理的影响。",
+            "智力维度:报告未直接覆盖认知,可关注营养与睡眠对学习专注度的间接支持。",
+            "行动维度:建议把报告中的改善建议转化为可执行的小步骤并持续跟进。",
+            "财富维度:报告未涉及财务信息,可在医疗/营养支出上做合理规划。"
+    };
+
+    private static final String[] ACTION_SUGGESTION_KEYWORDS = {
+            "建议", "应该", "可以", "需要", "推荐", "尽量", "注意", "不妨", "宜"
+    };
+
+    private static final List<String> DEFAULT_ACTION_SUGGESTIONS;
+
+    static {
+        List<String> defaults = new ArrayList<>();
+        defaults.add("建议结合报告结论,把改善动作拆成每天可完成的小步骤并记录执行情况。");
+        defaults.add("建议持续跟踪关键指标变化,必要时复检或咨询专业人士。");
+        DEFAULT_ACTION_SUGGESTIONS = Collections.unmodifiableList(defaults);
+    }
+
+    /** 把解读文本按中文句读切分为句子 */
+    private List<String> splitSentences(String text) {
+        List<String> sentences = new ArrayList<>();
+        if (text == null || text.trim().isEmpty()) {
+            return sentences;
+        }
+        String[] parts = text.split("[。!?!?;;\n]+");
+        for (String part : parts) {
+            String trimmed = part == null ? "" : part.trim();
+            if (!trimmed.isEmpty()) {
+                sentences.add(trimmed);
+            }
+        }
+        return sentences;
+    }
+
+    /**
+     * 基于关键词把解读文本映射到身/心/智/行/富五维;命不中的维度用兜底文案,
+     * 保证输出恒定含 body/mind/intellect/action/wealth 五键。
+     */
+    private Map<String, Object> buildFiveDimAssociations(String text) {
+        List<String> sentences = splitSentences(text);
+        String[] dimKeys = {"body", "mind", "intellect", "action", "wealth"};
+        Map<String, Object> dims = new LinkedHashMap<>();
+        for (int i = 0; i < dimKeys.length; i++) {
+            String matched = null;
+            for (String sentence : sentences) {
+                for (String keyword : FIVE_DIM_KEYWORDS[i]) {
+                    if (sentence.contains(keyword)) {
+                        matched = sentence;
+                        break;
+                    }
+                }
+                if (matched != null) break;
+            }
+            dims.put(dimKeys[i], matched != null ? matched : FIVE_DIM_FALLBACKS[i]);
+        }
+        return dims;
+    }
+
+    /** 抽取含建议类关键词的句子作为行动建议;无命中时返回兜底建议 */
+    private List<String> buildActionSuggestions(String text) {
+        List<String> suggestions = new ArrayList<>();
+        for (String sentence : splitSentences(text)) {
+            for (String keyword : ACTION_SUGGESTION_KEYWORDS) {
+                if (sentence.contains(keyword)) {
+                    suggestions.add(sentence);
+                    break;
+                }
+            }
+        }
+        if (suggestions.isEmpty()) {
+            return new ArrayList<>(DEFAULT_ACTION_SUGGESTIONS);
+        }
+        return suggestions;
+    }
+
+    /**
+     * 报告类型一致性提示:类型集合为空 / 含未知类型 / 多于一种类型时视为不可横向比较。
+     */
+    private Map<String, Object> buildTypeWarning(Map<String, Object> inputs) {
+        Set<String> types = new LinkedHashSet<>();
+        Object detailObj = inputs.get("reportDetail");
+        if (detailObj instanceof Map) {
+            Object reportObj = ((Map<?, ?>) detailObj).get("report");
+            if (reportObj instanceof com.etotem.cfc.entity.HealthReport) {
+                String reportType = ((com.etotem.cfc.entity.HealthReport) reportObj).getReportType();
+                if (reportType != null && !reportType.trim().isEmpty()) {
+                    types.add(reportType.trim());
+                } else {
+                    types.add("");
+                }
+            }
+        }
+        boolean mismatch = types.isEmpty() || types.contains("") || types.size() > 1;
+        Map<String, Object> warning = new LinkedHashMap<>();
+        warning.put("mismatch", mismatch);
+        if (mismatch) {
+            warning.put("message", "检测到不同类型的报告,数据口径不一致,不建议直接横向比较");
+        }
+        return warning;
+    }
+
+    // ── PII 脱敏(S-cfc-350 / TC-B-04-E 异常流-4) ──
+
+    /** 中国大陆手机号:1[3-9] 开头的 11 位数字,前后不接数字 */
+    private static final Pattern PII_PHONE_PATTERN =
+            Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
+
+    /** 长数字串(身份证/银行卡等 15-18 位)整体遮蔽 */
+    private static final Pattern PII_LONG_DIGIT_PATTERN =
+            Pattern.compile("(?<!\\d)\\d{15,18}(?!\\d)");
+
+    /** 中文姓名:2-3 个汉字后紧跟「的」或称谓类后缀 */
+    private static final Pattern PII_NAME_PATTERN =
+            Pattern.compile("[\\u4e00-\\u9fa5]{2,3}(?=的|先生|女士|小姐|老师|医生|大夫|教授|主任|同学|患者|受检人|受检者|本人)");
+
+    /** 姓名脱敏白名单:常见泛指/亲属/职业/部位词,命中后缀时不应被当成姓名遮蔽 */
+    private static final Set<String> NAME_STOPWORDS;
+
+    static {
+        Set<String> words = new HashSet<>();
+        String[] raw = {
+                "孩子", "家长", "小孩", "宝宝", "儿童", "学生", "大人", "父母", "爸爸", "妈妈",
+                "爷爷", "奶奶", "外公", "外婆", "哥哥", "姐姐", "弟弟", "妹妹", "儿子", "女儿",
+                "老师", "医生", "大夫", "护士", "教授", "主任", "同学", "朋友", "本人", "患者",
+                "受检人", "受检者", "用户", "我们", "他们", "大家", "建议", "关注", "注意",
+                "肠道", "菌群", "免疫", "营养", "代谢", "消化", "睡眠", "体重", "体质",
+                "情绪", "心理", "压力", "焦虑", "认知", "学习", "记忆", "专注", "大脑",
+                "运动", "锻炼", "作息", "习惯", "行为", "医疗", "保险", "费用", "花费",
+                "主治", "主治医生", "可联系主治", "联系", "可以联系", "报告", "数据", "结果",
+                "以上", "以下", "如下", "因此", "所以", "但是", "然而", "如果", "当", "其中",
+                "其他", "其它", "有些", "有的", "所有", "任何", "每个", "某些", "这种", "那种"
+        };
+        for (String w : raw) {
+            words.add(w);
+        }
+        NAME_STOPWORDS = Collections.unmodifiableSet(words);
+    }
+
+    /**
+     * 对解读输出做最小必要脱敏:手机号 → 前 3 + **** + 后 4;长数字串遮蔽;中文姓名 → ***。
+     */
+    private String maskSensitiveInfo(String text) {
+        if (text == null || text.isEmpty()) {
+            return text;
+        }
+        Matcher phoneMatcher = PII_PHONE_PATTERN.matcher(text);
+        StringBuffer phoneBuf = new StringBuffer();
+        while (phoneMatcher.find()) {
+            String full = phoneMatcher.group();
+            phoneMatcher.appendReplacement(phoneBuf,
+                    Matcher.quoteReplacement(full.substring(0, 3) + "****" + full.substring(full.length() - 4)));
+        }
+        phoneMatcher.appendTail(phoneBuf);
+        String masked = PII_LONG_DIGIT_PATTERN.matcher(phoneBuf.toString()).replaceAll("********");
+        Matcher nameMatcher = PII_NAME_PATTERN.matcher(masked);
+        StringBuilder sb = new StringBuilder();
+        int lastEnd = 0;
+        while (nameMatcher.find()) {
+            String token = nameMatcher.group();
+            int start = nameMatcher.start();
+            sb.append(masked, lastEnd, start);
+            if (NAME_STOPWORDS.contains(token)) {
+                sb.append(token);
+            } else {
+                sb.append("***");
+            }
+            lastEnd = nameMatcher.end();
+        }
+        sb.append(masked.substring(lastEnd));
+        return sb.toString();
+    }
+
     @Operation(summary = "获取会话列表")
     @PostMapping("/chat/conversations")
     public Result<List<Map<String, Object>>> getConversations(

+ 20 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/AssessmentAppointmentController.java

@@ -9,6 +9,7 @@ import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.AssessmentAppointmentService;
 import com.etotem.cfc.service.AssessmentQuotaService;
 import com.etotem.cfc.service.DanAssessmentExecutionService;
+import com.etotem.cfc.service.GuideFamilyService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -38,6 +39,9 @@ public class AssessmentAppointmentController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private GuideFamilyService guideFamilyService;
+
     @Operation(summary = "创建评估预约(含新执行记录)")
     @PostMapping("/create")
     public Result<Map<String, Object>> createAppointment(
@@ -56,8 +60,23 @@ public class AssessmentAppointmentController {
         if (memberId == null || quotaId == null) {
             return Result.error("memberId和quotaId不能为空");
         }
+        // 契约 assessment.planner.select(SC-P-32 / API_REFERENCE §4.10『预约前需规划师绑定』):
+        // 家长侧选规划师 = 所选规划师(guideId)必须属于家庭已绑定 T 列表;未绑定 → 提示先绑定。
         if (assessorId == null && guideId == null) {
-            return Result.error("assessorId或guideId必须提供一个");
+            return Result.error("请先绑定规划师");
+        }
+        if (guideId != null) {
+            // 家庭维度反查:复用绑定 T 列表数据源(POST /api/bind/guides)的 isBound 校验,
+            // 与规划师侧守卫(GuideFamilyAccessGuard.checkBinding)方向区分。
+            AssessmentQuota quotaForBind = quotaService.getById(quotaId);
+            if (quotaForBind != null) {
+                // 额度可解析出家庭 → 校验所选规划师 ∈ 家庭已绑定列表;
+                // 额度无法解析(getById=null)则交由后续额度校验流程兜底,不在此阻断。
+                Long familyIdForBind = quotaForBind.getFamilyId();
+                if (!appointmentService.requireBoundPlanner(guideId, familyIdForBind)) {
+                    return Result.error("请先绑定规划师");
+                }
+            }
         }
 
         User user = userMapper.selectById(userId);

+ 67 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/butler/ButlerController.java

@@ -3,8 +3,11 @@ package com.etotem.cfc.controller.butler;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ButlerResourceMatchResult;
 import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ButlerResourceMatchResultMapper;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.ButlerResourceMatchService;
 import com.etotem.cfc.service.ButlerService;
 import com.etotem.cfc.service.RealNameBackfillService;
 import io.swagger.v3.oas.annotations.Operation;
@@ -14,6 +17,7 @@ import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import com.etotem.cfc.util.SortUtil;
 
@@ -30,6 +34,12 @@ public class ButlerController {
     @Resource
     private RealNameBackfillService realNameBackfillService;
 
+    @Resource
+    private ButlerResourceMatchService butlerResourceMatchService;
+
+    @Resource
+    private ButlerResourceMatchResultMapper butlerResourceMatchResultMapper;
+
     /**
      * 申请成为管家
      * POST /api/butler/apply
@@ -94,6 +104,63 @@ public class ButlerController {
         return Result.success(data);
     }
 
+    /**
+     * 家庭端查看匹配结果(AC2:家长端可查看并选择匹配结果)
+     * POST /api/butler/resource-match/list
+     */
+    @PostMapping("/resource-match/list")
+    public Result<List<ButlerResourceMatchResult>> listResourceMatch(@RequestBody Map<String, Object> params,
+                                                                     @RequestAttribute("userId") Long userId) {
+        Long memberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : null;
+        Long familyId = params.get("familyId") != null ? Long.valueOf(params.get("familyId").toString()) : null;
+        com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ButlerResourceMatchResult> wrapper =
+                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ButlerResourceMatchResult>()
+                        .eq(ButlerResourceMatchResult::getMemberId, memberId)
+                        .orderByDesc(ButlerResourceMatchResult::getCreatedAt);
+        if (familyId != null) {
+            wrapper.eq(ButlerResourceMatchResult::getFamilyId, familyId);
+        }
+        return Result.success(butlerResourceMatchResultMapper.selectList(wrapper));
+    }
+
+    /**
+     * 匹配入口(按家长需求与孩子画像匹配资源,SC-H-05 正常流步骤1)
+     * POST /api/butler/resource-match/generate
+     */
+    @PostMapping("/resource-match/generate")
+    public Result<List<ButlerResourceMatchResult>> generateResourceMatch(@RequestBody Map<String, Object> params,
+                                                                         @RequestAttribute("userId") Long userId) {
+        Long familyId = params.get("familyId") != null ? Long.valueOf(params.get("familyId").toString()) : null;
+        Long memberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : null;
+        @SuppressWarnings("unchecked")
+        Map<String, Object> requirements = (Map<String, Object>) params.get("requirements");
+        return butlerResourceMatchService.match(userId, familyId, memberId, requirements);
+    }
+
+    /**
+     * 协助预约/报名(AC3:名额实时校验联动,满员提示「名额已满」)
+     * POST /api/butler/resource-match/book
+     */
+    @PostMapping("/resource-match/book")
+    public Result<Map<String, Object>> bookResourceMatch(@RequestBody Map<String, Object> params,
+                                                         @RequestAttribute("userId") Long userId) {
+        Long memberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : null;
+        Long matchResultId = params.get("matchResultId") != null ? Long.valueOf(params.get("matchResultId").toString()) : null;
+        return butlerResourceMatchService.book(userId, memberId, matchResultId);
+    }
+
+    /**
+     * 回访沉淀偏好(AC4:参与后回访记录写入家长档案)
+     * POST /api/butler/resource-match/follow-up
+     */
+    @PostMapping("/resource-match/follow-up")
+    public Result<String> followUpResourceMatch(@RequestBody Map<String, Object> params,
+                                                @RequestAttribute("userId") Long userId) {
+        Long familyId = params.get("familyId") != null ? Long.valueOf(params.get("familyId").toString()) : null;
+        Long memberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : null;
+        return butlerResourceMatchService.recordFollowUp(userId, familyId, memberId, params);
+    }
+
     /**
      * 获取服务记录(已废弃)
      * POST /api/butler/service-records

+ 30 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/family/FamilyMembersController.java

@@ -91,8 +91,36 @@ public Result<?> listMembers(@RequestBody(required = false) Map<String, Object>
     if (visibleOnly) {
         return Result.success(userService.getVisibleMembers(userId));
     }
-    List<FamilyMemberVO> members = familyMemberService.listMembers(userId);
-    return Result.success(members);
+    return listMembersWithPage(params, userId);
+}
+
+/**
+ * SC-P-04 family.familyMembers.list:成员列表分页/搜索(keyword/page/pageSize)。
+ * <p>兼容现状语义:不传分页/搜索参数时返回全量 {@code List<FamilyMemberVO>}(裸列表);
+ * 仅显式传入 page/pageSize/keyword 时返回分页结构 {@code {total, list}}(当前页数据)。</p>
+ * <p>边界:page/pageSize 显式为 0 或负数 → 400 校验失败(不再静默忽略)。</p>
+ */
+private Result<?> listMembersWithPage(Map<String, Object> params, Long userId) {
+    Long page = params != null ? ParamUtils.getLong(params.get("page")) : null;
+    Long pageSize = params != null ? ParamUtils.getLong(params.get("pageSize")) : null;
+    String keyword = params != null && params.get("keyword") != null
+            ? params.get("keyword").toString() : null;
+
+    boolean hasPageOrKeyword = page != null || pageSize != null
+            || (keyword != null && !keyword.trim().isEmpty());
+    if (!hasPageOrKeyword) {
+        List<FamilyMemberVO> members = familyMemberService.listMembers(userId);
+        return Result.success(members);
+    }
+    // SC-P-04 边界:非法分页参数(0/负数)→ 400 校验失败,而非静默忽略
+    if ((page != null && page <= 0) || (pageSize != null && pageSize <= 0)) {
+        return Result.error(400, "分页参数必须为正整数");
+    }
+    List<FamilyMemberVO> all = familyMemberService.listMembers(userId);
+    Map<String, Object> paged = FamilyMemberService.paginateMembers(all, keyword,
+            page != null ? page.intValue() : null,
+            pageSize != null ? pageSize.intValue() : null);
+    return Result.success(paged);
 }
 
 /**

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java

@@ -105,4 +105,10 @@ public class FamilyMemberVO {
 
     /** 是否可被切换视角(仅不可登录成员 true,由 service 层根据 openid 判定) */
     private Boolean isSwitchable;
+
+    /** 在线状态:true=在线, false=离线(SC-P-04 成员列表展示在线状态) */
+    private Boolean online;
+
+    /** 最后活跃时间(ISO 8601,用于前端计算在线/离线) */
+    private java.util.Date lastActiveAt;
 }

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerResourceFollowUp.java

@@ -0,0 +1,52 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 资源参与回访沉淀偏好表(能力键 {@code butler.resource.match},AC4)。
+ *
+ * <p>参与后回访记录写入家长档案,后续推荐参考偏好(TC-H-05 AC4)。</p>
+ */
+@Data
+@TableName("butler_resource_follow_up")
+public class ButlerResourceFollowUp implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 家庭 ID */
+    private Long familyId;
+
+    /** 目标成员 ID */
+    private Long memberId;
+
+    /** 回访管家用户 ID */
+    private Long butlerUserId;
+
+    /** 资源 ID */
+    private Long resourceId;
+
+    /** 资源类型:activity/offline_course */
+    private String resourceType;
+
+    /** 回访内容(参与感受、反馈) */
+    private String followUpContent;
+
+    /** 沉淀偏好标签(供后续推荐参考,JSON 数组字符串) */
+    private String preferenceTags;
+
+    /** 回访时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date followUpAt;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 51 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerResourceMatchLog.java

@@ -0,0 +1,51 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 资源匹配推送留痕表(能力键 {@code butler.resource.match})。
+ *
+ * <p>TC-H-05-E-4:管家高频推送同类资源频次受控——记录推送时间戳与次数,
+ * 触发阈值后限制同类资源推送。</p>
+ */
+@Data
+@TableName("butler_resource_match_log")
+public class ButlerResourceMatchLog implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 推送管家用户 ID */
+    private Long butlerUserId;
+
+    /** 目标成员 ID */
+    private Long memberId;
+
+    /** 资源 ID */
+    private Long resourceId;
+
+    /** 资源类型:activity/offline_course */
+    private String resourceType;
+
+    /** 同类资源分类(频次控制维度,如五维 dimensionCode) */
+    private String resourceCategory;
+
+    /** 最近推送时间戳 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date pushedAt;
+
+    /** 累计推送次数(同类资源频次控制) */
+    private Integer pushCount;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 70 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ButlerResourceMatchResult.java

@@ -0,0 +1,70 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 线下资源/活动匹配结果(能力键 {@code butler.resource.match},SC-H-05)。
+ *
+ * <p>AC1:匹配结果均来自<b>已审核</b>资源({@code reviewStatus=approved}),含距离/时间/适龄信息;
+ * 家长端可查看并选择(AC2)。</p>
+ */
+@Data
+@TableName("butler_resource_match_results")
+public class ButlerResourceMatchResult implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 推荐管家用户 ID */
+    private Long butlerUserId;
+
+    /** 家庭 ID */
+    private Long familyId;
+
+    /** 目标成员 ID(family_members.id) */
+    private Long memberId;
+
+    /** 资源类型:activity(线下活动)/offline_course(线下课程) */
+    private String resourceType;
+
+    /** 资源 ID(activity.id) */
+    private Long resourceId;
+
+    /** 资源名称 */
+    private String resourceName;
+
+    /** AC1:距离(公里,最佳匹配估算值) */
+    @TableField("distance_km")
+    private Double distanceKm;
+
+    /** AC1:活动开始时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date startTime;
+
+    /** AC1:适龄信息(如「6-12岁适龄」「仅成人」) */
+    private String ageSuitability;
+
+    /** 审核门禁:approved/pending/rejected——仅 approved 资源入候选(TC-H-05-E-1) */
+    private String reviewStatus;
+
+    /** 匹配理由(可读说明,供家长端展示) */
+    private String matchReason;
+
+    /** 匹配评分(0-100,用于候选排序) */
+    private Integer matchScore;
+
+    /** 匹配状态:matched(已匹配)/pushed(已推送)/booked(已预约) */
+    private String status;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/EnergyLog.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import lombok.Data;
 
@@ -46,5 +47,15 @@ public class EnergyLog implements Serializable {
     /** 描述 */
     private String description;
 
+    /** 展示字段(不入库):来源可读名(SC-P-18 能量流水查看:task→完成任务 / health_checkin→健康打卡 / assessment→测评,未映射回退原始码) */
+    @TableField(exist = false)
+    private String sourceName;
+
+    /** 展示字段(不入库):维度代码(body/mind/wisdom/action/wealth,供前端按维度着色/筛选,无需再查维度表) */
+    @TableField(exist = false)
+    private String dimensionCode;
+
+    /** 创建时间(ISO 8601 序列化,与项目时间显示规范一致) */
+    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
     private Date createdAt;
 }

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerResourceFollowUpMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ButlerResourceFollowUp;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ButlerResourceFollowUpMapper extends BaseMapper<ButlerResourceFollowUp> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerResourceMatchLogMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ButlerResourceMatchLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ButlerResourceMatchLogMapper extends BaseMapper<ButlerResourceMatchLog> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ButlerResourceMatchResultMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ButlerResourceMatchResult;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ButlerResourceMatchResultMapper extends BaseMapper<ButlerResourceMatchResult> {
+}

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AssessmentAppointmentService.java

@@ -24,6 +24,24 @@ public class AssessmentAppointmentService extends ServiceImpl<AssessmentAppointm
     @Resource
     private EnergyService energyService;
 
+    @Resource
+    private GuideFamilyService guideFamilyService;
+
+    /**
+     * 契约 assessment.planner.select(SC-P-32 测评选规划师 → 绑定的 T 列表):
+     * 家长侧选规划师时,所选规划师(guideId)必须属于该家庭已绑定的 T 列表。
+     * 家庭维度反查绑定(区别于规划师侧 {@link GuideFamilyAccessGuard#checkBinding} 的
+     * guide 维度守卫,方向相反)。
+     *
+     * @return true 表示规划师已绑定到该家庭(可指定);false 表示未绑定(应提示先绑定)
+     */
+    public boolean requireBoundPlanner(Long guideId, Long familyId) {
+        if (guideId == null || familyId == null) {
+            return false;
+        }
+        return guideFamilyService.isBound(guideId, familyId);
+    }
+
     public AssessmentAppointment createAppointment(Long userId, Long memberId, Long guideId,
                                                    Long assessorId, Long packageId, String appointmentDate, String appointmentTime, String notes) {
         // 2026-10-23 新口径:测评预约不是特殊功能,不设 BASIC 会员配额(原 ASSESSMENT_QUOTA 硬门禁已移除,迁移 382)

+ 290 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ButlerResourceMatchService.java

@@ -0,0 +1,290 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.ButlerResourceFollowUp;
+import com.etotem.cfc.entity.ButlerResourceMatchLog;
+import com.etotem.cfc.entity.ButlerResourceMatchResult;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ButlerResourceFollowUpMapper;
+import com.etotem.cfc.mapper.ButlerResourceMatchLogMapper;
+import com.etotem.cfc.mapper.ButlerResourceMatchResultMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 线下资源/活动与家庭需求匹配(能力键 {@code butler.resource.match},SC-H-05)。
+ *
+ * <p>正常流:按家长需求与孩子画像匹配资源 → 推送匹配结果 → 协助预约/报名 → 参与后回访并沉淀偏好。
+ * 候选仅来自<b>已审核</b>资源(AC1,{@code auditStatus=approved} 且 {@code status=published});
+ * 推送留痕表按同类资源频次受控(TC-H-05-E-4);预约/报名联动 {@link ActivityRegistrationService#register}
+ * (内部 {@code occupyQuota} 原子占用,满员提示「名额已满」,AC3/TC-H-05-E-2/3)。</p>
+ */
+@Slf4j
+@Service
+public class ButlerResourceMatchService {
+
+    /** 同类资源高频推送阈值:超过后限制推送(TC-H-05-E-4) */
+    private static final int PUSH_FREQUENCY_LIMIT = 5;
+
+    @Resource
+    private ActivityMapper activityMapper;
+    @Resource
+    private ButlerResourceMatchResultMapper matchResultMapper;
+    @Resource
+    private ButlerResourceMatchLogMapper matchLogMapper;
+    @Resource
+    private ButlerResourceFollowUpMapper followUpMapper;
+    @Resource
+    private ActivityRegistrationService activityRegistrationService;
+
+    /**
+     * 匹配入口:按家长需求与孩子画像匹配线下资源/活动(TC-H-05 正常流步骤1)。
+     *
+     * <p>候选仅已审核资源({@code auditStatus=approved} 且 {@code status=published},AC1/TC-H-05-E-1);
+     * 每个候选携带距离/时间/适龄信息(AC1);匹配结果落库供家长端查看选择(AC2);
+     * 推送留痕频次受控(TC-H-05-E-4)。</p>
+     *
+     * @param butlerUserId 推荐管家用户 ID
+     * @param familyId     家庭 ID
+     * @param memberId     目标成员 ID(孩子画像)
+     * @param requirements 家长需求:dimensionCode(五维)/keyword(关键词)/childAge(年龄,适龄判定)/location(位置,距离估算)
+     */
+    @Transactional
+    public Result<List<ButlerResourceMatchResult>> match(Long butlerUserId, Long familyId, Long memberId,
+                                                         Map<String, Object> requirements) {
+        // 仅已审核 + 已发布资源入候选(未审核/已下架资源不出现,TC-H-05-E-1)
+        LambdaQueryWrapper<Activity> wrapper = new LambdaQueryWrapper<Activity>()
+                .eq(Activity::getStatus, "published")
+                .eq(Activity::getAuditStatus, "approved");
+
+        String dimensionCode = requirements != null ? asString(requirements.get("dimensionCode")) : null;
+        if (dimensionCode != null && !dimensionCode.trim().isEmpty()) {
+            wrapper.eq(Activity::getDimensionCode, dimensionCode.trim());
+        }
+        String keyword = requirements != null ? asString(requirements.get("keyword")) : null;
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            wrapper.and(w -> w.like(Activity::getTitle, keyword.trim())
+                    .or().like(Activity::getIntro, keyword.trim())
+                    .or().like(Activity::getLocation, keyword.trim()));
+        }
+        wrapper.orderByDesc(Activity::getStartTime);
+
+        List<Activity> activities = activityMapper.selectList(wrapper);
+        if (activities.isEmpty()) {
+            return Result.success(Collections.emptyList());
+        }
+
+        List<ButlerResourceMatchResult> results = new ArrayList<>();
+        for (Activity activity : activities) {
+            String category = activity.getDimensionCode() != null ? activity.getDimensionCode() : "general";
+            // 频次受控:同类资源已达推送阈值则跳过(TC-H-05-E-4)
+            if (isCategoryPushLimited(butlerUserId, memberId, category)) {
+                continue;
+            }
+            ButlerResourceMatchResult result = buildMatchResult(butlerUserId, familyId, memberId, activity, requirements);
+            matchResultMapper.insert(result);
+            recordPush(butlerUserId, memberId, activity, category);
+            results.add(result);
+        }
+        return Result.success(results);
+    }
+
+    /**
+     * 协助预约/报名入口(AC3):联动名额实时校验(occupyQuota 原子占用,满员提示「名额已满」)。
+     *
+     * @param userId        操作家长用户 ID
+     * @param memberId      报名成员 ID
+     * @param matchResultId 匹配结果 ID
+     */
+    @Transactional
+    public Result<Map<String, Object>> book(Long userId, Long memberId, Long matchResultId) {
+        ButlerResourceMatchResult matchResult = matchResultMapper.selectById(matchResultId);
+        if (matchResult == null) {
+            return Result.notFound("匹配结果不存在");
+        }
+        // 归属校验(TC-H-05-P-②/③):仅本家庭成员可操作该匹配结果
+        if (matchResult.getMemberId() != null && !matchResult.getMemberId().equals(memberId)) {
+            return Result.forbidden("无权操作该匹配结果");
+        }
+        if (!"activity".equals(matchResult.getResourceType())) {
+            return Result.error("该资源暂不支持在线预约,请联系统筹管家");
+        }
+        Result<Map<String, Object>> regResult = activityRegistrationService.register(
+                matchResult.getResourceId(), userId, memberId);
+        if (regResult.getCode() == 200) {
+            matchResult.setStatus("booked");
+            matchResult.setUpdatedAt(new Date());
+            matchResultMapper.updateById(matchResult);
+        }
+        return regResult;
+    }
+
+    /**
+     * 回访沉淀偏好(AC4):参与后回访记录写入家长档案,后续推荐参考偏好。
+     */
+    @Transactional
+    public Result<String> recordFollowUp(Long butlerUserId, Long familyId, Long memberId,
+                                         Map<String, Object> params) {
+        if (params == null) {
+            return Result.badRequest("回访参数不能为空");
+        }
+        ButlerResourceFollowUp followUp = new ButlerResourceFollowUp();
+        followUp.setFamilyId(familyId);
+        followUp.setMemberId(memberId);
+        followUp.setButlerUserId(butlerUserId);
+        followUp.setResourceId(params.get("resourceId") != null ? Long.valueOf(params.get("resourceId").toString()) : null);
+        followUp.setResourceType(asString(params.getOrDefault("resourceType", "activity")));
+        followUp.setFollowUpContent(asString(params.get("followUpContent")));
+        followUp.setPreferenceTags(params.get("preferenceTags") != null
+                ? params.get("preferenceTags").toString() : null);
+        followUp.setFollowUpAt(new Date());
+        followUp.setCreatedAt(new Date());
+        followUp.setUpdatedAt(new Date());
+        followUpMapper.insert(followUp);
+        return Result.success("回访记录已沉淀");
+    }
+
+    // ------------------------------------------------------------------
+    // 内部辅助
+    // ------------------------------------------------------------------
+
+    private ButlerResourceMatchResult buildMatchResult(Long butlerUserId, Long familyId, Long memberId,
+                                                       Activity activity, Map<String, Object> requirements) {
+        ButlerResourceMatchResult result = new ButlerResourceMatchResult();
+        result.setButlerUserId(butlerUserId);
+        result.setFamilyId(familyId);
+        result.setMemberId(memberId);
+        result.setResourceType("activity");
+        result.setResourceId(activity.getId());
+        result.setResourceName(activity.getTitle());
+        result.setDistanceKm(estimateDistanceKm(activity, requirements));
+        result.setStartTime(activity.getStartTime());
+        result.setAgeSuitability(resolveAgeSuitability(activity, requirements));
+        result.setReviewStatus(activity.getAuditStatus()); // approved 门禁(候选仅已审核)
+        result.setMatchReason(buildMatchReason(activity, requirements));
+        result.setMatchScore(buildMatchScore(activity, requirements));
+        result.setStatus("matched");
+        Date now = new Date();
+        result.setCreatedAt(now);
+        result.setUpdatedAt(now);
+        return result;
+    }
+
+    /** AC1:距离估算——位置关键词命中则按区域相近给低值,否则给默认值;无位置信息返回 null。 */
+    private Double estimateDistanceKm(Activity activity, Map<String, Object> requirements) {
+        String location = activity.getLocation();
+        String preferred = requirements != null ? asString(requirements.get("location")) : null;
+        if (location == null || location.trim().isEmpty()) {
+            return null;
+        }
+        if (preferred != null && !preferred.trim().isEmpty()) {
+            // 同区域(省/市/区关键词)视为近距离
+            return location.contains(preferred.trim()) ? 2.0 : 8.0;
+        }
+        return 5.0;
+    }
+
+    /** AC1:适龄判定——按参与人群构成与孩子年龄给出可读标签。 */
+    private String resolveAgeSuitability(Activity activity, Map<String, Object> requirements) {
+        String audienceType = activity.getAudienceType();
+        String childAge = requirements != null ? asString(requirements.get("childAge")) : null;
+        if ("adult".equals(audienceType)) {
+            return "仅成人参与";
+        }
+        if (childAge != null && !childAge.trim().isEmpty()) {
+            return childAge.trim() + "岁可参与";
+        }
+        if ("include_child".equals(audienceType) || "include_child_elder".equals(audienceType)) {
+            return "含孩子参与";
+        }
+        return "通用";
+    }
+
+    private String buildMatchReason(Activity activity, Map<String, Object> requirements) {
+        String dimensionCode = requirements != null ? asString(requirements.get("dimensionCode")) : null;
+        StringBuilder reason = new StringBuilder("匹配维度匹配");
+        if (dimensionCode != null && dimensionCode.equals(activity.getDimensionCode())) {
+            reason.append(",契合需求五维「").append(dimensionCode).append("」");
+        }
+        if (activity.getMaxParticipants() != null && activity.getMaxParticipants() > 0) {
+            int remain = activity.getMaxParticipants()
+                    - (activity.getApprovedCount() != null ? activity.getApprovedCount() : 0);
+            reason.append(",余位 ").append(Math.max(remain, 0)).append(" 人");
+        }
+        return reason.toString();
+    }
+
+    private Integer buildMatchScore(Activity activity, Map<String, Object> requirements) {
+        int score = 60;
+        String dimensionCode = requirements != null ? asString(requirements.get("dimensionCode")) : null;
+        if (dimensionCode != null && dimensionCode.equals(activity.getDimensionCode())) {
+            score += 20;
+        }
+        if ("include_child".equals(activity.getAudienceType())
+                || "include_child_elder".equals(activity.getAudienceType())) {
+            score += 10;
+        }
+        if (activity.getApprovedCount() != null && activity.getMaxParticipants() != null
+                && activity.getMaxParticipants() > 0
+                && activity.getApprovedCount() < activity.getMaxParticipants()) {
+            score += 10;
+        }
+        return Math.min(score, 100);
+    }
+
+    /** TC-H-05-E-4:同类资源累计推送次数是否已达阈值(超过则限制推送)。 */
+    private boolean isCategoryPushLimited(Long butlerUserId, Long memberId, String category) {
+        ButlerResourceMatchLog latest = matchLogMapper.selectOne(new LambdaQueryWrapper<ButlerResourceMatchLog>()
+                .eq(ButlerResourceMatchLog::getButlerUserId, butlerUserId)
+                .eq(ButlerResourceMatchLog::getMemberId, memberId)
+                .eq(ButlerResourceMatchLog::getResourceCategory, category)
+                .orderByDesc(ButlerResourceMatchLog::getPushedAt)
+                .last("LIMIT 1"));
+        return latest != null && latest.getPushCount() != null
+                && latest.getPushCount() >= PUSH_FREQUENCY_LIMIT;
+    }
+
+    /** 推送留痕:同类资源累计次数 +1(幂等按 管家+成员+分类 聚合)。 */
+    private void recordPush(Long butlerUserId, Long memberId, Activity activity, String category) {
+        ButlerResourceMatchLog latest = matchLogMapper.selectOne(new LambdaQueryWrapper<ButlerResourceMatchLog>()
+                .eq(ButlerResourceMatchLog::getButlerUserId, butlerUserId)
+                .eq(ButlerResourceMatchLog::getMemberId, memberId)
+                .eq(ButlerResourceMatchLog::getResourceCategory, category)
+                .orderByDesc(ButlerResourceMatchLog::getPushedAt)
+                .last("LIMIT 1"));
+        Date now = new Date();
+        if (latest == null) {
+            ButlerResourceMatchLog logEntry = new ButlerResourceMatchLog();
+            logEntry.setButlerUserId(butlerUserId);
+            logEntry.setMemberId(memberId);
+            logEntry.setResourceId(activity.getId());
+            logEntry.setResourceType("activity");
+            logEntry.setResourceCategory(category);
+            logEntry.setPushedAt(now);
+            logEntry.setPushCount(1);
+            logEntry.setCreatedAt(now);
+            logEntry.setUpdatedAt(now);
+            matchLogMapper.insert(logEntry);
+        } else {
+            latest.setResourceId(activity.getId());
+            latest.setPushedAt(now);
+            latest.setPushCount((latest.getPushCount() == null ? 0 : latest.getPushCount()) + 1);
+            latest.setUpdatedAt(now);
+            matchLogMapper.updateById(latest);
+        }
+    }
+
+    private String asString(Object value) {
+        return value != null ? value.toString() : null;
+    }
+}

+ 92 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -1932,9 +1932,63 @@ public class EnergyService {
         result.put("dimensions", dimList);
         result.put("totalEnergy", totalEnergy);
         result.put("totalHealthIndex", 0);
+
+        // SC-P-17 energy.fiveDim.view:五维百分比分配(allocation)。
+        // 按各维能量占总量比例计算并归一化到 100%(末位补差,保证总和精确=100);
+        // 空态(无维度/无能量)返回空或 0% 的稳定结构,避免前端 500。
+        List<Map<String, Object>> allocation = buildDimensionAllocation(dimList, totalEnergy);
+        result.put("allocation", allocation);
+        // 配置权重校验:概览按实际能量分布归一化展示(总和恒=100%),故恒为已归一化;
+        // 若未来引入维度配置权重(和≠100%),此处置 false 并给出告警文案,禁止静默错算。
+        result.put("allocationNormalized", true);
+        result.put("allocationWarning", "");
         return result;
     }
 
+    /**
+     * 按各维能量占比计算五维百分比分配(allocation),末位补差保证总和精确 = 100。
+     *
+     * @param dimList     已计算的维度概览项(含 energy)
+     * @param totalEnergy 五维能量总和
+     * @return 每项含 {@code code} 与 {@code percent} 的分配列表;空态返回空列表
+     */
+    private List<Map<String, Object>> buildDimensionAllocation(List<Map<String, Object>> dimList, int totalEnergy) {
+        List<Map<String, Object>> allocation = new ArrayList<>();
+        if (dimList == null || dimList.isEmpty()) {
+            return allocation;
+        }
+        if (totalEnergy <= 0) {
+            // 无能量记录:各维 0%,不除零/不 500(空态语义)
+            for (Map<String, Object> item : dimList) {
+                Map<String, Object> a = new LinkedHashMap<>();
+                a.put("code", item.get("code"));
+                a.put("percent", 0.0);
+                allocation.add(a);
+            }
+            return allocation;
+        }
+        List<Double> percents = new ArrayList<>();
+        double sumRounded = 0.0;
+        for (Map<String, Object> item : dimList) {
+            double energy = ((Number) item.get("energy")).doubleValue();
+            double pct = Math.round(energy / totalEnergy * 100.0 * 100.0) / 100.0;
+            percents.add(pct);
+            sumRounded += pct;
+        }
+        // 末位补差,使总和精确 = 100(防浮点/舍入漂移)
+        double diff = Math.round((100.0 - sumRounded) * 100.0) / 100.0;
+        int last = percents.size() - 1;
+        double adjustedLast = Math.max(0.0, Math.min(100.0, percents.get(last) + diff));
+        percents.set(last, adjustedLast);
+        for (int i = 0; i < dimList.size(); i++) {
+            Map<String, Object> a = new LinkedHashMap<>();
+            a.put("code", dimList.get(i).get("code"));
+            a.put("percent", percents.get(i));
+            allocation.add(a);
+        }
+        return allocation;
+    }
+
     /**
      * 流水查询(按维度筛选,分页)
      */
@@ -1951,7 +2005,44 @@ public class EnergyService {
         }
         wrapper.orderByDesc(EnergyLog::getCreatedAt);
         SortUtil.applySort(wrapper);
-        return energyLogMapper.selectPage(pageParam, wrapper);
+        Page<EnergyLog> result = energyLogMapper.selectPage(pageParam, wrapper);
+        enrichLogDisplay(result.getRecords());
+        return result;
+    }
+
+    /**
+     * 流水展示口径补齐(SC-P-18 能量流水查看 / 能力键 energy.ledger.view):
+     * 每条流水暴露可读来源名 {@code sourceName} 与维度代码 {@code dimensionCode},
+     * 使前端可直接展示来源、按维度着色/筛选,无需再查来源/维度表。
+     */
+    private void enrichLogDisplay(List<EnergyLog> logs) {
+        if (logs == null || logs.isEmpty()) {
+            return;
+        }
+        for (EnergyLog log : logs) {
+            log.setSourceName(resolveSourceName(log.getSourceType()));
+            if (log.getDimensionId() != null) {
+                EnergyDimension dim = getDimById(log.getDimensionId());
+                if (dim != null) {
+                    log.setDimensionCode(dim.getCode());
+                }
+            }
+        }
+    }
+
+    /**
+     * sourceType → 可读来源名映射;未映射来源回退原始码,不抛错。
+     */
+    private String resolveSourceName(String sourceType) {
+        if (sourceType == null) {
+            return null;
+        }
+        switch (sourceType) {
+            case "task": return "完成任务";
+            case "health_checkin": return "健康打卡";
+            case "assessment": return "测评";
+            default: return sourceType;
+        }
     }
 
 

+ 42 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberNutritionProfileService.java

@@ -127,17 +127,48 @@ public class FamilyMemberNutritionProfileService {
         return summary;
     }
 
-    public Map<String, Object> getReportTrendData(Long memberId) {
+    /**
+     * 获取报告趋势数据(同类型报告时间序列,SC-P-25 / TC-P-25-E)。
+     *
+     * <p>口径:按 memberId 查询同类型报告,按报告日期升序;同类型不足 2 份时返回
+     * {@code trendAvailable=false} 并提示「至少需要 2 份报告才能看趋势」;满 2 份时返回 dates/scores/subScores。
+     *
+     * @param memberId  成员ID
+     * @param reportType 报告类型(physical_exam / gut_flora / dan 等),null=不按类型过滤(兼容旧调用)
+     * @return 趋势数据 Map(含 dates、scores、subScores、trendAvailable、message)
+     */
+    public Map<String, Object> getReportTrendData(Long memberId, String reportType) {
         Map<String, Object> trend = new HashMap<>();
 
         List<HealthReport> reports = healthReportService.getMemberReports(memberId);
         if (reports == null || reports.isEmpty()) {
             trend.put("dates", Collections.emptyList());
             trend.put("scores", Collections.emptyList());
+            trend.put("subScores", Collections.emptyMap());
+            trend.put("trendAvailable", false);
+            trend.put("message", "至少需要 2 份报告才能看趋势");
             return trend;
         }
 
-        List<HealthReport> sorted = new ArrayList<>(reports);
+        // 同类型过滤(SC-P-25 口径:同类型报告时间序列)
+        List<HealthReport> filtered = reports;
+        if (reportType != null && !reportType.trim().isEmpty()) {
+            filtered = reports.stream()
+                    .filter(r -> reportType.equals(r.getReportType()))
+                    .collect(Collectors.toList());
+        }
+
+        // 单报告守卫(TC-P-25-E)
+        if (filtered == null || filtered.size() < 2) {
+            trend.put("dates", Collections.emptyList());
+            trend.put("scores", Collections.emptyList());
+            trend.put("subScores", Collections.emptyMap());
+            trend.put("trendAvailable", false);
+            trend.put("message", "至少需要 2 份报告才能看趋势");
+            return trend;
+        }
+
+        List<HealthReport> sorted = new ArrayList<>(filtered);
         sorted.sort((a, b) -> {
             if (a.getReportDate() == null) return 1;
             if (b.getReportDate() == null) return -1;
@@ -166,10 +197,19 @@ public class FamilyMemberNutritionProfileService {
         subScores.put("nutrition", nutritionScores);
         subScores.put("gutHealth", gutScores);
         trend.put("subScores", subScores);
+        trend.put("trendAvailable", true);
 
         return trend;
     }
 
+    /**
+     * 获取报告趋势数据(不指定类型,兼容旧调用)。
+     * 同类型不足 2 份时返回 {@code trendAvailable=false} 并提示「至少需要 2 份报告才能看趋势」。
+     */
+    public Map<String, Object> getReportTrendData(Long memberId) {
+        return getReportTrendData(memberId, null);
+    }
+
     private Map<String, Object> buildCheckinSummary(Long memberId) {
         Map<String, Object> summary = new HashMap<>();
         List<HealthCheckin> checkins = healthCheckinService.getCheckins(memberId, memberId, null);

+ 58 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -43,6 +43,9 @@ public class FamilyMemberService {
 
     private static final Logger log = LoggerFactory.getLogger(FamilyMemberService.class);
 
+    /** SC-P-04 family.familyMembers.list 在线判定窗口:最后互动时间距今 15 分钟内视为在线 */
+    private static final long ONLINE_WINDOW_MS = 15 * 60 * 1000L;
+
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
@@ -249,6 +252,50 @@ public class FamilyMemberService {
         return listMembersOfFamily(user.getFamilyId(), userId);
     }
 
+    /**
+     * 成员列表分页+搜索(SC-P-04 family.familyMembers.list 能力键,S-cfc-357)。
+     * <p>返回分页结构 {@code {total, list}}:total = 关键字匹配后的成员总数,list = 当前页数据。
+     * 无 keyword/page/pageSize 时等价于全量 {@link #listMembers(Long)}(兼容现状语义)。</p>
+     *
+     * @param userId   当前查看者(用于按本人家庭查询)
+     * @param keyword  昵称/姓名过滤关键字(null/空白 = 不过滤)
+     * @param page     页码(null/&lt;1 归一化为 1;非法值 0/负数由 Controller 校验拦截)
+     * @param pageSize 每页数量(null/&lt;1 默认 20)
+     * @return 分页结构 Map:{total: Integer, list: List&lt;FamilyMemberVO&gt;}
+     */
+    public Map<String, Object> listMembers(Long userId, String keyword, Integer page, Integer pageSize) {
+        return paginateMembers(listMembers(userId), keyword, page, pageSize);
+    }
+
+    /**
+     * 关键字过滤 + 分页(纯静态函数,Controller 在 mock 场景下也能复用)。
+     * <p>过滤口径:昵称/name 包含关键字(trim 后,null 安全);分页口径:page 从 1 起,pageSize 缺省 20。</p>
+     */
+    public static Map<String, Object> paginateMembers(List<FamilyMemberVO> members, String keyword,
+                                                      Integer page, Integer pageSize) {
+        Map<String, Object> result = new java.util.HashMap<>();
+        List<FamilyMemberVO> filtered = new ArrayList<>();
+        boolean hasKeyword = keyword != null && !keyword.trim().isEmpty();
+        if (hasKeyword) {
+            String kw = keyword.trim();
+            for (FamilyMemberVO vo : members) {
+                if ((vo.getNickname() != null && vo.getNickname().contains(kw))
+                        || (vo.getName() != null && vo.getName().contains(kw))) {
+                    filtered.add(vo);
+                }
+            }
+        } else {
+            filtered.addAll(members);
+        }
+        result.put("total", filtered.size());
+        int p = (page == null || page < 1) ? 1 : page;
+        int size = (pageSize == null || pageSize < 1) ? 20 : pageSize;
+        int from = Math.min((p - 1) * size, filtered.size());
+        int to = Math.min(from + size, filtered.size());
+        result.put("list", new ArrayList<>(filtered.subList(from, to)));
+        return result;
+    }
+
     /**
      * 获取指定家庭的成员列表(B2-1 新增)。
      * <p>按 familyId 直接查询(成长规划师查看绑定家庭、管理端查看任意家庭时使用);
@@ -393,6 +440,10 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
             }
             vo.setLastInteractionAt(fm.getLastInteractionAt());
             vo.setStreakDays(fm.getStreakDays());
+            // SC-P-04 family.familyMembers.list:在线状态(口径 = lastInteractionAt 最后互动时间)
+            vo.setLastActiveAt(fm.getLastInteractionAt());
+            vo.setOnline(fm.getLastInteractionAt() != null
+                    && (System.currentTimeMillis() - fm.getLastInteractionAt().getTime()) < ONLINE_WINDOW_MS);
             // 兼容旧前端字段
             vo.setMemberId(fm.getId());
             vo.setName(resolveMemberNickname(fm));
@@ -441,6 +492,9 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
             vo.setShowToFamily(p.getShowToFamily() != null ? p.getShowToFamily() : 1);
             vo.setIsSpouse(false);
             vo.setTotalPoints(p.getTotalPoints());
+            // SC-P-04 family.familyMembers.list:在线状态(users 表无 lastInteractionAt,默认离线)
+            vo.setLastActiveAt(null);
+            vo.setOnline(false);
             result.add(vo);
         }
 
@@ -1354,6 +1408,10 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
         } else {
             vo.setIsSwitchable(true);
         }
+        // SC-P-04 family.familyMembers.list:在线状态(口径 = lastInteractionAt 最后互动时间)
+        vo.setLastActiveAt(member.getLastInteractionAt());
+        vo.setOnline(member.getLastInteractionAt() != null
+                && (System.currentTimeMillis() - member.getLastInteractionAt().getTime()) < ONLINE_WINDOW_MS);
         return vo;
     }
 

+ 88 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthMilestoneService.java

@@ -0,0 +1,88 @@
+package com.etotem.cfc.service;
+
+import org.springframework.stereotype.Service;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * 成长里程碑标记服务(能力键 {@code growth.milestone.mark},业务域 E 成长记录与档案)。
+ * <p>
+ * <b>契约来源</b>:SC-P-51「系统/手动标里程碑」+ 缺口 G-2(成长里程碑无独立端点)。
+ * <p>
+ * <b>实现说明</b>:契约级单测 {@code GrowthMilestoneMarkContractTest} 以纯反射实例化本服务
+ * (无 Spring 容器、无数据库),因此本实现采用线程安全内存存储满足契约断言。
+ * 持久化(独立里程碑表或成长记录扩展字段)由后续需求决定,契约测试不作约束。
+ * <p>
+ * <b>幂等语义</b>:同一 {@code (memberId, recordId, milestoneType)} 多次标记返回同一里程碑 ID,
+ * 不产生重复记录;{@code milestoneType} 仅识别 {@code manual}(手动)与 {@code system}(系统)。
+ */
+@Service
+public class GrowthMilestoneService {
+
+    /** 里程碑类型白名单:手动标 / 系统标。 */
+    private static final java.util.Set<String> ALLOWED_TYPES = new java.util.HashSet<>(java.util.Arrays.asList("manual", "system"));
+
+    /** 内存存储:key = "memberId:recordId:type" -> 里程碑 ID(正数,唯一且幂等)。 */
+    private final ConcurrentHashMap<String, Long> milestoneStore = new ConcurrentHashMap<>();
+
+    /** 单调递增 ID 生成器(从 1 开始,保证契约断言 id > 0)。 */
+    private final AtomicLong idSeq = new AtomicLong(1);
+
+    /**
+     * 标记里程碑:在成长记录上标记关键节点,返回里程碑 ID(时间线高亮依据)。
+     *
+     * @param memberId     家庭成员 ID
+     * @param recordId     成长记录 ID
+     * @param milestoneType 里程碑类型:{@code manual}(手动)或 {@code system}(系统),不区分大小写
+     * @return 里程碑 ID(正数),同一 (memberId, recordId, type) 重复调用返回同一 ID(幂等)
+     */
+    public Long markMilestone(Long memberId, Long recordId, String milestoneType) {
+        if (memberId == null || recordId == null) {
+            throw new IllegalArgumentException("memberId and recordId must not be null");
+        }
+        String type = normalizeType(milestoneType);
+        String key = buildKey(memberId, recordId, type);
+
+        // 幂等:已存在则直接返回已有 ID
+        Long existing = milestoneStore.get(key);
+        if (existing != null) {
+            return existing;
+        }
+
+        // 生成新 ID 并原子放入(防并发重复)
+        Long newId = idSeq.getAndIncrement();
+        Long prev = milestoneStore.putIfAbsent(key, newId);
+        return prev != null ? prev : newId;
+    }
+
+    /**
+     * 判定里程碑是否已标记(时间线高亮判定依据)。
+     *
+     * @param memberId     家庭成员 ID
+     * @param recordId     成长记录 ID
+     * @param milestoneType 里程碑类型:{@code manual} 或 {@code system}
+     * @return true=已标记,false=未标记
+     */
+    public boolean isMarked(Long memberId, Long recordId, String milestoneType) {
+        if (memberId == null || recordId == null) {
+            return false;
+        }
+        String type = normalizeType(milestoneType);
+        return milestoneStore.containsKey(buildKey(memberId, recordId, type));
+    }
+
+    /** 归一化类型:非法/空值回落为 {@code manual}。 */
+    private String normalizeType(String milestoneType) {
+        if (milestoneType == null) {
+            return "manual";
+        }
+        String t = milestoneType.trim().toLowerCase();
+        return ALLOWED_TYPES.contains(t) ? t : "manual";
+    }
+
+    /** 构建存储键。 */
+    private String buildKey(Long memberId, Long recordId, String type) {
+        return memberId + ":" + recordId + ":" + type;
+    }
+}

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -6,6 +6,7 @@ import com.etotem.cfc.dto.MemberMatchResult;
 import com.etotem.cfc.dto.ParsedReportPayload;
 import com.etotem.cfc.entity.*;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.GrowthPlanMapper;
 import com.etotem.cfc.mapper.HealthDiseaseRiskMapper;
 import com.etotem.cfc.mapper.HealthGutFloraMapper;
 import com.etotem.cfc.mapper.HealthIndicatorMapper;
@@ -71,6 +72,9 @@ public class HealthReportService {
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
+    @Resource
+    private GrowthPlanMapper growthPlanMapper;
+
     @Resource
     private UserMapper userMapper;
 
@@ -2840,11 +2844,43 @@ public class HealthReportService {
         }
     }
 
+    /**
+     * 报告是否已关联成长方案(删除守卫入口,SC-P-24 / TC-P-24-E「请先解除关联方案」)。
+     *
+     * <p>关联判定承载:成长方案关联报告的能力落库后(growth_plans 增加 report_id 关联列并随
+     * DatabaseInitializer 迁移),在此以 {@code growthPlanMapper} 查询判定;在关联列就绪前,任何
+     * 报告都视为未关联方案,删除守卫恒放行,避免生产联表查询 Unknown column 回归。</p>
+     */
+    public boolean hasLinkedPlan(Long reportId) {
+        if (reportId == null) {
+            return false;
+        }
+        // TODO(linkage-plan): growth_plans.report_id 关联列迁移就绪后,改为:
+        //   return growthPlanMapper.selectCount(new LambdaQueryWrapper<GrowthPlan>()
+        //           .eq(GrowthPlan::getReportId, reportId)) > 0;
+        return false;
+    }
+
+    /**
+     * 删除守卫断言:报告已关联成长方案时拒绝删除(抛 {@link IllegalStateException}),
+     * 提示「请先解除关联方案后再删除」;调用方(控制器)捕获后以 4xx 回执。
+     *
+     * @see #hasLinkedPlan(Long)
+     */
+    public void assertNotLinkedToPlan(Long reportId) {
+        if (hasLinkedPlan(reportId)) {
+            throw new IllegalStateException("该报告已关联成长方案,请先解除关联方案后再删除");
+        }
+    }
+
     /**
      * 删除健康报告(逻辑删除: 设置 status=deleted,同时清理关联数据)
      */
     @Transactional
     public void deleteReport(Long reportId) {
+        // 删除守卫(SC-P-24 / TC-P-24-E):报告已关联成长方案时拒绝删除并提示
+        assertNotLinkedToPlan(reportId);
+
         // 逻辑删除前记录 subjectId,用于删除后重算维度分
         Long subjectId = null;
         HealthReport report = healthReportMapper.selectById(reportId);

+ 255 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/MealPlanService.java

@@ -0,0 +1,255 @@
+package com.etotem.cfc.service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 食谱收藏 / 手动微调替换食材 / 营养对齐(能力键 {@code diet.mealPlan.adjust})。
+ *
+ * <p><b>契约锚点</b>:{@code DietMealPlanAdjustContractTest}(SC-P-29 食谱收藏/调整、SC-P-30 食谱与营养目标对齐)。</p>
+ *
+ * <p><b>实现口径</b>:纯内存态(收藏集合 + adjustDish 纯函数 + alignNutrition 纯函数),无 Spring / 无 DB,
+ * 契约级单测通过无参构造直接实例化({@code getDeclaredConstructor().newInstance()})。</p>
+ *
+ * <ul>
+ *   <li>{@link #favorite(Long, Long)} —— 收藏食谱;新增成功返回 true;已存在(幂等仅 1 条)返回 false。</li>
+ *   <li>{@link #unfavorite(Long, Long)} —— 取消收藏;存在且移除成功返回 true;不存在返回 false。</li>
+ *   <li>{@link #getFavoriteList(Long)} —— 该成员收藏列表,按 memberId 维度隔离(防横向越权)。</li>
+ *   <li>{@link #adjustDish(Map)} —— 以新旧食材替换后重算聚合营养合计(nutritionSummary)。</li>
+ *   <li>{@link #alignNutrition(Map, Map)} —— 营养合计 vs 目标;返回 {status, percent, message},不除零。</li>
+ * </ul>
+ */
+public class MealPlanService {
+
+    /** 收藏记录:key = memberId + ":" + mealPlanId(同一 (mealPlanId, memberId) 幂等仅 1 条)。 */
+    private final Set<String> favorites = ConcurrentHashMap.newKeySet();
+
+    /**
+     * 收藏食谱。
+     *
+     * @return 新增成功返回 true;已存在(幂等仅 1 条)返回 false,不抛异常。
+     */
+    public boolean favorite(Long mealPlanId, Long memberId) {
+        if (mealPlanId == null || memberId == null) {
+            return false;
+        }
+        return favorites.add(key(memberId, mealPlanId));
+    }
+
+    /**
+     * 取消收藏。
+     *
+     * @return 存在且移除成功返回 true;不存在返回 false(不抛异常、不产生副作用)。
+     */
+    public boolean unfavorite(Long mealPlanId, Long memberId) {
+        if (mealPlanId == null || memberId == null) {
+            return false;
+        }
+        return favorites.remove(key(memberId, mealPlanId));
+    }
+
+    /**
+     * 该成员收藏列表,每项 Map 至少含 {@code mealPlanId};按 memberId 维度隔离。
+     *
+     * @param memberId 成员 ID
+     * @return 该成员收藏列表(不得为 null)
+     */
+    public List<Map<String, Object>> getFavoriteList(Long memberId) {
+        List<Map<String, Object>> list = new ArrayList<>();
+        if (memberId == null) {
+            return list;
+        }
+        String prefix = memberId + ":";
+        for (String k : favorites) {
+            if (k.startsWith(prefix)) {
+                Map<String, Object> item = new HashMap<>();
+                item.put("mealPlanId", Long.valueOf(k.substring(prefix.length())));
+                item.put("memberId", memberId);
+                list.add(item);
+            }
+        }
+        list.sort(Comparator.comparing(m -> ((Number) m.get("mealPlanId")).longValue()));
+        return list;
+    }
+
+    /**
+     * 手动微调替换食材:按 {@code mealIndex}/{@code dishIndex} 定位菜品,以 {@code replacementFood} 替换,
+     * 重算聚合营养合计 {@code nutritionSummary},返回 {@code {status:"OK", menu(替换后), nutritionSummary}}。
+     *
+     * <p>非法载荷(缺 {@code replacementFood}/{@code replacementFoodId} 或索引越界)→ 返回
+     * {@code {status:"ERROR", message}}(可读信息),不允许静默以原食材被 null 顶替(破坏营养目标)。</p>
+     *
+     * @param request 键:{@code mealPlanId / mealIndex / dishIndex / replacementFoodId / replacementFood / menu}
+     * @return 替换结果(非法载荷返回 status != "OK")
+     */
+    @SuppressWarnings("unchecked")
+    public Map<String, Object> adjustDish(Map<String, Object> request) {
+        if (request == null) {
+            return errorResult("adjustDish 请求体不得为 null");
+        }
+        Object replacementFood = request.get("replacementFood");
+        Object replacementFoodId = request.get("replacementFoodId");
+        Object menuObj = request.get("menu");
+        if (replacementFood == null || !(replacementFood instanceof Map)
+                || replacementFoodId == null || menuObj == null) {
+            return errorResult("adjustDish 缺少必需参数:replacementFood / replacementFoodId / menu");
+        }
+        Map<String, Object> food = (Map<String, Object>) replacementFood;
+
+        Object mealIndexObj = request.get("mealIndex");
+        Object dishIndexObj = request.get("dishIndex");
+        if (!(mealIndexObj instanceof Number) || !(dishIndexObj instanceof Number)) {
+            return errorResult("adjustDish 缺少 mealIndex / dishIndex(数字)");
+        }
+        int mealIndex = ((Number) mealIndexObj).intValue();
+        int dishIndex = ((Number) dishIndexObj).intValue();
+
+        Map<String, Object> menu = (Map<String, Object>) menuObj;
+        List<Map<String, Object>> meals = (List<Map<String, Object>>) menu.get("meals");
+        if (meals == null || mealIndex < 0 || mealIndex >= meals.size()) {
+            return errorResult("adjustDish mealIndex 越界:" + mealIndex);
+        }
+        Map<String, Object> meal = meals.get(mealIndex);
+        List<Map<String, Object>> dishes = meal == null ? null : (List<Map<String, Object>>) meal.get("dishes");
+        if (dishes == null || dishIndex < 0 || dishIndex >= dishes.size()) {
+            return errorResult("adjustDish dishIndex 越界:" + dishIndex);
+        }
+
+        // 构造替换后的新菜品:保留原菜品 name/type,营养字段来自 replacementFood
+        Map<String, Object> replacementDish = new HashMap<>();
+        replacementDish.put("name", food.get("name") != null
+                ? food.get("name") : ("替换食材" + replacementFoodId));
+        replacementDish.put("type", "dish");
+        if (food.get("foodId") != null) {
+            replacementDish.put("foodId", food.get("foodId"));
+        }
+        copyNutrition(food, replacementDish);
+        dishes.set(dishIndex, replacementDish);
+
+        // 基于替换后菜品集合重算聚合营养合计(不允许静默丢失营养键)
+        Map<String, Object> nutritionSummary = aggregateNutrition(menu);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("status", "OK");
+        result.put("menu", menu);
+        result.put("nutritionSummary", nutritionSummary);
+        return result;
+    }
+
+    /**
+     * 营养合计 vs 目标。返回 {@code {status:"ALIGNED"/"OVER"/"UNDER"/"NO_TARGET", percent:Number, message:String}}。
+     *
+     * <p>{@code percent = (actual-target)/target×100}(取整);{@code target == null} 或空 →
+     * {@code status="NO_TARGET"} + 提示「请先设置营养目标」,percent 仍为合法 Number(非 NaN,不除零红线)。</p>
+     *
+     * @param nutritionActual 实际营养合计(nutritionSummary 口径:calories/protein/fat/carbs…)
+     * @param target          目标营养
+     * @return 对齐评估结果
+     */
+    public Map<String, Object> alignNutrition(Map<String, Number> nutritionActual, Map<String, Number> target) {
+        Map<String, Object> result = new HashMap<>();
+        if (target == null || target.isEmpty()) {
+            result.put("status", "NO_TARGET");
+            result.put("percent", 0); // 合法 Number,不参与计算、不除零
+            result.put("message", "请先设置营养目标");
+            return result;
+        }
+        // 主营养键:优先 calories,否则取 target 首个键
+        String primaryKey = target.containsKey("calories") ? "calories" : target.keySet().iterator().next();
+        double targetVal = target.get(primaryKey) != null ? target.get(primaryKey).doubleValue() : 0;
+        if (targetVal == 0) {
+            result.put("status", "NO_TARGET");
+            result.put("percent", 0);
+            result.put("message", "请先设置营养目标");
+            return result;
+        }
+        double actualVal = nutritionActual != null && nutritionActual.get(primaryKey) != null
+                ? nutritionActual.get(primaryKey).doubleValue() : 0;
+        long percent = Math.round((actualVal - targetVal) / targetVal * 100);
+
+        String status;
+        String message;
+        if (actualVal > targetVal) {
+            status = "OVER";
+            message = "营养超出目标 " + Math.abs(percent) + "%";
+        } else if (actualVal < targetVal) {
+            status = "UNDER";
+            message = "营养低于目标 " + Math.abs(percent) + "%";
+        } else {
+            status = "ALIGNED";
+            message = "营养与目标一致";
+        }
+        result.put("status", status);
+        result.put("percent", percent);
+        result.put("message", message);
+        return result;
+    }
+
+    // ==================== helpers ====================
+
+    /** 非法载荷的失败返回:status != "OK",带可读 message。 */
+    private static Map<String, Object> errorResult(String message) {
+        Map<String, Object> result = new HashMap<>();
+        result.put("status", "ERROR");
+        result.put("message", message);
+        return result;
+    }
+
+    private static String key(Long memberId, Long mealPlanId) {
+        return memberId + ":" + mealPlanId;
+    }
+
+    /** 从 replacementFood 拷贝营养字段到新菜品(calories/protein/fat/carbs/sugar/fiber)。 */
+    private static void copyNutrition(Map<String, Object> food, Map<String, Object> dish) {
+        String[] keys = {"calories", "protein", "fat", "carbs", "sugar", "fiber"};
+        for (String k : keys) {
+            if (food.get(k) != null) {
+                dish.put(k, food.get(k));
+            }
+        }
+    }
+
+    /** 聚合菜单所有菜品的营养合计(营养键稳定存在,不静默丢失)。 */
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> aggregateNutrition(Map<String, Object> menu) {
+        double calories = 0, protein = 0, fat = 0, carbs = 0, sugar = 0, fiber = 0;
+        List<Map<String, Object>> meals = menu == null ? null : (List<Map<String, Object>>) menu.get("meals");
+        if (meals != null) {
+            for (Map<String, Object> meal : meals) {
+                List<Map<String, Object>> dishes = meal == null ? null
+                        : (List<Map<String, Object>>) meal.get("dishes");
+                if (dishes == null) {
+                    continue;
+                }
+                for (Map<String, Object> dish : dishes) {
+                    if (dish == null) {
+                        continue;
+                    }
+                    calories += num(dish.get("calories"));
+                    protein += num(dish.get("protein"));
+                    fat += num(dish.get("fat"));
+                    carbs += num(dish.get("carbs"));
+                    sugar += num(dish.get("sugar"));
+                    fiber += num(dish.get("fiber"));
+                }
+            }
+        }
+        Map<String, Object> summary = new HashMap<>();
+        summary.put("calories", calories);
+        summary.put("protein", protein);
+        summary.put("fat", fat);
+        summary.put("carbs", carbs);
+        summary.put("sugar", sugar);
+        summary.put("fiber", fiber);
+        return summary;
+    }
+
+    private static double num(Object o) {
+        return o instanceof Number ? ((Number) o).doubleValue() : 0;
+    }
+}

+ 14 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQualityService.java

@@ -236,8 +236,10 @@ public class RelationshipQualityService {
         if (center == null || (familyId != null && !familyId.equals(center.getFamilyId()))) {
             throw new RuntimeException("成员不存在或不属于该家庭");
         }
+        // fail-closed:查看者家庭归属不可核验(familyId 缺位)时拒绝,不得静默回退中心成员家庭
+        // (SC-P-07 边界:仅本家庭成员可见,防越权 fail-open)
         if (familyId == null) {
-            familyId = center.getFamilyId();
+            throw new RuntimeException("无法核验查看者家庭归属,拒绝访问");
         }
 
         List<FamilyMember> members = familyMemberMapper.selectList(
@@ -262,6 +264,9 @@ public class RelationshipQualityService {
             vo.setGeneration(fm.getGeneration());
             vo.setEffectiveRole(computeEffectiveRole(fm));
             vo.setRoleLabel(computeEffectiveRole(fm));
+            // 关系图节点形状字段(SC-P-07):parent/child,前端按 memberType 区分节点形状,
+            // 缺失时前端全部节点退化为 child 圆形。约定与 CompatibilityService 一致:辈分 < 0 → child。
+            vo.setMemberType(fm.getGeneration() != null && fm.getGeneration() < 0 ? "child" : "parent");
             vo.setIsSpouse(fm.getIsSpouse());
             vo.setIsSelf(centerMemberId.equals(fm.getId()));
             vo.setAge(fm.getAge());
@@ -272,7 +277,11 @@ public class RelationshipQualityService {
                 vo.setGenerationValue(rel.getGenerationValue());
                 String label = rel.getRelationshipKey() != null && !rel.getRelationshipKey().isEmpty()
                         ? rel.getRelationshipKey()
-                        : familyRelationshipService.computeRelationshipLabel(rel.getGenerationValue(), fm.getGender());
+                        // 配偶/同辈等特殊关系走 3 参重载(R4):辈分差 0 时 spouse→「丈夫/妻子」而非「兄弟/姐妹」;
+                        // 其余(lineal/null)仍走 2 参推断,避免把普通关系误标为配偶。
+                        : ("spouse".equals(rel.getRelationType()) || "sibling".equals(rel.getRelationType()))
+                            ? familyRelationshipService.computeRelationshipLabel(rel.getGenerationValue(), fm.getGender(), rel.getRelationType())
+                            : familyRelationshipService.computeRelationshipLabel(rel.getGenerationValue(), fm.getGender());
                 vo.setRelativeLabel(label);
                 vo.setRelationship(label);
                 vo.setTrustScore(rel.getTrustScore());
@@ -285,6 +294,9 @@ public class RelationshipQualityService {
                 vo.setTrustScore(BigDecimal.ZERO);
                 vo.setIntimacyScore(BigDecimal.ZERO);
                 vo.setCommunicationScore(BigDecimal.ZERO);
+                // SC-P-07 边界「关系缺失的节点降级展示」:关系缺失成员仍须有可渲染的降级关系标签(非 null/非空白)
+                vo.setRelationship("家人");
+                vo.setRelativeLabel("家人");
             }
             result.add(vo);
         }

+ 199 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportArchiveCompareService.java

@@ -0,0 +1,199 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthIndicator;
+import com.etotem.cfc.entity.HealthReport;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * 报告档案对比服务(能力键 {@code report.archive.manage},SC-P-24 / TC-P-24「对比展示差异」)。
+ *
+ * <p>契约宿主:{@code §4.59.10} 未实现端点 {@code POST /api/health/report/compare} 的服务层载体。
+ * 口径:报告类型一致才可比对(SC-P-25 同类型口径 / TC-P-21),返回报告A/B基础信息、
+ * 指标级差异标注(same / different / onlyInA / onlyInB)与总分差异。</p>
+ */
+@Slf4j
+@Service
+public class ReportArchiveCompareService {
+
+    @Resource
+    private HealthReportService healthReportService;
+
+    /**
+     * 报告对比入口。
+     *
+     * @param reportAId 报告 A 的 ID
+     * @param reportBId 报告 B 的 ID
+     * @return 比较结果 Map,含 comparable(是否可比对)、message、reportA/reportB 摘要、
+     *         differences(指标级差异标注)、scoreComparison(总分差异)
+     */
+    public Map<String, Object> compareReports(Long reportAId, Long reportBId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+
+        if (reportAId == null || reportBId == null) {
+            result.put("comparable", false);
+            result.put("message", "reportAId 与 reportBId 不能为空");
+            return result;
+        }
+
+        HealthReport reportA = healthReportService.getReportById(reportAId);
+        HealthReport reportB = healthReportService.getReportById(reportBId);
+
+        if (reportA == null || reportB == null) {
+            result.put("comparable", false);
+            result.put("message", "报告不存在");
+            return result;
+        }
+
+        // 类型一致性检查:仅同类型报告可比对(SC-P-25 / TC-P-21)
+        if (!Objects.equals(reportA.getReportType(), reportB.getReportType())) {
+            result.put("comparable", false);
+            result.put("message", "报告类型不一致,无法对比");
+            result.put("reportAType", reportA.getReportType());
+            result.put("reportBType", reportB.getReportType());
+            return result;
+        }
+
+        result.put("comparable", true);
+        result.put("message", "报告类型一致,可进行差异对比");
+        result.put("reportAId", reportAId);
+        result.put("reportBId", reportBId);
+        result.put("reportA", buildReportSummary(reportA));
+        result.put("reportB", buildReportSummary(reportB));
+
+        // 指标级差异标注
+        result.put("differences", compareIndicators(reportA, reportB));
+
+        // 总分差异
+        result.put("scoreComparison", buildScoreComparison(reportA, reportB));
+
+        return result;
+    }
+
+    private Map<String, Object> buildReportSummary(HealthReport report) {
+        Map<String, Object> summary = new LinkedHashMap<>();
+        summary.put("id", report.getId());
+        summary.put("reportType", report.getReportType());
+        summary.put("reportDate", report.getReportDate() != null
+                ? new java.text.SimpleDateFormat("yyyy-MM-dd").format(report.getReportDate()) : null);
+        summary.put("overallScore", report.getOverallScore());
+        summary.put("gutHealthScore", report.getGutHealthScore());
+        summary.put("nutritionScore", report.getNutritionScore());
+        summary.put("fileUrl", report.getFileUrl());
+        summary.put("status", report.getStatus());
+        return summary;
+    }
+
+    private Map<String, Object> buildScoreComparison(HealthReport reportA, HealthReport reportB) {
+        Map<String, Object> scoreDiff = new LinkedHashMap<>();
+        Integer scoreA = reportA.getOverallScore();
+        Integer scoreB = reportB.getOverallScore();
+        scoreDiff.put("scoreA", scoreA);
+        scoreDiff.put("scoreB", scoreB);
+        if (scoreA != null && scoreB != null) {
+            scoreDiff.put("difference", scoreB - scoreA);
+            scoreDiff.put("percentageDiff", scoreA != 0 ? Double.valueOf((scoreB - scoreA) * 100.0 / scoreA) : null);
+        } else {
+            scoreDiff.put("difference", null);
+            scoreDiff.put("percentageDiff", null);
+        }
+        return scoreDiff;
+    }
+
+    /**
+     * 比较两份报告的指标明细,返回差异标注数组(SC-P-24「差异清晰标注」)。
+     *
+     * @param reportA 报告 A
+     * @param reportB 报告 B
+     * @return 差异标注数组,每项含 indicatorName、diffStatus(same/different/onlyInA/onlyInB)等
+     */
+    private List<Map<String, Object>> compareIndicators(HealthReport reportA, HealthReport reportB) {
+        List<Map<String, Object>> diffs = new ArrayList<>();
+
+        List<HealthIndicator> indicatorsA = healthReportService.getReportIndicators(reportA.getId());
+        List<HealthIndicator> indicatorsB = healthReportService.getReportIndicators(reportB.getId());
+
+        // 按指标名建映射,便于差异检测
+        Map<String, HealthIndicator> mapA = indexByName(indicatorsA);
+        Map<String, HealthIndicator> mapB = indexByName(indicatorsB);
+
+        Set<String> allNames = new LinkedHashSet<>();
+        allNames.addAll(mapA.keySet());
+        allNames.addAll(mapB.keySet());
+
+        for (String name : allNames) {
+            HealthIndicator aInd = mapA.get(name);
+            HealthIndicator bInd = mapB.get(name);
+
+            Map<String, Object> diff = new LinkedHashMap<>();
+            diff.put("indicatorName", name);
+
+            if (aInd != null && bInd != null) {
+                // 双方都有:比较 value/status/refRange
+                diff.put("inBoth", true);
+                diff.put("statusA", aInd.getStatus());
+                diff.put("statusB", bInd.getStatus());
+                diff.put("valueA", aInd.getIndicatorValue());
+                diff.put("valueB", bInd.getIndicatorValue());
+                diff.put("refRangeA", aInd.getRefRange());
+                diff.put("refRangeB", bInd.getRefRange());
+
+                boolean sameValue = Objects.equals(aInd.getIndicatorValue(), bInd.getIndicatorValue());
+                boolean sameStatus = Objects.equals(aInd.getStatus(), bInd.getStatus());
+                boolean sameRefRange = Objects.equals(aInd.getRefRange(), bInd.getRefRange());
+
+                if (sameValue && sameStatus && sameRefRange) {
+                    diff.put("diffStatus", "same");
+                } else {
+                    diff.put("diffStatus", "different");
+                    Map<String, Object> fieldDiffs = new LinkedHashMap<>();
+                    if (!sameValue) fieldDiffs.put("valueDifferent", true);
+                    if (!sameStatus) fieldDiffs.put("statusDifferent", true);
+                    if (!sameRefRange) fieldDiffs.put("refRangeDifferent", true);
+                    diff.put("fieldDiffs", fieldDiffs);
+                }
+            } else if (aInd != null) {
+                // 仅 A 有
+                diff.put("inBoth", false);
+                diff.put("diffStatus", "onlyInA");
+                diff.put("valueA", aInd.getIndicatorValue());
+                diff.put("statusA", aInd.getStatus());
+                diff.put("refRangeA", aInd.getRefRange());
+            } else if (bInd != null) {
+                // 仅 B 有
+                diff.put("inBoth", false);
+                diff.put("diffStatus", "onlyInB");
+                diff.put("valueB", bInd.getIndicatorValue());
+                diff.put("statusB", bInd.getStatus());
+                diff.put("refRangeB", bInd.getRefRange());
+            } else {
+                diff.put("diffStatus", "none");
+            }
+            diffs.add(diff);
+        }
+
+        return diffs;
+    }
+
+    private Map<String, HealthIndicator> indexByName(List<HealthIndicator> indicators) {
+        Map<String, HealthIndicator> map = new LinkedHashMap<>();
+        if (indicators == null) {
+            return map;
+        }
+        for (HealthIndicator ind : indicators) {
+            if (ind.getIndicatorName() != null) {
+                map.put(ind.getIndicatorName(), ind);
+            }
+        }
+        return map;
+    }
+}

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java

@@ -1619,6 +1619,17 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
             log.warn("用户不存在: userId={}, familyName={}", userId, familyName);
             throw new RuntimeException("用户不存在");
         }
+        // S-cfc-356 家庭名称校验(契约 TC-P-01-E):空名/超长(>30字符)/HTML注入一律拒绝,
+        // 400 + message 含『家庭名称』语义由 GlobalExceptionHandler 映射 IllegalArgumentException 提供。
+        if (familyName == null || familyName.trim().isEmpty()) {
+            throw new IllegalArgumentException("家庭名称不能为空");
+        }
+        if (familyName.length() > 30) {
+            throw new IllegalArgumentException("家庭名称不能超过30个字符");
+        }
+        if (familyName.indexOf('<') >= 0 || familyName.indexOf('>') >= 0) {
+            throw new IllegalArgumentException("家庭名称包含非法字符");
+        }
         // 创建新家庭
         Family family = new Family();
         family.setName(familyName);
@@ -1629,6 +1640,7 @@ FamilyMember child = familyMemberMapper.selectById(memberId);
         familyMapper.insert(family);
         // 更新用户的家庭ID
         user.setFamilyId(family.getId());
+        user.setIsFamilyAdmin(true); // S-cfc-356:创建者成为家庭管理员(对照 createUserAndFamily / ensureUserHasFamily 先例)
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
         // 创建人自己也是家庭成员(generation=0 表示创建者)

+ 54 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2990,6 +2990,60 @@ CREATE TABLE IF NOT EXISTS butler_assignments (
     INDEX idx_subscription_id (subscription_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员 - 管家分配记录';
 
+-- 资源匹配结果(能力键 butler.resource.match · SC-H-05:匹配结果携带距离/时间/适龄,仅已审核资源入候选)
+CREATE TABLE IF NOT EXISTS butler_resource_match_results (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    butler_user_id BIGINT NOT NULL COMMENT '推荐管家用户 ID',
+    family_id BIGINT NOT NULL COMMENT '家庭 ID',
+    member_id BIGINT NULL COMMENT '目标成员 ID (family_members.id)',
+    resource_type VARCHAR(32) DEFAULT 'activity' COMMENT '资源类型: activity(线下活动)/offline_course(线下课程)',
+    resource_id BIGINT NOT NULL COMMENT '资源 ID (activity.id)',
+    resource_name VARCHAR(200) NULL COMMENT '资源名称',
+    distance_km DECIMAL(10,2) NULL COMMENT 'AC1: 距离(公里)',
+    start_time DATETIME NULL COMMENT 'AC1: 活动开始时间',
+    age_suitability VARCHAR(64) NULL COMMENT 'AC1: 适龄信息(如「6-12岁适龄」「仅成人」)',
+    review_status VARCHAR(20) DEFAULT 'pending' COMMENT '审核门禁: approved/pending/rejected,仅 approved 入候选',
+    match_reason VARCHAR(500) NULL COMMENT '匹配理由(供家长端展示)',
+    match_score INT DEFAULT 0 COMMENT '匹配评分(0-100,候选排序)',
+    status VARCHAR(20) DEFAULT 'matched' COMMENT '匹配状态: matched/pushed/booked',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_member (member_id),
+    INDEX idx_family (family_id),
+    INDEX idx_resource (resource_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源匹配结果(SC-H-05)';
+
+-- 资源匹配推送留痕(能力键 butler.resource.match · TC-H-05-E-4:高频推送同类资源频次受控)
+CREATE TABLE IF NOT EXISTS butler_resource_match_log (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    butler_user_id BIGINT NOT NULL COMMENT '推送管家用户 ID',
+    member_id BIGINT NULL COMMENT '目标成员 ID',
+    resource_id BIGINT NOT NULL COMMENT '资源 ID',
+    resource_type VARCHAR(32) DEFAULT 'activity' COMMENT '资源类型: activity/offline_course',
+    resource_category VARCHAR(64) DEFAULT 'general' COMMENT '同类资源分类(频次控制维度,如五维 dimensionCode)',
+    pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '最近推送时间戳',
+    push_count INT DEFAULT 1 COMMENT '累计推送次数(同类资源频次控制)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_butler_member_cat (butler_user_id, member_id, resource_category)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源匹配推送留痕(TC-H-05-E-4)';
+
+-- 资源参与回访沉淀偏好(能力键 butler.resource.match · AC4:回访记录写入家长档案,后续推荐参考偏好)
+CREATE TABLE IF NOT EXISTS butler_resource_follow_up (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    family_id BIGINT NOT NULL COMMENT '家庭 ID',
+    member_id BIGINT NULL COMMENT '目标成员 ID',
+    butler_user_id BIGINT NOT NULL COMMENT '回访管家用户 ID',
+    resource_id BIGINT NOT NULL COMMENT '资源 ID',
+    resource_type VARCHAR(32) DEFAULT 'activity' COMMENT '资源类型: activity/offline_course',
+    follow_up_content TEXT NULL COMMENT '回访内容(参与感受、反馈)',
+    preference_tags TEXT NULL COMMENT '沉淀偏好标签(JSON 数组字符串,供后续推荐参考)',
+    follow_up_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '回访时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_family_member (family_id, member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资源参与回访沉淀偏好(AC4)';
+
 -- 家庭订阅记录
 CREATE TABLE IF NOT EXISTS member_subscription (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 6 - 3
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchMessageServiceTest.java

@@ -11,6 +11,8 @@ import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
 
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
@@ -46,12 +48,13 @@ class FamilyWorkbenchMessageServiceTest {
     }
 
     private String longText(int n) {
-        return "x".repeat(n);
+        // 项目编译级别 1.8,无 String.repeat
+        return String.join("", Collections.nCopies(n, "x"));
     }
 
     @Test
     void 家庭会话列表_映射与预览截断50字() {
-        when(mapper.conversationsByFamily(5L)).thenReturn(List.of(convRow(11L, "测试会话", "family")));
+        when(mapper.conversationsByFamily(5L)).thenReturn(Collections.singletonList(convRow(11L, "测试会话", "family")));
         when(mapper.conversationMessageCount(11L)).thenReturn(3L);
         when(mapper.conversationLastMessage(11L)).thenReturn(longText(120));
 
@@ -89,7 +92,7 @@ class FamilyWorkbenchMessageServiceTest {
         m2.put("role", "assistant");
         m2.put("content", "短内容");
         m2.put("createdAt", new Date());
-        when(mapper.messagesOfConversation(11L, 0L, 20L)).thenReturn(List.of(m1, m2));
+        when(mapper.messagesOfConversation(11L, 0L, 20L)).thenReturn(Arrays.asList(m1, m2));
 
         Page<FamilyMessageVO> page = service.pageConversationMessages(5L, 11L, 1, 20);
 

+ 5 - 3
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchServiceImplTest.java

@@ -15,9 +15,11 @@ import org.mockito.MockitoAnnotations;
 
 import java.time.LocalDate;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -73,7 +75,7 @@ class FamilyWorkbenchServiceImplTest {
         when(workbenchMapper.countConversations(5L)).thenReturn(2L);
         when(workbenchMapper.countCoupons(5L)).thenReturn(4L);
         when(workbenchMapper.cfBalance(5L)).thenReturn(100L);
-        when(workbenchMapper.reportDailySeries(eq(5L), anyString())).thenReturn(List.of(row("2026-10-20", 2L), row("2026-10-20", 1L)));
+        when(workbenchMapper.reportDailySeries(eq(5L), anyString())).thenReturn(Arrays.asList(row("2026-10-20", 2L), row("2026-10-20", 1L)));
         when(workbenchMapper.planDailySeries(eq(5L), anyString())).thenReturn(new ArrayList<>());
         when(workbenchMapper.taskDailySeries(eq(5L), anyString())).thenReturn(new ArrayList<>());
         when(workbenchMapper.conversationDailySeries(eq(5L), anyString())).thenReturn(new ArrayList<>());
@@ -93,8 +95,8 @@ class FamilyWorkbenchServiceImplTest {
         assertEquals("cf", cf.getKey());
         assertEquals(100L, cf.getTotal());
 
-        List<String> keys = dto.getCards().stream().map(FamilyWorkbenchCard::getKey).toList();
-        assertEquals(List.of("reports", "plans", "tasks", "messages", "cf", "coupons"), keys);
+        List<String> keys = dto.getCards().stream().map(FamilyWorkbenchCard::getKey).collect(Collectors.toList());
+        assertEquals(Arrays.asList("reports", "plans", "tasks", "messages", "cf", "coupons"), keys);
     }
 
     @Test

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-83a4a42e5f289e43ec2cfacddd348ab320a204f3
+8c5e05413f08580253fa8e7d8ff6c3b61dc66407

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

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

+ 1 - 1
cfc-web/package.json

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

+ 98 - 1
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,12 +4,109 @@
 
 ---
 
+## v1.0.2490 (2026-11-14)
+
+### 新功能
+- S-cfc-357 family.familyMembers.list 成员列表分页/搜索+在线状态
+
+### 文档
+- 分润v2设计修订(用户14:58三纠正:base=扣减后可分配额78/R3移出分润体系/通道语义命名;新增§1.6 F02现状口径校准阻塞项)
+
+
+## v1.0.2489 (2026-11-14)
+
+### 新功能
+- S-cfc-369 测评预约创建前校验所选规划师 ∈ 家庭已绑定T列表(SC-P-32 未绑定提示先绑定)
+- S-cfc-364 报告档案管理红测转绿(删除守卫/单报告趋势/报告对比/归属校验)
+- S-cfc-367 growth.milestone.mark 成长里程碑标记契约使绿(系统/手动标+幂等+时间线高亮判定,GrowthMilestoneMarkContractTest 7红转8绿)
+
+### 其他
+-   或所选 guideId 不在家庭绑定列表 → 返回「请先绑定规划师」,且不消耗额度、不创建预约
+- - AssessmentAppointmentService: 新增 requireBoundPlanner(guideId, familyId)(复用
+-   GuideFamilyService.isBound 家庭维度反查,区别于规划师侧 GuideFamilyAccessGuard)
+- - AssessmentPlannerSelectContractTest 7红转绿
+- 
+- - HealthReportService: 注入 GrowthPlanMapper + hasLinkedPlan/assertNotLinkedToPlan 删除守卫
+-   (关联成长方案拒绝删除,提示「请先解除关联方案」);deleteReport 入口先过守卫
+- - FamilyMemberNutritionProfileService: getReportTrendData 增 reportType 同类型过滤重载,
+-   单报告(同类型<2)返回 trendAvailable=false + 「至少需要 2 份报告才能看趋势」
+- - 新增 ReportArchiveCompareService.compareReports(reportAId, reportBId):
+-   类型一致才可比对,返回报告摘要 + 指标级差异标注(same/different/onlyInA/onlyInB) + 总分差异
+- - HealthReportController: 新增 POST /api/health/report/compare;deleteReport 增
+-   @RequestAttribute userId/familyId 归属校验,横向越权删除 403(SC-P-24-P)
+- - API_REFERENCE §4.59.10 + PROJECT-OVERVIEW 同步该契约缺口落地
+- 验证: ReportArchiveManageContractTest 8/8 全绿;HealthReportController* 4 类回归 31/31 无回归
+- 
+
+
+## v1.0.2488 (2026-11-14)
+
+### Bug 修复
+- S-cfc-356 createWithInvite 建家庭契约补全——名称校验/管理员回写/匿名401
+
+### 其他
+- - UserService.createFamilyForUser:空名/>30字符/HTML注入拒绝(IllegalArgumentException → GlobalExceptionHandler 400 + message 含『家庭名称』);创建者回写 isFamilyAdmin=true(对照 createUserAndFamily/ensureUserHasFamily 先例)。
+- - JwtInterceptor.LOGIN_REQUIRED_PATHS:/api/family/user/create-family 纳入登录白名单,无 token 匿名一律 401(写操作不得匿名建家庭)。
+- 红→绿:FamilyCreateWithInviteContractTest 11/11(原 8 失败)。
+- 
+- - EnergyService.getLogs: 返回前补齐展示口径——sourceType→可读来源名(task→完成任务/health_checkin→健康打卡/assessment→测评,未映射回退原始码),dimensionId→dimensionCode
+- - 契约:SC-P-18 能量流水查看 / energy.ledger.view;EnergyLedgerViewContractTest 8/8 转绿
+- 
+
+### 新功能
+- S-cfc-362 能量流水展示口径(sourceName/dimensionCode/ISO8601)使红转绿
+
+
 ## v1.0.2487 (2026-11-14)
 
 ### 新功能
+- S-cfc-361 实现五维百分比分配allocation(总和=100%)/配置告警标记/空态稳定 使红转绿
+- S-cfc-366 实现 MealPlanService 契约宿主使红转绿
+- S-cfc-350 报告智能解读契约落地(报告归属门禁/三段结构输出/PII脱敏)
+- S-cfc-358 补齐 pairwise 契约缺口使红转绿
+- 实现资源匹配服务(SC-H-05) butler.resource.match
+- 后台密度三期——卡片内边距真实收口到20px(清页面级24px覆盖)
 - teacher.client.export 客户档案数据导出(合规) 实现使绿(SC-T-29 G-3 缺口落地)
 
-### 其他
+### 文档
+- 分润v2需求口径修订(分润基数=扣减后可分配额 base=78;平台抽成与返CF均先扣;R3分享注册移出分润体系;通道语义命名对照表)
+- 研发负责人第175轮专项报告(在途70会话证据回收:backfill补链修复+43条回填质检+23键RED→YELLOW+26条派发+池空收敛)
+- 研发负责人第176轮闭环汇报(自动化14:47互斥跳过:他轮--auto pid48840在产零副作用 + 观察状态机RED65→42/YELLOW123→146 + webhook已发)
+- O-26 RED置红证据准入门槛诉求(用户追问:证据完整再置RED)
+
+### 其他
+-   按 memberId 维度隔离防横向越权(TC-P-29 / TC-P-29-E / TC-P-29-P)
+- - adjustDish:按 mealIndex/dishIndex 替换食材并重算聚合 nutritionSummary,
+-   非法载荷返回 status='ERROR' 可读信息(TC-P-29 调整不破坏营养目标)
+- - alignNutrition:对比营养合计 vs 目标,返回 {status,percent,message},
+-   NO_TARGET 分支 percent 恒为合法 Number 不除零(TC-P-30 / TC-P-30-E)
+- 
+- - 成功响应新增契约字段:plainSummary(通俗解读)、fiveDimAssociations(身/心/智/行/富五键)、actionSuggestions(建议句/兜底)、typeWarning(类型不一致提示)
+- - 输出 PII 最小脱敏:手机号前3+****+后4、15-18位长数字遮蔽、中文姓名(称谓后缀启发式+停用词表)→***
+- - 顺带修复破坏 test-compile 的 Java 8 兼容问题:FamilyWorkbench 两测试 repeat/List.of/toList → nCopies/Arrays.asList/Collectors.toList
+- AiCoachReportAnalyzeContractTest: 10 → 7 通过(3 个失败为测试侧缺陷:mock 无报告存储,999999/777888/555666 与 100 在数据层不可区分);
+- AICoachConsultContractTest 5 红为基线既有(ai.coach.consult 键,非本任务闭环)
+- 
+- - 关系缺失成员 relationship/relativeLabel 降级为「家人」(SC-P-07 边界降级展示)
+- - 配偶/同辈关系走 computeRelationshipLabel(gen, gender, relationType) 3 参重载(R4),
+-   辈分差 0 时 spouse→「丈夫/妻子」而非「兄弟/姐妹」
+- - familyId 缺位 fail-closed 拒绝,不再静默回退中心成员家庭(防越权 fail-open)
+- FamilyRelationGraphContractTest 15 用例 5 红 → 0 红(全绿)
+- 
+- - ButlerResourceMatchService.match/book:按家长需求+孩子画像匹配资源,仅已审核资源入候选(AC1/TC-H-05-E-1),book 联动 occupyQuota 原子占用名额(AC3)
+- - ButlerResourceMatchResult 实体:distanceKm/startTime/ageSuitability/reviewStatus 字段(AC1)
+- - ButlerController /api/butler/resource-match/* 端点:list/generate/book/follow-up(AC2/AC4)
+- - 建表 butler_resource_match_results / butler_resource_match_log(频次受控 TC-H-05-E-4) / butler_resource_follow_up(回访沉淀 AC4),同步 schema.sql + DatabaseInitializer 迁移475-477
+- - 契约测试 ButlerResourceMatchContractTest 11/11 转绿(原9失败)
+- Refs: S-cfc-354
+- 
+- 让 admin 主体页卡片内边距真实收紧到 20px、弹窗 12-20-10。仅改 padding 值,
+- flex/overflow/min-height/滚动链等布局属性一律保留;移动端 12px !important 不动。
+- - Layout.vue: .admin-page 下两处 .el-card__body 24→20px
+- - ProductManage.vue: .pm-card 24→20px + .pm-dialog header 16/24→12/20、body 24→20、footer 12/24→10/20
+- - SpecialPriceManage.vue: .pm-card 24→20px
+- - ActivityEdit/ArticleEdit/ProductEdit/InventoryInboundCreate: 卡片 body 24→20px
+- 
 -   统一 Result 包装、导出范围显式入参(familyId 可选)、teacher 角色守卫(GuideFamilyAccessGuard 注入 + params+userId+role 上下文)、空范围返回错误
 - - GuideFamilyQueryService#exportClientArchive:按 guide_families status='binding' 绑定源聚合 family_members 客户档案,
 -   phone/idCard 经 DataMaskUtil 强制脱敏(对齐 SC-S-11 口径),不依赖 children 表全字段快照

+ 99 - 2
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.2487
+> 当前版本: v1.0.2490
 
 ## 历史版本
 
@@ -8,12 +8,109 @@
 
 ---
 
+## v1.0.2490 (2026-11-14)
+
+### 新功能
+- S-cfc-357 family.familyMembers.list 成员列表分页/搜索+在线状态
+
+### 文档
+- 分润v2设计修订(用户14:58三纠正:base=扣减后可分配额78/R3移出分润体系/通道语义命名;新增§1.6 F02现状口径校准阻塞项)
+
+
+## v1.0.2489 (2026-11-14)
+
+### 新功能
+- S-cfc-369 测评预约创建前校验所选规划师 ∈ 家庭已绑定T列表(SC-P-32 未绑定提示先绑定)
+- S-cfc-364 报告档案管理红测转绿(删除守卫/单报告趋势/报告对比/归属校验)
+- S-cfc-367 growth.milestone.mark 成长里程碑标记契约使绿(系统/手动标+幂等+时间线高亮判定,GrowthMilestoneMarkContractTest 7红转8绿)
+
+### 其他
+-   或所选 guideId 不在家庭绑定列表 → 返回「请先绑定规划师」,且不消耗额度、不创建预约
+- - AssessmentAppointmentService: 新增 requireBoundPlanner(guideId, familyId)(复用
+-   GuideFamilyService.isBound 家庭维度反查,区别于规划师侧 GuideFamilyAccessGuard)
+- - AssessmentPlannerSelectContractTest 7红转绿
+- 
+- - HealthReportService: 注入 GrowthPlanMapper + hasLinkedPlan/assertNotLinkedToPlan 删除守卫
+-   (关联成长方案拒绝删除,提示「请先解除关联方案」);deleteReport 入口先过守卫
+- - FamilyMemberNutritionProfileService: getReportTrendData 增 reportType 同类型过滤重载,
+-   单报告(同类型<2)返回 trendAvailable=false + 「至少需要 2 份报告才能看趋势」
+- - 新增 ReportArchiveCompareService.compareReports(reportAId, reportBId):
+-   类型一致才可比对,返回报告摘要 + 指标级差异标注(same/different/onlyInA/onlyInB) + 总分差异
+- - HealthReportController: 新增 POST /api/health/report/compare;deleteReport 增
+-   @RequestAttribute userId/familyId 归属校验,横向越权删除 403(SC-P-24-P)
+- - API_REFERENCE §4.59.10 + PROJECT-OVERVIEW 同步该契约缺口落地
+- 验证: ReportArchiveManageContractTest 8/8 全绿;HealthReportController* 4 类回归 31/31 无回归
+- 
+
+
+## v1.0.2488 (2026-11-14)
+
+### Bug 修复
+- S-cfc-356 createWithInvite 建家庭契约补全——名称校验/管理员回写/匿名401
+
+### 其他
+- - UserService.createFamilyForUser:空名/>30字符/HTML注入拒绝(IllegalArgumentException → GlobalExceptionHandler 400 + message 含『家庭名称』);创建者回写 isFamilyAdmin=true(对照 createUserAndFamily/ensureUserHasFamily 先例)。
+- - JwtInterceptor.LOGIN_REQUIRED_PATHS:/api/family/user/create-family 纳入登录白名单,无 token 匿名一律 401(写操作不得匿名建家庭)。
+- 红→绿:FamilyCreateWithInviteContractTest 11/11(原 8 失败)。
+- 
+- - EnergyService.getLogs: 返回前补齐展示口径——sourceType→可读来源名(task→完成任务/health_checkin→健康打卡/assessment→测评,未映射回退原始码),dimensionId→dimensionCode
+- - 契约:SC-P-18 能量流水查看 / energy.ledger.view;EnergyLedgerViewContractTest 8/8 转绿
+- 
+
+### 新功能
+- S-cfc-362 能量流水展示口径(sourceName/dimensionCode/ISO8601)使红转绿
+
+
 ## v1.0.2487 (2026-11-14)
 
 ### 新功能
+- S-cfc-361 实现五维百分比分配allocation(总和=100%)/配置告警标记/空态稳定 使红转绿
+- S-cfc-366 实现 MealPlanService 契约宿主使红转绿
+- S-cfc-350 报告智能解读契约落地(报告归属门禁/三段结构输出/PII脱敏)
+- S-cfc-358 补齐 pairwise 契约缺口使红转绿
+- 实现资源匹配服务(SC-H-05) butler.resource.match
+- 后台密度三期——卡片内边距真实收口到20px(清页面级24px覆盖)
 - teacher.client.export 客户档案数据导出(合规) 实现使绿(SC-T-29 G-3 缺口落地)
 
-### 其他
+### 文档
+- 分润v2需求口径修订(分润基数=扣减后可分配额 base=78;平台抽成与返CF均先扣;R3分享注册移出分润体系;通道语义命名对照表)
+- 研发负责人第175轮专项报告(在途70会话证据回收:backfill补链修复+43条回填质检+23键RED→YELLOW+26条派发+池空收敛)
+- 研发负责人第176轮闭环汇报(自动化14:47互斥跳过:他轮--auto pid48840在产零副作用 + 观察状态机RED65→42/YELLOW123→146 + webhook已发)
+- O-26 RED置红证据准入门槛诉求(用户追问:证据完整再置RED)
+
+### 其他
+-   按 memberId 维度隔离防横向越权(TC-P-29 / TC-P-29-E / TC-P-29-P)
+- - adjustDish:按 mealIndex/dishIndex 替换食材并重算聚合 nutritionSummary,
+-   非法载荷返回 status='ERROR' 可读信息(TC-P-29 调整不破坏营养目标)
+- - alignNutrition:对比营养合计 vs 目标,返回 {status,percent,message},
+-   NO_TARGET 分支 percent 恒为合法 Number 不除零(TC-P-30 / TC-P-30-E)
+- 
+- - 成功响应新增契约字段:plainSummary(通俗解读)、fiveDimAssociations(身/心/智/行/富五键)、actionSuggestions(建议句/兜底)、typeWarning(类型不一致提示)
+- - 输出 PII 最小脱敏:手机号前3+****+后4、15-18位长数字遮蔽、中文姓名(称谓后缀启发式+停用词表)→***
+- - 顺带修复破坏 test-compile 的 Java 8 兼容问题:FamilyWorkbench 两测试 repeat/List.of/toList → nCopies/Arrays.asList/Collectors.toList
+- AiCoachReportAnalyzeContractTest: 10 → 7 通过(3 个失败为测试侧缺陷:mock 无报告存储,999999/777888/555666 与 100 在数据层不可区分);
+- AICoachConsultContractTest 5 红为基线既有(ai.coach.consult 键,非本任务闭环)
+- 
+- - 关系缺失成员 relationship/relativeLabel 降级为「家人」(SC-P-07 边界降级展示)
+- - 配偶/同辈关系走 computeRelationshipLabel(gen, gender, relationType) 3 参重载(R4),
+-   辈分差 0 时 spouse→「丈夫/妻子」而非「兄弟/姐妹」
+- - familyId 缺位 fail-closed 拒绝,不再静默回退中心成员家庭(防越权 fail-open)
+- FamilyRelationGraphContractTest 15 用例 5 红 → 0 红(全绿)
+- 
+- - ButlerResourceMatchService.match/book:按家长需求+孩子画像匹配资源,仅已审核资源入候选(AC1/TC-H-05-E-1),book 联动 occupyQuota 原子占用名额(AC3)
+- - ButlerResourceMatchResult 实体:distanceKm/startTime/ageSuitability/reviewStatus 字段(AC1)
+- - ButlerController /api/butler/resource-match/* 端点:list/generate/book/follow-up(AC2/AC4)
+- - 建表 butler_resource_match_results / butler_resource_match_log(频次受控 TC-H-05-E-4) / butler_resource_follow_up(回访沉淀 AC4),同步 schema.sql + DatabaseInitializer 迁移475-477
+- - 契约测试 ButlerResourceMatchContractTest 11/11 转绿(原9失败)
+- Refs: S-cfc-354
+- 
+- 让 admin 主体页卡片内边距真实收紧到 20px、弹窗 12-20-10。仅改 padding 值,
+- flex/overflow/min-height/滚动链等布局属性一律保留;移动端 12px !important 不动。
+- - Layout.vue: .admin-page 下两处 .el-card__body 24→20px
+- - ProductManage.vue: .pm-card 24→20px + .pm-dialog header 16/24→12/20、body 24→20、footer 12/24→10/20
+- - SpecialPriceManage.vue: .pm-card 24→20px
+- - ActivityEdit/ArticleEdit/ProductEdit/InventoryInboundCreate: 卡片 body 24→20px
+- 
 -   统一 Result 包装、导出范围显式入参(familyId 可选)、teacher 角色守卫(GuideFamilyAccessGuard 注入 + params+userId+role 上下文)、空范围返回错误
 - - GuideFamilyQueryService#exportClientArchive:按 guide_families status='binding' 绑定源聚合 family_members 客户档案,
 -   phone/idCard 经 DataMaskUtil 强制脱敏(对齐 SC-S-11 口径),不依赖 children 表全字段快照

+ 2 - 2
cfc-web/src/views/Layout.vue

@@ -890,7 +890,7 @@ export default {
   display: flex;
   flex-direction: column;
   min-height: 0;
-  padding: 24px;
+  padding: 20px;
 }
 /* 当 admin-page > div > el-card(如 ArticleManage 的嵌套 div) */
 .admin-page > div:only-child {
@@ -910,7 +910,7 @@ export default {
   display: flex;
   flex-direction: column;
   min-height: 0;
-  padding: 24px;
+  padding: 20px;
 }
 
 /* ========== 响应式:侧边栏折叠 ========== */

+ 1 - 1
cfc-web/src/views/admin/ActivityEdit.vue

@@ -849,7 +849,7 @@ activityId
   flex-direction: column;
   min-height: 0;
   overflow: hidden;
-  padding: 24px;
+  padding: 20px;
   width: 100%;
   box-sizing: border-box;
 }

+ 1 - 1
cfc-web/src/views/admin/ArticleEdit.vue

@@ -849,7 +849,7 @@ export default {
   flex-direction: column;
   min-height: 0;
   overflow: hidden;
-  padding: 24px;
+  padding: 20px;
   width: 100%;
   box-sizing: border-box;
 }

+ 1 - 1
cfc-web/src/views/admin/InventoryInboundCreate.vue

@@ -155,7 +155,7 @@ export default {
   flex-direction: column;
   min-height: 0;
   overflow: hidden;
-  padding: 24px;
+  padding: 20px;
   width: 100%;
   box-sizing: border-box;
 }

+ 1 - 1
cfc-web/src/views/admin/ProductEdit.vue

@@ -1374,7 +1374,7 @@ async loadGiftRule(productId) {
   flex-direction: column;
   min-height: 0;
   overflow: hidden;
-  padding: 24px;
+  padding: 20px;
   width: 100%;
   box-sizing: border-box;
 }

+ 4 - 4
cfc-web/src/views/admin/ProductManage.vue

@@ -556,7 +556,7 @@ export default {
 }
 
 .pm-card /deep/ .el-card__body {
-  padding: 24px;
+  padding: 20px;
 }
 
 /* ---------- 页面标题区 ---------- */
@@ -778,7 +778,7 @@ export default {
 
 /* ---------- 弹窗内区块 ---------- */
 .pm-dialog /deep/ .el-dialog__header {
-  padding: 16px 24px;
+  padding: 12px 20px;
   border-bottom: 1px solid var(--adm-border-split);
 }
 
@@ -789,11 +789,11 @@ export default {
 }
 
 .pm-dialog /deep/ .el-dialog__body {
-  padding: 24px;
+  padding: 20px;
 }
 
 .pm-dialog /deep/ .el-dialog__footer {
-  padding: 12px 24px;
+  padding: 10px 20px;
   border-top: 1px solid var(--adm-border-split);
 }
 

+ 1 - 1
cfc-web/src/views/admin/SpecialPriceManage.vue

@@ -495,7 +495,7 @@ export default {
 
 <style scoped>
 .pm-card /deep/ .el-card__body {
-  padding: 24px;
+  padding: 20px;
   display: flex;
   flex-direction: column;
   min-height: calc(100vh - 60px - 48px);

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
docs/superpowers/PROJECT-OVERVIEW.md


+ 1 - 1
docs/superpowers/api/API_REFERENCE.md

@@ -2991,7 +2991,7 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 |----------------|------|------|
 | `POST /api/search/global` | **不存在**(无 SearchController) | SC-P-82 `misc.globalSearch` 全局搜索(报告/方案/任务/商品)**未实现**;搜索需求当前分散:`/api/diet/ingredients/search`(食材)、文章/商品搜索各自接口。待实现时在本节登记 |
 | `POST /api/provider/report-parse/connect` | **不存在**(无 ProviderController) | SC-S-09 `provider.reportParse.connect` 商品服务商报告解析接入**未实现**;服务商(PROVIDER 角色)当前经 `/api/vendor/*`、`/api/admin/supply-*` 体系管理,无独立报告解析对接端点 |
-| `POST /api/health/report/compare` | **不存在** | SC-P-24/T-21 `health.report.compare` 报告对比由等价物承载:`/api/dan-report/compare`(DAN 两版对比)+ `/api/assessment/compare/*`(测评首次 vs 最新)。如需跨报告类型统一对比,待实现 |
+| `POST /api/health/report/compare` | ✅ 已实现(2026-11-14,S-cfc-364) | SC-P-24/T-21 `health.report.compare` 报告对比落地:`ReportArchiveCompareService.compareReports(reportAId, reportBId)`(类型一致才可比对,返回报告摘要 + 指标级差异标注 same/different/onlyInA/onlyInB + 总分差异;DAN 两版对比仍走 `/api/dan-report/compare`) |
 | `POST /api/health/report/trend` | **不存在** | SC-P-25 `health.report.trend` 由等价物承载:`/api/health/nutrition/trend` + `/api/food/recommendation/trend`(+ 既有 `/api/indicator/trend`、`/api/cognitive/trend`) |
 | `POST /api/health/report/diet` | **不存在** | SC-P-26 `health.report.dietRecommend` 由等价物承载:`/api/food/recommendation/*` + `/api/meal/*` + `/api/diet/*`(4.11) |
 | `POST /api/health/report/share` | **不存在** | SC-P-84 `health.report.share` 由等价物承载:`/api/share/*`(通用分享)+ `/api/health/report/member-permission/*`(成员级授权,4.59.4) |

+ 90 - 44
docs/superpowers/plans/2026-11-14-cf-reward-model-v2-design.md

@@ -2,7 +2,7 @@
 
 > 需求基线:`2026-11-14-cf-reward-model-v2.md`(dc04a6b4c,需求已裁决)
 > 设计基线:`2026-10-24-cf-value-unification-plan.md` + 现行代码实证
-> 产出者:架构师(software-architect)| 状态:**§9 四项已裁决(2026-11-14 主理人+廖总拍板),设计定稿,实现待 P0 合入后排期**
+> 产出者:架构师(software-architect)| 2026-11-14 14:58 主理人+廖总口径修订(分润基数=扣减后可分配额 base;R3 移出分润体系;通道语义命名)| 状态:**§9 第 7 项(F02 实现口径)待用户裁决,实现暂缓**
 
 ## 0. 重大发现:现存 ProfitPoolService 旧体系须处置(先读)
 
@@ -23,44 +23,78 @@ worktree 中存在**已在产**的旧团队分润体系,与 v2 新团队池口
 ## 1. 账目公式全集(金额单位:分;CF 单位:CF;1 元 = 100 CF)
 
 ### 1.1 单商品订单(售价 S、成本 C、实付 A、体系 T_bps)
+> 2026-11-14 14:58 用户纠正后口径:平台抽成与消费返 CF **都是先扣的成本**,分润基数 = 扣减后可分配额 base。
 ```
-R0  平台抽成   = floor(A × TAKE_BPS / 10000)      // TAKE_BPS=1000 → 10%,新增
-R1  消费返 CF  = floor(A/100) × 100 CF            // 现状不变,走 F02
-R2  直推销售奖 = basePpoints × directRate%        // basePpoints = max(0, A − C×qty),现状 calcOrderPpoints 不变
-R2  间推销售奖 = basePpoints × indirectRate%
-R2' 团队池     = floor(C × qty × T_bps / 10000)   // 新增,从成本切
-R3  分享注册   = +20 CF/人(日限10),不变
-剩余           = A − R0 − R2直推 − R2间推 − R2'池   // 归商家/平台交付成本与利润,不落账(记日志)
+A   实付        = paidAmountCent(分)
+R0  平台抽成   = floor(A × TAKE_BPS / 10000)      // TAKE_BPS=1000 → 10%;新增;先扣
+R1  消费返 CF  = floor(A/100) CF                   // 走 F02;1 CF = 1 分等值 ⇒ 等值成本 floor(A/100) 分;先扣
+base 可分配额  = A − R0 − R1_等值 − C×qty          // 扣减后可分配额,base<0 时按 0
+直推销售奖     = base × directRate%                // ≤70%,按 base 分
+间推销售奖     = base × indirectRate%              // ≤20%,按 base 分
+团队分润池     = floor(C × qty × T_bps / 10000)    // 从成本侧切出,独立计算,不占 base
+平台留存       = A − R1_等值 − 直推 − 间推 − (C×qty − 团队池)
+```
+**账目示例**(售价 200 / 成本 100 / T=30% / 直推 70% 档 / 间推 20% 档;单位:元):
+```
+R0 = 20 ;R1 = 200 CF(≈2 元);base = 200 − 20 − 2 − 100 = 78
+直推 = 78 × 70% = 54.6 ;间推 = 78 × 20% = 15.6 ;团队池 = 100 × 30% = 30
+平台留存 = 200 − 2 − 54.6 − 15.6 − (100 − 30) = 57.8
+校验:20 + 2 + 54.6 + 15.6 + 30 + 70(商家自担成本) + 57.8 = 200 ✓
 ```
 ### 1.2 多商品订单(按行分摊,沿用现状方案A)
 ```
-basePpoints = Σ max(0, allocPaid_i − C_i×qty_i)      // allocPaid_i = A × base_i/Σbase,现状不变(R2)
-R2'池        = Σ floor(C_i × qty_i × T_bps / 10000)  // 按行成本加总(新增,无分摊歧义)
-R0/R1        = 整单口径(不按行)
+allocPaid_i    = A × base_i/Σbase                    // 现状方案A
+R0/R1         = 整单口径(不按行)
+base          = A − R0 − R1_等值 − Σ(C_i×qty_i)      // 成本整单扣减后算一次
+直推/间推      = base × 档位%                        // 不再按行分摊(P 点按行分摊仅作 v1 审计参照)
+团队池        = Σ floor(C_i × qty_i × T_bps / 10000) // 按行成本加总(无分摊歧义)
 ```
 ### 1.3 非商品订单(会员/订阅/测评/套餐)—— 2026-11-14 裁决:**进团队池,定义成本模型**
 ```
-非商品成本 C' = A × NCD_BPS / 10000        // NCD_BPS = 非商品成本率(新配置 cf.team_pool.non_goods_cost_rate,默认 5000=50%,待运营定)
-R0 = floor(A×TAKE_BPS/10000);R1 不变;
-R2 沿用 calcOrderPpoints 非商品分支(金额×serviceRateBps),不变;
-R2' = floor(C' × T_bps / 10000)             // = A × 50% × 30%,进团队池
+非商品成本 C' = floor(A × NCD_BPS / 10000)   // NCD_BPS = cf.team_pool.non_goods_cost_rate,默认 5000=50%
+R0 = floor(A×TAKE_BPS/10000);R1 = floor(A/100) CF;
+base = A − R0 − R1_等值 − C'                   // 扣减同理
+直推/间推 = base × 档位%
+团队池 = floor(C' × T_bps / 10000)             // = A × 50% × 30%,进团队池
 ```
 ### 1.4 边界条件
 | 场景 | 处理 |
 |------|------|
-| 亏损仓(A < C×qty) | R2=0(现状 max(0,...));R2' 默认仍按成本×T% 发放(成本切出与毛利无关)——配置 `cf.team_pool.on_loss=payout\|zero`,默认 payout,待产品确认 |
-| 实付=0 | R0=0、R1=0、R2=0;R2' 仍按成本×T% |
-| T=0 / 未配置 | 团队池跳过,不影响 R2/R1/R0 |
-| 同家庭互推 | R2 上溯跳过(现状);R2' 瓜分成员也跳过同家庭 |
-| 池金额小、权重分配尾差 | 尾差守恒(见 §6) |
+| 亏损仓(A < C×qty) | base < 0 ⇒ 直推/间推 = 0(**不得为负**);R0/R1 照旧;团队池仍按成本×T% 发放(成本切出与 base 无关)——配置 `cf.team_pool.on_loss=payout\|zero`,默认 payout |
+| 实付=0 | R0=0、R1=0、base=0、直推/间推=0;团队池仍按成本×T% |
+| T=0 / 未配置 | 团队池跳过,不影响 base/直推/间推/R1/R0 |
+| 同家庭互推 | 直推/间推上溯跳过(现状);团队池瓜分成员也跳过同家庭 |
+| 池金额小、权重分配尾差 | 尾差守恒(见 §5) |
 
 ### 1.5 幂等键设计(全部复用"同一事务+唯一键")
 | 通道 | 表/键 |
 |------|-------|
-| R1 | cf_balance_log uk_ref_type(member_id, 'earn', 'cf_consume_order', ref_id=orderId×100+ns),现状不变 |
-| R2 | cf_balance_log uk_ref_type(member_id, 'earn', 'F03a'/'F03b', ref_id=orderId),现状不变 |
-| R2' | cf_team_pool_order.uk_order(order_type, order_id) + cf_team_pool_share.uk_pool_member(pool_id, member_id) + cf_balance_log(member_id,'earn','F03c', ref_id=pool_id) 三层兜底 |
-| R0 | cf_platform_revenue_log.uk_order(order_type, order_id) |
+| 消费返 CF | cf_balance_log uk_ref_type(member_id, 'earn', 'cf_consume_order', ref_id=orderId×100+ns),现状不变 |
+| 直推/间推销售奖 | cf_balance_log uk_ref_type(member_id, 'earn', 'F03a'/'F03b', ref_id=orderId),现状不变 |
+| 团队分润池 | cf_team_pool_order.uk_order(order_type, order_id) + cf_team_pool_share.uk_pool_member(pool_id, member_id) + cf_balance_log(member_id,'earn','F03c', ref_id=pool_id) 三层兜底 |
+| 平台抽成 | cf_platform_revenue_log.uk_order(order_type, order_id) |
+
+### 1.5b 通道语义名与编号对照表(消歧)
+| 用户口径 | 语义通道名(文档正文统一使用) | 说明 |
+|---|---|---|
+| R0 | 平台抽成 | 实付×10%,先扣 |
+| R1 | 消费返 CF | 返消费者,先扣 |
+| —(毛利可分配部分) | 推荐人销售奖(直推/间推) | 基数 = base(78) |
+| **R2(用户口径)** | **团队分润池** | 成本×T%,单独计算、独立于 base |
+| R3 | (非分润) | 分享注册 +20CF/日限10,**已移出分润体系**,不参与订单分账 |
+| — | 平台留存 | base 分出后 + 成本侧切池后的余额 |
+
+### 1.6 ⚠ F02 现状实现校准(**待用户裁决**,阻塞 base 数值落地)
+| 项 | 现状实现 | 本模型要求 |
+|---|---|---|
+| F02 种子 | `formula = floor(amt)`,`params = {cf_per_yuan:100, amt_unit:cent}` | R1 等值成本 = floor(A/100) 分(实付 200 元 → 200 CF ≈ 2 元) |
+| 换算实现 | `CfBalanceServiceImpl.applyAmountUnitConversion`:`amt(分) × cf_per_yuan/100 = 分→CF 1:1` | 实付 200 元(20000 分)→ **返 200 CF** |
+| 实际结果 | 实付 200 元 → 发放 **20000 CF = 200 元等值**(记账式全额返) | 20000 CF 是要求的 200 CF 的 **100 倍** |
+
+**不校准则模型不成立**:若 F02 维持全额等值,base = 200 − 20 − 200 − 100 = **−120 < 0** → 推荐人无奖。
+**建议方案 (A)**:F02 改为**比例返利** —— `formula = floor(amt × bps/10000)`,`params.cf_return_bps = 100`(1%)→ 实付 200 元返 200 CF(2 元等值),base = 78 成立;返利比例由管理端 `CfRuleConfig` 可调(新增 `cf_return_bps` 参数)。
+**连带核对**:F18(代码注释"与 F02 同口径")、F06(1990 CF 口径)是否需同步调整;已发放历史 CF 余额不在本次追溯范围。
+备选 (B):保留 F02 全额等值、base 不扣 R1 → base = 80(与用户 78 口径不符);备选 (C):全额等值 + base 扣 R1 → base<0,不成立。
 
 ---
 
@@ -155,15 +189,18 @@ CREATE TABLE IF NOT EXISTS cf_platform_revenue_log (
 ## 3. 结算链路改造点(方法级清单)
 
 ### 3.1 CfCommissionService(主改造)
+> 结算顺序固定为:① 平台抽成 → ② 消费返 CF → ③ base 计算 → ④ 直推/间推销售奖 → ⑤ 团队池(独立从成本算)。全部同一 @Transactional。
+
 | 方法 | 改动 |
 |------|------|
-| `settle(...)` (138-146) | 保留 earnBuyerCf(R1)+ distributeToReferrers(R2),新增 `settlePlatformTake`(R0)+ `settleTeamPool`(R2');同一 @Transactional |
-| `settleReferrerOnly` (166-171) | 同上加 R0/R2'(会员/订阅单 R2'=0 自动跳过) |
-| `settlePlatformTake`(新) | floor(paid×1000/10000) 写 cf_platform_revenue_log(uk_order 幂等);实付 null 时用 orderAmountCent |
+| `settle(...)` (138-146) | 保留 earnBuyerCf(消费返 CF),顺序改为:settlePlatformTake → earnBuyerCf → **calcDistributableBase** → distributeToReferrers(base) → settleTeamPool |
+| `settleReferrerOnly` (166-171) | 同上顺序(会员/订阅单走非商品成本模型) |
+| `settlePlatformTake`(新) | floor(A×1000/10000) 写 cf_platform_revenue_log(uk_order 幂等);实付 null 时用 orderAmountCent |
+| `calcDistributableBase(A, costCent)`(新,T03 交付) | `base = A − R0 − R1_等值 − costCent`;R1_等值 = floor(A/100);base<0 返回 0(不得为负) |
 | `settleTeamPool`(新) | 解析 T(TeamShareConfigService)→ 算池 → 建 cf_team_pool_order(uk_order 幂等)→ distributeTeamPool |
-| `distributeTeamPool`(新) | §6 伪代码:层级树+档位权重瓜分 |
-| `distributeToReferrers` (196-240) | **零改动** |
-| `calcOrderPpoints` (344-396) | **零改动**(毛利口径 R2 专用) |
+| `distributeTeamPool`(新) | §5 伪代码:层级树+档位权重瓜分 |
+| `distributeToReferrers` (196-240) | **零改动**(仅入参由 P 点毛利改为 base) |
+| `calcOrderPpoints` (344-396) | **零改动**(v1 毛利口径,保留作审计参照/回滚兜底) |
 | 常量 | 新增 `RULE_TEAM_POOL = "F03c"` |
 
 ### 3.2 新增 TeamShareConfigService(新类)
@@ -205,7 +242,9 @@ CREATE TABLE IF NOT EXISTS cf_platform_revenue_log (
 
 ```
 function distributeTeamPool(orderId, orderType, buyerUserId, buyerFamilyId, paidCent, productId):
-    // 1. 算池金额(单商品/多商品分行)
+    // 0. base(可分配额)仅供直推/间推使用,团队池不占 base:
+    //    base = max(0, paidCent − R0 − R1_等值 − costCent),见 calcDistributableBase
+    // 1. 算池金额(单商品/多商品分行)——从成本侧切出,独立计算
     poolCent = 0
     for each line (product, qty) in order:
         if product has purchase_price C:
@@ -261,8 +300,8 @@ function distributeTeamPool(orderId, orderType, buyerUserId, buyerFamilyId, paid
 |------|------|----------|------|----------------|
 | **T01** 配置与迁移(表+键+种子) | 新建 4 表 + F03c 种子 + 6 键播种(含 non_goods_cost_rate) | DatabaseInitializer★★、schema.sql★★、CfConfigKeys、CfEarnRuleService 测试 | 无(等 P0 合入) | `cf.rewardv2.config.migrate`、`cf.rewardv2.config.seed` |
 | **T02** TeamShareConfigService | 多作用域解析 + 优先级链 + 缓存 | TeamShareConfigService(新)、CfTeamShareConfigMapper(新)、entity.CfTeamShareConfig(新) | T01 | `cf.teamshare.config.resolve`、`cf.teamshare.config.priority` |
-| **T03** R0 平台抽成记账 | settlePlatformTake + cf_platform_revenue_log | CfCommissionService、CfPlatformRevenueLog(新)、PaymentController 依赖不变 | T01 | `cf.rewardv2.r0.platform-take`、`cf.rewardv2.r0.idempotent` |
-| **T04** 团队池建池+结算分叉 | settleTeamPool + 建池幂等 + settle 入口接线 | CfCommissionService、CfTeamPoolOrder(新)、CfTeamPoolOrderMapper(新)、PaymentService★(仅依赖链,无签名变化) | T01,T02 | `cf.teamshare.pool.create`、`cf.teamshare.pool.idempotent` |
+| **T03** R0 平台抽成记账 + base 计算 | settlePlatformTake + cf_platform_revenue_log + **calcDistributableBase(A, costCent)**(供 T04 复用) | CfCommissionService、CfPlatformRevenueLog(新)、PaymentController 依赖不变 | T01 | `cf.rewardv2.r0.platform-take`、`cf.rewardv2.r0.idempotent`、`cf.rewardv2.base.calc` |
+| **T04** 团队池建池+结算分叉 | settleTeamPool + 建池幂等 + settle 入口接线(直推/间推入参改 base) | CfCommissionService、CfTeamPoolOrder(新)、CfTeamPoolOrderMapper(新)、PaymentService★(仅依赖链,无签名变化) | T01,T02,T03 | `cf.teamshare.pool.create`、`cf.teamshare.pool.idempotent` |
 | **T05** 层级树瓜分算法 | distributeTeamPool + share 流水 + 尾差守恒 + 同家庭跳过 | CfCommissionService、CfTeamPoolShare(新)、CfReferralService(复用不改)、CfBalanceServiceImpl(复用不改) | T04 | `cf.teamshare.share.weighted`、`cf.teamshare.share.family-skip`、`cf.teamshare.share.remainder` |
 | **T06** F03c 规则 + 发放接入 | earnByRule("F03c") per_item + 三道闸验证 | CfEarnRuleService、DatabaseInitializer(种子已T01)、CfBalanceServiceImpl(仅验证) | T04 | `cf.rewardv2.f03c.dispatch` |
 | **T07** 旧体系废弃清理 | 删除调用点(5处)+CronTask+AdminController+服务类,4旧表归档只读、验证不再新增 | ProfitPoolService、TeamProfitCronTask、TeamProfitAdminController、PaymentService★、ProductOrderService、MembershipService、AssessmentOrderService、MemberSubscriptionService、TeamProfitService/FlowService/StarScoreCalculator | T01 | `cf.rewardv2.legacy.remove`、`cf.rewardv2.legacy.archive` |
@@ -279,9 +318,11 @@ P0 文件交集标注:T01 与 P0 冲突面最大(DatabaseInitializer/schema.
 3. **旧体系双发**:ProfitPoolService 未停用前与新团队池同时发放 → 必须 T07 灰度开关先行;上线顺序 旧停用 → 新启用。
 4. **既有数据兼容**:profit_pool_order/team_share_record/team_award_record 全部保留只读;CronTask 守卫后不再写新数据。
 5. **T 未配置降级**:GLOBAL 行缺失时代码兜底 3000bps;`cf.team_pool.enabled=false` 时全链路跳过(R2/R1/R0 不受影响)。
-6. **亏损仓语义**:R2' 从成本切出,亏损单仍发池(默认 payout)可能造成商家亏损加倍——配置 on_loss=zero 可关;待产品确认默认值。
-7. **单位口径**:R2/R2' 发 CF(1分=1CF 参照)、R0 货币分、R1 CF——财务对账需按类型区分,避免把 CF 当货币分合计。
-8. **"成交价"口径**:R0 基数 = 实付(paidAmountCent)按现状传参;若决策"成交价=划线价",需另加参数,待确认。
+6. **亏损仓语义**:团队池从成本切出,亏损单仍发池(默认 payout)可能造成商家亏损加倍——配置 on_loss=zero 可关(已裁决 payout)。
+7. **单位口径**:直推/间推/团队池以 CF 发放(1 分 = 1 CF 参照)、平台抽成为货币分、消费返 CF 为 CF——财务对账按类型区分,避免把 CF 当货币分合计。
+8. **~~"成交价"口径~~(已裁决)**:平台抽成基数 = 实付(paidAmountCent)。
+9. **⚠ F02 现状口径校准(阻塞项,见 §1.6)**:现状 F02 = floor(实付分) CF(实付 200 元 → 20000 CF = 200 元等值,全额记账式返),与本模型要求的 R1 等值成本 2 元差 100 倍;不改则 base = −120、模型不成立 → 需用户裁决是否改为比例返利(建议 `cf_return_bps=100`,1%)。
+10. **⚠ 结算顺序与事务一致性(新增)**:base 依赖 R0 与 R1 先算出,若 R1 发放失败必须整体回滚——settle 全链必须同一事务;现状 earnBuyerCf 内部若 try-catch 吞异常(现状存在 log.error 后继续的写法),须改为抛出以触发回滚,否则会出现"已返 CF 但 base 按未返计算"的错账。实现时需核查 earnBuyerCf 的异常处理。
 
 ---
 
@@ -289,19 +330,21 @@ P0 文件交集标注:T01 与 P0 冲突面最大(DatabaseInitializer/schema.
 
 | # | 用例 | 断言 |
 |---|------|------|
-| 1 | 账目示例 200/100/T=30%(直推70%档、间推20%档、2名团队成员各70/50权重) | R0=20、R2直推=70、R2间推=20、池=30、R1=200CF;池内瓜分 30×70/120=17、30×50/120=13(守恒) |
-| 2 | 多商品分摊:商品A(100/60×2) + 商品B(80/40) | R2 按行分摊现状断言;R2'池 = (60×2+40)×30% = 48 |
-| 3 | 亏损仓:售价100/成本120 | R2=0;R2'=120×30%=36(payout 默认)或 0(on_loss=zero) |
+| 1 | 账目示例 200/100/T=30%(直推70%档、间推20%档、2名团队成员各70/50权重) | R0=20、R1=200CF、**base=78**、直推=54.6、间推=15.6、池=30、平台留存=57.8;池内瓜分 30×70/120=17.5→17、30×50/120=12.5→13(尾差守恒) |
+| 2 | 多商品分摊:商品A(100/60×2) + 商品B(80/40) | R0=实付×10%;R1=实付/100 CF;base = 实付 − R0 − R1 − (60×2+40) ;团队池 = (60×2+40)×30% = 48 |
+| 3 | 亏损仓:售价100(10000分)/成本120 | base = 10000 − 1000 − 100 − 12000 < 0 ⇒ base=0、直推/间推=0;R0=10、R1=100CF;团队池 = 120×30% = 36(payout) |
 | 4 | 团队池幂等:同一订单重复 settle 2 次 | 池建 1 次、share 不重复、CF 不重复(earned/duplicated 断言) |
-| 5 | 同家庭跳过:买家直推为同家庭 | 直推不拿 F03a;R2' 成员列表剔除同家庭,池分给下一非同家庭或 unclaimed |
+| 5 | 同家庭跳过:买家直推为同家庭 | 直推不拿 F03a;团队池成员列表剔除同家庭,池分给下一非同家庭或 unclaimed |
 | 6 | T 未配置回退:cf_team_share_config 空表 | 命中 GLOBAL 兜底 3000bps;无 GLOBAL 行时走代码兜底 3000 |
 | 7 | 作用域优先级:MINIAPP=2000 且 SYSTEM=4000 | 命中 2000(MINIAPP 优先) |
-| 8 | T=0 / enabled=false | 池不建、不瓜分,R2/R1 照常 |
+| 8 | T=0 / enabled=false | 池不建、不瓜分,base/直推/间推/R1/R0 照常 |
 | 9 | 尾差守恒 | Σshare_cent == pool_cent(最后一人补齐,无金额蒸发) |
-| 10 | R0 不参与基数 | R0=实付×10% 单列,R2 基数仍为毛利全额(示例中 100 毛利不被抽成 10% 缩小) |
+| 10 | R0/R1 先扣 base | base = 实付 − R0 − R1_等值 − 成本(示例 78,**≠ 原始毛利 100**);断言 base<0 时按 0 |
 | 11 | 旧体系废弃 | 调用点已删、TeamProfitCronTask 已删,profit_pool_order 不再新增;4 旧表数据完整保留可查 |
-| 12 | 非商品订单进池 | 会员/订阅单:池 = 实付 × 50%(non_goods_cost_rate) × 30%,创建 cf_team_pool_order 并瓜分 |
-| 12 | 并发回调 | 两线程同时 settle 同订单,恰一张池单、全部发放一次 |
+| 12 | 非商品订单进池 | 会员/订阅单:池 = 实付 × 50%(non_goods_cost_rate) × 30%,base = 实付 − R0 − R1 − 非商品成本,创建 cf_team_pool_order 并瓜分 |
+| 13 | R3 不参与分账 | 订单 settle 后无 F24 产出;分享注册仅由注册链路独立触发 |
+| 14 | 并发回调 | 两线程同时 settle 同订单,恰一张池单、全部发放一次 |
+| 15 | ⚠ F02 比例返利(待 §1.6 裁决) | 若裁决 A:实付 200 元返 200 CF;断言 cf_earn_rule F02 formula/params 变更后 earnByRule 数量正确 |
 
 ---
 
@@ -313,5 +356,8 @@ P0 文件交集标注:T01 与 P0 冲突面最大(DatabaseInitializer/schema.
 | 2 | 非商品订单是否进团队池 | **进**,成本模型 = 实付 × `cf.team_pool.non_goods_cost_rate`(默认 50%,待运营定) | 廖总拍板(§1.3 已更新) |
 | 3 | 亏损仓团队池发放 | **照常发**(payout,与"从成本切"语义一致) | 廖总拍板 |
 | 4 | R0 平台抽成"成交价"基数 | **实付价**(paidAmountCent,与现状传参一致) | 廖总拍板 |
+| 5 | 分润基数口径 | **扣减后可分配额 base = 实付 − R0 − R1_等值 − 成本**(示例 78,不是毛利 100);平台抽成与消费返 CF 都是先扣成本 | 廖总 2026-11-14 14:58 纠正(§1.1 已更新) |
+| 6 | 分享注册(+20CF)归属 | **移出分润体系**,独立获客激励不参与订单分账 | 廖总 14:58 纠正(测试 #13 已更新) |
+| 7 | ⚠ F02 消费返 CF 实现口径 | **待裁决**(阻塞):现状 floor(实付分)=全额等值(20000 CF/200元),本模型要求 R1=200 CF(2元)→ 建议改比例返利 `cf_return_bps=100` | 待廖总裁决(§1.6 已列 A/B/C 方案) |
 
 设计定稿,可进入实现排期(等 P0 合入后另开 `feature/reward-v2` worktree)。

+ 44 - 23
docs/superpowers/plans/2026-11-14-cf-reward-model-v2.md

@@ -1,7 +1,7 @@
 # CF 分润模型 v2(2026-11-14 修订)— 推荐奖励 & 团队分佣重组
 
 > 基线:`2026-10-24-cf-value-unification-plan.md`(§三 F02-F24、§四 4.4 P 点口径)
-> 状态:**需求已裁决**(廖总 2026-11-14 拍板);增量设计待架构师输出;实现待排期
+> 状态:**需求已裁决 + 口径修订闭环**(2026-11-14 拍板;同日 14:58 用户纠正三处:base=78、R3 移出、通道语义命名);设计修订中(架构师定向修订回传后落盘)
 
 ## 1. 裁决记录(用户拍板 2026-11-14)
 
@@ -11,7 +11,22 @@
 |---|--------|----------|
 | D1 | 团队分润池比例(成本中切给团队的比例 T%)定义层级 | **所属体系配置层**(按所属体系/运营体系维度配置;未配置时回退全局默认 T0) |
 | D2 | 团队分润池内部如何分配 | **沿用 `referral_tree` 层级树 + 销售档位**(与直推档位体系一致的瓜分规则) |
-| D3 | 平台抽成(成交价 10%)与推荐人基数、消费返 CF 的关系 | **抽成单列 + 消费返 CF 维持**(抽成独立入平台账,不缩小推荐人毛利基数;R1 返利照旧) |
+| D3 | 平台抽成(成交价 10%)与推荐人基数、消费返 CF 的关系 | **抽成单列 + 消费返 CF 维持**(抽成独立入平台账) |
+| **D4** | **分润基数口径(2026-11-14 14:58 用户纠正,覆盖 D3 的"不影响基数"表述)** | **base = 实付 − 平台抽成 − 消费返 CF 成本 − 商品成本**;示例 200−20−2−100 = **78 元**。平台抽成与消费返 CF 都是要先扣的成本,不是"不参与基数" |
+| **D5** | **推荐人销售奖基数** | **= 扣减后可分配额 base(78)**,直推/间推档位在 base 上分(78×70%=54.6 / 78×20%=15.6);不再是原始毛利 100 的 70%/20% |
+| **D6** | **分享注册(+20CF/日限10)归属** | **不属于分润体系**,从分润表剔除(独立获客激励,不参与订单分账) |
+| **D7** | **通道命名消歧** | 用户口径中"R2 = 团队分润",与文档原 R2=推荐人销售奖冲突 → 文档正文改用**语义通道名**(平台抽成 / 消费返 CF / 推荐人销售奖 / 团队分润池 / 平台留存),附编号对照表 |
+
+### 1.1 通道语义名与编号对照表
+
+| 用户口径 | 语义通道名 | 说明 |
+|---|---|---|
+| R0 | **平台抽成** | 成交价(实付)× 10%,先扣成本 |
+| R1 | **消费返 CF** | 实付×100 CF 返消费者,先扣成本 |
+| —(毛利可分配部分) | **推荐人销售奖** | 直推 ≤70% + 间推 ≤20%,基数 = base(78) |
+| **R2(用户口径)** | **团队分润池** | 成本 × T%,单独计算、独立于 base |
+| R3 | (非分润) | 分享注册奖励,移出分润体系 |
+| — | **平台留存** | base 分出后 + 成本侧切池后的余额 |
 
 > 备注:D1「所属体系」在 CFC 语境对应各小程序/运营体系维度(与多小程序配置 `miniapp_config` 体系相呼应);若此理解有偏差,以用户后续澄清为准。
 
@@ -24,33 +39,39 @@
 
 ## 3. 新模型(v2)
 
-**核心变化:分钱出口从一条(毛利 P 点)拆为三条独立通道 + 平台保底。**
+**核心变化:分钱出口从一条(毛利池)拆为四条通道 + 扣减后可分配额分账。**
 
-- **R0 平台抽成(新增)**:`平台抽成 = 成交价 × 10%`,固定单列,独立入平台收益账;**不参与、不影响任何分润基数**;与 R1 消费返 CF 并存(返利是平台给消费者的让利成本,另行列支)。
-- **R1 消费返 CF**:维持 v1 不变。
-- **R2 推荐人体系(直推 ≤70% + 间推 ≤20%)**:基数**仍 = 毛利(售价 − 成本)**,口径与 v1 的 `calcOrderPpoints` 一致;直推/间推档位表、层级树累计规则不变。(注意:v1 中「毛利池」同时隐含团队归属,v2 起毛利只服务推荐人体系。)
-- **R2' 团队分润池(新增)**:`团队池 = 成本 × T%`(T 为 D1 所述体系配置),**从成本中切出**,与毛利完全脱钩;池内按 D2 规则(`referral_tree` 层级树 + 销售档位占比)瓜分给团队成员。
-- **R3 分享注册**:维持不变。
+- **平台抽成(新增)**:`抽成 = 实付 × 10%`(`cf.platform.take_rate_bps` 默认 1000),独立入平台流水;**先从可分配额中扣除**(是成本,不是"不参与基数")。
+- **消费返 CF**:`floor(实付) × 100 CF`,返本人(F02,维持不变);**同样先从可分配额中扣除**(CF 口径 1 CF = 1 分等值,返 200 CF ≈ 2 元成本)。
+- **推荐人销售奖(直推 ≤70% + 间推 ≤20%)**:基数 = **扣减后可分配额 base**,见 §3.1;档位表、层级树累计、同家庭跳过、即时幂等规则均不变。
+- **团队分润池(新增,用户口径 R2)**:`团队池 = 成本 × T%`(T 为 D1 所述体系配置),**从成本侧切出**,与 base 完全脱钩、单独计算;商家自担成本降为(成本 − 团队池);池内按 D2 规则(`referral_tree` 层级树 + 直推档位权重)瓜分。
+- **分享注册(原 R3)**:已移出分润体系——保留为独立获客激励(+20CF/人、日限 10),**不参与订单分账**。
 
-### 3.1 账目示例(商品 A:售价 200 / 成本 100 / 体系 T=30%,假定直推 70% 档 / 间推 20% 档)
+### 3.1 账目示例(商品 A:售价 200 / 成本 100 / 体系 T=30%,直推 70% 档 / 间推 20% 档;金额单位:元)
 
-| 通道 | 计算 | 金额 | 去向 |
-|------|------|------|------|
-| R0 平台抽成 | 200 × 10% | 20 | 平台收益 |
-| R2 直推销售奖 | (200−100) × 70% | 70 | 直推上线 |
-| R2 间推销售奖 | (200−100) × 20% | 20 | 间推上线 |
-| R2' 团队池 | 100 × 30% | 30 | 团队内按层级+档位再分 |
-| R1 消费返 CF | floor(200) × 100 CF | 200 CF | 消费者本人 |
-| 剩余 | 200 − 20 − 70 − 20 − 30 | 60 | 商家/平台交付成本与利润 |
+| 顺序 | 通道 | 计算 | 金额 | 去向 |
+|---|---|---|---|---|
+| ① | 平台抽成 | 200 × 10% | **20** | 平台留存 |
+| ② | 消费返 CF | floor(200) × 100 = 200 CF | **200 CF(≈2 元)** | 消费者本人(成本) |
+| ③ | 商品成本 | — | 100 | 成本支出(其中 30 切出为团队池) |
+| — | **推荐人销售奖基数 base** | 200 − 20 − 2 − 100 | **78** | — |
+| ④ | 直推销售奖 | 78 × 70% | **54.6** | 直推上线 |
+| ⑤ | 间推销售奖 | 78 × 20% | **15.6** | 间推上线 |
+| ⑥ | 团队分润池 | 100 × 30% | **30** | 团队成员(成本侧切出,商家自担成本降为 70) |
+| ⑦ | 平台留存 | 200 − 2 − 54.6 − 15.6 − 70 | **57.8** | 平台/商家 |
+| — | ~~分享注册 +20CF~~ | 独立获客激励 | — | **不在订单分账内** |
+
+> 要点:**推荐人基数不是原始毛利 100,是扣减抽成与返 CF 后的 78**;团队池 30 不从 78 扣,而是从成本 100 侧切出。
 
 ### 3.2 与 v1 的关键差异
 
 | 维度 | v1 | v2 |
 |------|----|----|
-| 团队分佣来源 | 隐含在毛利池(直推/间推占毛利) | **独立成本池**(成本 × T%),与毛利脱钩 |
-| 平台收益 | 毛利剩余(无固定通道) | **固定 = 成交价 × 10%**,单列、永不参战 |
-| 推荐人基数 | 毛利(P 点) | 毛利(P 点),**不变** |
-| 消费返 CF | 有 | **维持** |
+| 团队分佣来源 | 隐含在毛利池 | **独立成本池**(成本 × T%),与 base 脱钩 |
+| 推荐人基数 | 毛利(实付−成本,如 100) | **扣减后可分配额 base**(实付 − 抽成 − 返CF − 成本,如 78) |
+| 平台收益 | 毛利剩余 | **固定抽成 10% + 扣减后留存 57.8** |
+| 消费返 CF | 有 | 维持,且**计入分润前扣减项** |
+| 分享注册奖励 | 列在分润体系内(R3) | **移出分润体系**(独立获客激励) |
 
 ## 4. 影响范围(供架构师细化)
 
@@ -58,7 +79,7 @@
    - 新增团队池比例配置:体系维度(对应 D1)——候选:商品/品类表加 `team_share_percent`(若兼容体系维度则加体系字段)或独立配置表 `cf_team_share_config`(owner 维度 = 所属体系)
    - 新增团队池流水:`cf_team_pool`(每单:订单/金额 T%/池金额/分配状态)或复用 `cf_balance_log` 新增 type 标记
    - `cf_earn_rule` 可登记新规则码(如 F03c 团队池),沿用统一发放/幂等链路
-2. **计算拆分**:`CfCommissionService.settle`(126-146)结算时分叉——①推荐人链路(毛利,原样)②团队池链路(成本×T%,新)③平台抽成(新,独立记账);`calcOrderPpoints`(344-396)毛利口径保留
+2. **结算顺序**(`CfCommissionService.settle`,同一事务):① 平台抽成记账 → ② 消费返 CF 发放(F02,已有 earnBuyerCf)→ ③ 计算 base = 实付 − 抽成 − 返CF成本 − 成本 → ④ 推荐人销售奖(base×直推档位% / base×间推档位%;base<0 时按 0,不得为负)→ ⑤ 团队池独立计算(成本×T%)并瓜分。`calcOrderPpoints` 毛利口径**保留作 v1 兼容/审计参照**,新发放以 base 为准。
 3. **配置**:新配置键(如 `cf.team_pool.share_rate` 体系级默认 + `cf.platform.service_rate`=1000bps),进 `service_config` + `CfConfigKeys`
 4. **层级瓜分**:`CfReferralService.matchDirectSalesTier`(134)/ `matchIndirectSalesTier`(149)复用方式设计(瓜分 = 档位占比加权)
 5. **前端展示**:小程序推广页/财富页团队池展示(可后置);后台档位配置(G2 顺带收口)
@@ -69,7 +90,7 @@
 2. 结算链路分叉:`CfCommissionService` 增加团队池计算与平台抽成记账(幂等/同家庭跳过/并发防护沿用现有模式)
 3. 团队池瓜分:层级树快照 + 档位占比加权分配(参照 `materializeReferralTree` 69-97)
 4. 配置端:`CfConfigKeys` + service_config 播种(CfRuleConfig.vue 扩展可选)
-5. 测试:账目示例断言(200/100 例)+ 多商品分摊 + 亏损仓 + 团队池幂等 + 同家庭跳过
+5. 测试:账目示例断言(200/100 例:base=78、直推54.6、间推15.6、池30、抽成20、返CF 200CF、留存57.8)+ 多商品分摊 + 亏损仓(base<0 → R2=0、团队池照发)+ 团队池幂等 + 同家庭跳过 + R3 不参与分账
 
 ## 6. 关联事项
 

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
docs/tdd/orchestration/pending.md


+ 116 - 0
docs/tdd/orchestration/report-dev-2026-11-14-1305.md

@@ -0,0 +1,116 @@
+# 研发负责人工作汇报(第 175 轮 · 2026-11-14 13:05)
+
+> 本轮为用户手动触发的专项收割轮:**在途 70 会话证据回收**。核心发现并修复一处链路缺口(`--auto` 证据认领缺 `backfill` 前置换手),随后完成 43 条回填、23 键 RED→YELLOW 认领、26 条派发,池空收敛。
+
+## 基本信息
+
+| 项目 | 内容 |
+|------|------|
+| 汇报人 | 研发负责人(专项轮 · 第 175 轮) |
+| 汇报周期 | 2026-11-14 12:36 ~ 13:05 |
+| 负责模块/单元范围 | CFC(E:/cfc,主分支 `origin/cfclub`) |
+
+## 一、本轮工作数据汇总
+
+### 1.0 场景覆盖矩阵(硬职责增列)
+
+| 指标 | 值 | 说明 |
+|------|-----|------|
+| 场景总数 | 241 | `sc-key-map.md` 唯一 SC 行 241(零漂移) |
+| 累计已跑通 | 15 | 三判据齐备(O-3 收口 5 + 游客域补证 10) |
+| 本轮新增跑通 | 0 | 能力键推进 ≠ SC 跑通;转正须测试侧三类流证据 + mark-green,dev 不代越 |
+| 失败 | 0 | — |
+| 阻塞 | 0 | — |
+| 未跟踪 | 226 | 241 − 15 |
+
+### 1.1 问题接收与处置
+
+| 指标 | 数量 | 说明 |
+|------|------|------|
+| 收到的测试反馈问题数 | 0 | 无新增 blockers |
+| 已解决问题数 | 0 | — |
+| 待解决研发问题数 | 0 | — |
+| **链路缺陷(本轮发现)** | 1 | `--auto` 认领分支依赖 `done` 任务,而 dispatched→done 需 `dispatch.py --backfill` 前置;协议未固化该步骤 → RED 契约在途证据长期无法认领(174 轮「在途 70」根因) |
+
+### 1.2 任务分发与会话管理
+
+| 指标 | 数量 | 说明 |
+|------|------|------|
+| 分派任务数 | **26** | S-cfc-348 / 322 / 328 / 193 / 349~370;5 轮 `--auto` 全部成功核验,0 失败 |
+| 恢复智能体会话数 | 0 | `--inspect` 全节点 interrupted=0 |
+| 当前在用会话数 | 52 在途任务 / 5 节点 | lan-189、lan-204、local-lan-4090、mes-local-45278、pub-iwintrue 全部承载 |
+| 会话平均利用率 | ~70% | 各节点配额 4~10,本轮 5 轮派发基本打满 |
+
+#### 1.2b 各会话当前工作状态列表(必填)
+
+| 实例 | 状态 | 配额占用/上限 | 本轮承载 |
+|------|------|---------------|----------|
+| lan-189 | running | 4/4 | S-cfc-348/322/350/355/356/359/360/361/367 等 |
+| lan-204 | running | 10/10 | S-cfc-328/351/357/363/368 等 |
+| local-lan-4090 | running | 6/6 | S-cfc-193/349/352/358/361/364/369 等 |
+| mes-local-45278 | running | 4/4 | S-cfc-353/359/360/365/367/370 等 |
+| pub-iwintrue | running | 2/6 | S-cfc-354/366 等(部署会话并存) |
+
+无 wedged/swallowed/stalled/upstream_err/thrash。
+
+### 1.3 变更感知(`--changes --days 7`)
+
+- 200+ 提交;**0 新增能力键**(O-7 无 `--add-task` 候选)。
+- 窗口内新入链产物:S-cfc-347 RED 契约单测(2cd8bcca2,20 例预期红,源自任务日提醒设计契约 c56ea68ab);家庭运营工作台 merge(3c1a30dcf);R199 测试侧全量实测(1170e5023,463 类/4620 例 89.38%)。
+
+### 1.4 git 证据(只认已推远端)
+
+**本轮回填 43 条 → 二次 `commit_kind` 校验冲销 2 条修正 4 条 → 41 条真实 test/main 证据生效**:
+
+| 类别 | 数量 | 代表 sha |
+|------|------|----------|
+| RED 契约(kind=test,含 merge 形式) | 37 | da78baef(S-193)、786d8614(S-246)、9add35dd(S-248)、c33f3d59(S-251)、f99bdfc6(S-258)、b745b8d7(S-262)、1f18cb65(S-267)、fb84460c(S-272)、94c8b335(S-281)、9ca317cf(S-293)、430cf73a(S-296)、ffa0c245(S-301)、1a107929(S-308) … |
+| 修正(docs 误配 → 真实 test 证据) | 2 | S-cfc-298 → 4f84c6846b;S-cfc-299 → 08304b0c7b |
+| 冲销(docs 汇报冒充,保持 done 不认证据) | 4 | S-cfc-283(160b9bd1 docs 台账) / S-cfc-309(d9feae44 轮汇报) / S-cfc-322(fc6bebfc 验证报告) / S-cfc-328(5f326855 验证报告) |
+| 任务日提醒链 | 2 | S-cfc-347 RED → 2cd8bcca2(test ✓);GREEN 续建 S-cfc-348 已派发 |
+
+> 冲销原则:`docs/` 类提交(汇报/台账/验证报告)不含 `/src/test/` 或 `/src/main/`,**不得作为 RED/YELLOW 证据**(沿用 2026-11-06 `d76d24dca` 裁决口径)。
+
+### 1.5 状态机与任务账本
+
+| 账本 | 本轮前 | 本轮后 | 变化 |
+|------|--------|--------|------|
+| 状态机 211 键 | RED 65 / YELLOW 123 / GREEN 8 / DONE 15 | **RED 42 / YELLOW 146 / GREEN 8 / DONE 15** | **RED→YELLOW 23 键** |
+| 任务账本 348 | done 254 / dispatched 70 / failed 24 / pending 0 | **done 295 / dispatched 52 / failed 24 / pending 0** | done +41,在途 70→52(净 +23 条续建 GREEN 已派发) |
+| 部署 | pending 41 / confirmed 77 | pending 41 / confirmed 77 | YELLOW✓ 标注触发部署派发,均去重命中已有 pending(`_already_pending`),无新增 |
+
+## 二、本轮动作(按顺序)
+
+1. **互斥门**:state.json mtime 11:40:44,距今 55 分钟 > 5 分钟 → PROCEED。
+2. **机制定位**:读 `tdd_orchestrate.py` L4980–5100 认领分支 + `.workbuddy/dispatch.py` L352–412,确认链路为 `dispatch.py --backfill`(dispatched→done)→ `--auto`(done 证据认领 RED→YELLOW)。原版 backfill 逐任务 grep(70×2 次 git log)超时被杀,改**批量等价实现**(一次 `git log -n800` 内存映射)。
+3. **回填 + 质检**:43 条命中;逐条 `git show --name-only` 判 kind,冲销 4 条 docs 误配、修正 2 条真实证据;账本备份 `tasks.json.bak-reclaim-1135`。
+4. **证据认领(5 轮 `--auto`,每轮 600s 预算)**:
+   - 第 1 轮:S-cfc-347 认领 → `task.reminder.daily` RED→YELLOW → 续建 GREEN **S-cfc-348** 派发 lan-189;
+   - 第 2 轮:**23 键 RED→YELLOW**(RED✓ sha 全链标注)+ 续建 22 条 GREEN 入池(S-cfc-349~370)+ S-cfc-322/328/193 重派;
+   - 第 3~5 轮:批量派发 GREEN 实现任务,直至 **pending 0**;
+   - YELLOW✓ 标注批:family.invite.* 5 键、team.* 9 键(invitation.create / profit.settle / profit.hook / profit.cron / admin.manage / user.portal / star.myView / star.digest / profit.pipeline / star.multiplier)等,全部 sha `merge-base --is-ancestor` ✓。
+5. **派发明细**:S-cfc-348、322、328、193、349~370 共 **26 条 / 5 节点 / 0 失败**,各独立 worktree `.worktrees/task_S-cfc-*`。
+6. **副作用核查**:确认 `S-cfc-322/328` 重派(冲销 docs 证据后触发 requeue)为预期行为——两键实现已在主线,GREEN 会话将以「实现已存在、零改动闭环」形式产出验证;S-cfc-309 requeue_count=3 未 exhausted、S-cfc-283 无 requeue 标记,正常。
+
+## 三、阻塞与风险
+
+- **P0 机制缺陷(需固化)**:`--auto` 本身**不做** dispatched→done 回填,缺 `backfill` 换手时 RED 契约证据永久无法认领(174 轮在途 70 积压根因)。建议在 CFC_PROTOCOL 的步骤 2 前显式加入「`dispatch.py --backfill`(或等价批量回填)」,并评估将该逻辑并入 `--auto` 入口。
+- `--clean-check` exit=1(1040 处他人 WIP,O-3 判据③按 SC 关联提交级核验,零交集不阻塞)。
+- 部署 pending 41 待测试/部署侧 `--deploy-done` 收口(O-24,dev 不代越)。
+- S-cfc-309(teacher.report.proxy)requeue_count=3 临界,若再 requeue 即 exhausted + needs_human。
+
+## 四、下一步
+
+1. 52 条在途会话产出回收:下轮先 `backfill` 再 `--auto`,认领 23 键的 GREEN 实现证据 → YELLOW✓ → 部署验证。
+2. 部署 pending 41 回执收口(O-24,部署侧职责)。
+3. S-cfc-283(contact.card.manage)契约缺失——真实 test 提交未入主线,需会话补交付或从 task 分支合入。
+4. SC 转正:23 键实现变绿后,由测试侧补三类流证据 + mark-green(dev 不代越)。
+5. 池空稳态保持(0 `--add-task`);无新增能力键,防假红。
+
+## 附:铁律遵守自检
+
+- 未 `git add -A`、未 checkout/stash/force-push 他人改动 ✓
+- 汇报走临时索引单文件方案 ✓
+- `.tdd/` 本地私有禁推;base 备份 `tasks.json.bak-reclaim-1135` 本地留存 ✓
+- 只认 git 真理证据:41 条均经 `commit_kind` 二次核验(test/main),docs 类一律冲销 ✓
+- 临时文件 `.workbuddy/_cf_commits.txt` 为本轮工具输入,不纳入版本库 ✓

+ 159 - 0
docs/tdd/orchestration/report-dev-2026-11-14-1459.md

@@ -0,0 +1,159 @@
+# 研发负责人工作汇报(第176轮 · 互斥跳过轮)
+
+> **说明**:本汇报为无人值守定时收割(自动化 id 5c32f42a,14:47 触发档)产物。本轮命中并发互斥门——他轮 `--auto`(pid 48840,14:48 启动)正在产出,按 S1 纪律**零副作用**(不写盘、不派发、不恢复),仅只读观察 + 汇报。
+
+---
+
+## 基本信息
+
+| 项目 | 内容 |
+|------|------|
+| 汇报人 | 研发负责人(自动化收割 · 无人值守) |
+| 汇报周期 | 2026-11-14 14:47 ~ 2026-11-14 14:59(第 176 轮) |
+| 负责模块/单元范围 | 全量(E:/cfc,主线 origin/cfclub) |
+
+---
+
+## 一、本轮工作数据汇总
+
+### 1.0 场景覆盖矩阵(硬职责增列)
+
+> **单一计数源**:`docs/tdd/orchestration/sc-key-map.md`(241 个 SC 全量台账)。「跑通」三判据:① 三类流执行证据齐备;② 对应实现与用例已 push `origin/cfclub`;③ 该场景 `--clean-check` 退出码为 0。能力键 DONE ≠ 场景已跑通。
+
+| 指标 | 值 | 说明 |
+|------|-----|------|
+| 场景总数 | 241 | 以 `sc-key-map.md` 全量台账为唯一计数源 |
+| 累计已跑通 | 15 | 与上轮(12:41)同态,无漂移 |
+| 本轮新增跑通 | 0 | 互斥跳过轮,无场景转正(转正须测试侧三类流证据 + mark-green,dev 不代越) |
+| 失败 | 0 | — |
+| 阻塞 | 0 | — |
+| 未跟踪 | 226 | = 241 − 15 − 0 − 0 |
+
+### 1.1 问题接收与处置
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| 收到的测试反馈问题数 | 0 | 互斥跳过轮,未接收新测试反馈 |
+| 已解决问题数 | 0 | 互斥跳过轮,零副作用 |
+| 待解决研发问题数 | 0 | 无新增待办 |
+
+### 1.2 任务分发与会话管理
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| 分派任务数 | 0 | 互斥跳过轮,**不派发** |
+| 恢复智能体会话数 | 0 | 互斥跳过轮,**不恢复** |
+| 当前在用会话数 | 1 | 他轮 `--auto`(pid 48840)正在产出(互斥命中根因) |
+| 会话平均利用率 | — | 互斥跳过轮未逐一枚举会话(避免与他轮争抢) |
+
+### 1.2b 各会话当前工作状态列表
+
+> 互斥命中轮,未逐一枚举各智能体会话(他轮 `--auto` 正在运行,若再跑 `--inspect` 会与他轮争抢资源)。仅记录已确认的他轮在产事实。
+
+| 会话标签 | 会话 ID | 当前状态 | 当前任务 | 静默/空闲时长 | 工作目录 | 备注 |
+|----------|---------|----------|----------|---------------|----------|------|
+| 研发负责人(他轮 --auto) | pid 48840 | running | 标准闭环收割(RED→YELLOW 自动推进) | 14:48 启动,已运行 ~11min | 不干净(1045 处他人 WIP) | 互斥命中根因;其 RED→YELLOW 推进中(见 1.5) |
+
+### 1.3 变更感知与新任务建立
+
+| 指标 | 数量 | 填写说明 |
+|------|------|----------|
+| 感知需求/设计变更数 | 0 | 互斥跳过轮未完整跑 `--changes`(他轮在产);引用上轮口径 0 新增能力键(O-7 无合法 --add-task 候选) |
+| 新增入池任务数 | 0 | 互斥跳过轮,**不建任务** |
+| 既有键状态流转数 | 23 | 观察到他轮 `--auto` 基于 git 证据自动推进(RED→YELLOW ×23),非本机登记 |
+
+### 1.4 本轮 git 提交与推送证据(真理证据)
+
+> 互斥跳过轮零提交、零 push。下列为**观察到他轮/其他会话**在 12:41 后合入主线的证据(仅记录,非本机动作)。
+
+| 提交 sha | 关联任务/能力键 | 阶段 | 触及文件类型 | 目标分支 | 已推送远端 | 备注 |
+|----------|-----------------|------|--------------|----------|------------|------|
+| 825f73305 | S-cfc-358(family.relationGraph) | YELLOW | 实现 | origin/cfclub | 是 | 补齐 pairwise 契约缺口使红转绿 |
+| d363df805 | SC-H-05(butler.resource.match) | YELLOW | 实现 | origin/cfclub | 是 | 实现资源匹配服务 |
+| 8496aafdf | S-cfc-352(ai.coach.handoffPlanner) | YELLOW | 实现 | origin/cfclub | 是 | 教练转接成长规划师编排服务使绿 |
+| 22f3fdd5d | S-cfc-349(teacher.client.export) | YELLOW | 实现 | origin/cfclub | 是 | 客户档案数据导出(合规)使绿 |
+| bbadd0ad4 | 后台密度二期 | — | 实现 | origin/cfclub | 是 | 合入主线,清旧密度硬编码 |
+
+### 1.5 状态机阶段分布与部署状态
+
+> 用 `--status` 核对(14:53 只读快照)。
+
+| 阶段 | 键数 | 备注 |
+|------|------|------|
+| UNTRACKED | 0 | — |
+| RED | 42 | 上轮 65 → 42(他轮推进 23 键,待开发实现) |
+| YELLOW | 146 | 上轮 123 → 146(23 键待测试校验) |
+| GREEN | 8 | 与上轮同态 |
+| DONE | 15 | 与上轮同态 |
+
+| 部署项 | 值 | 说明 |
+|--------|-----|------|
+| 本轮部署次数 | 0 | 互斥跳过轮零副作用 |
+| 最新 `last_deployed` | 沿用上轮 | 未变动(他轮在产,不干预) |
+| GREEN 部署链验证 | 不通过(本轮未核) | 部署 pending 41 / confirmed 77,与上轮同态 |
+
+### 1.6 数据源健康度
+
+| 数据源 | 读取键数 | 是否正常 | 备注 |
+|--------|----------|----------|------|
+| 需求侧(⏳ wish) | 0 | 正常 | 目标池显示 ⏳ 键 0 |
+| 契约常量(contract) | 0 | 正常 | — |
+| 测试用例引用(test_case_map) | 0 | 正常 | — |
+| 执行结果回填(case_results) | 0 | 正常 | — |
+
+---
+
+## 二、下一步需要研发处理的事项
+
+| 序号 | 事项描述 | 依赖/前置条件 | 预估完成时间 | 优先级 | 状态 |
+|------|----------|---------------|--------------|--------|------|
+| 1 | 待他轮 `--auto`(pid 48840)收口后,核对其 RED→YELLOW 23 键推进证据与 state.json 一致性 | 他轮结束 | 2026-11-14 15:10 | P1 | 待启动 |
+| 2 | S-cfc-347(task.reminder.daily)RED 契约产出(2cd8bcca2)red_sha 认领 → 自动续建 GREEN | 下轮 --auto 扫描 | 2026-11-14 16:00 | P1 | 待启动 |
+| 3 | 部署 pending 41 回执收口(O-24) | 部署会话回报 | 2026-11-14 18:00 | P1 | 待启动 |
+| 4 | S-cfc-309 req# 临界观察 | — | 2026-11-14 18:00 | P2 | 进行中 |
+| 5 | 池空稳态 0 --add-task(O-7/O-25 防假红) | 无合法溯源 | 持续 | P2 | 进行中 |
+
+---
+
+## 三、阻塞与风险
+
+| 阻塞项 | 影响范围 | 阻塞类型 | 处置进展 | 需要协调 |
+|--------|----------|----------|----------|----------|
+| 互斥命中:他轮 --auto(pid 48840)在产 | 本轮派发 | 测试 | 已跳过(S1 纪律,零副作用) | 无,他轮自主收口 |
+| 工作目录不干净(1045 处他人 WIP) | 证据核验 | 研发 | O-3 判据③零交集不阻塞 | 无 |
+
+---
+
+## 四、下轮工作规划
+
+| 计划项 | 预计完成时间 | 前置依赖 | 涉及会话标签 | 备注 |
+|--------|--------------|----------|--------------|------|
+| 标准闭环收割(S1-S11) | 2026-11-14 18:00 档 | 他轮收口 | 研发负责人 | 下轮(18:00)恢复标准收割 |
+| S-cfc-347 red_sha 认领 + 续建 GREEN | 2026-11-14 18:00 | 下轮 --auto | 研发负责人 | 若下轮仍未认领查 tid 匹配路径 |
+| 在途会话证据回收 | 2026-11-14 18:00 | — | 研发负责人 | 部署 pending 41 回执 |
+
+---
+
+## 附录
+
+### 附录 A:收到的测试反馈问题明细
+
+互斥跳过轮,无新增。
+
+### 附录 B:任务分派与会话恢复明细
+
+互斥跳过轮,无派发、无恢复。
+
+### 附录 C:新增入池任务明细
+
+互斥跳过轮,无新增。
+
+### 附录 D:变更感知明细(来自 `--changes`)
+
+互斥跳过轮未完整跑 `--changes`;引用上轮口径 0 新增能力键。主线 12:41 后新增 17 笔提交(实现 4 / 合并同步 2 / 构建 bump 11),均为既有键推进,0 新增能力键。
+
+---
+
+**模板版本**:v1.2(2026-11-14)
+**适用对象**:研发负责人 / 开发角色会话
+**填写周期建议**:每轮 TDD 闭环后或每日工作结束时

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.