Parcourir la source

feat(cfclub): 同步 cfclub 分支 + 恢复 BASIC 会员 + 饮食分享 + 新增指标/减脂/Sso 模块

## 本次提交包含
### 1. BASIC 会员恢复(f86dd164b 反向还原)
- 迁移360 restoreBasicMembershipLevel() 恢复 membership_levels BASIC 行 + membership_benefits 4 条 LITE 权益
- backfillMembershipLevelOrder 修正 level_order:FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6
- BasicQuotaService 恢复 refreshMonthlyQuota/isActiveBasicMember/getExpireTime
- MembershipService 恢复 BASIC canUseFeature/getMemberLevel 到期判断/listActive/配额初始化
- 新增 FOOD_RECOGNIZE 配额(3次/月),控制器层校验 FREE→403
- schema.sql 同步种子数据 + 注释

### 2. 饮食打卡分享
- diet-checkin.vue / records.vue / record-detail.vue 接入 share-mixin
- 分享链接自动带 refCode,新用户注册后自动绑定推荐人

### 3. cfclub 分支合并(~300+ 文件)
- 新模块:Indicator(指标曲线/相关性/映射)、FatDiet/FatExercise/FatGoal/FatWeight(减脂)、GutHealth(菌群健康)、SsoTicket(单点登录)、AdminTaskReminderConfig
- 前端:ContentSharePoster.vue、TrendChart.vue、indicator-curve/quicklook 页面、self-check-ai、indicator-change/sso utils
- LangGraph:indicator_classify、question_generate、report_parse 更新
- Web 管理端:IndicatorCorrelations/IndicatorMappings、api/indicator.js、utils/indicatorSeeds.js
- xiaofat-miniprogram(独立减脂小程序 Demo)纳入版本库
- docs/superpowers/plans/ & specs/ 补充完整

## 主提交
- DatabaseInitializer.java、schema.sql、BasicQuotaService.java、MembershipService.java
- DietRecordController.java、diet-checkin.vue、records.vue、record-detail.vue
- docs/superpowers/specs/2026-10-11-restore-basic-and-diet-share-design.md
- 全部 cfclub 分支新增/修改文件
Sisyphus Agent il y a 2 jours
Parent
commit
ed93c5cfc9
100 fichiers modifiés avec 8185 ajouts et 181 suppressions
  1. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  2. 203 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/GutHealthProjectController.java
  3. 118 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java
  4. 53 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminTaskReminderConfigController.java
  5. 95 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java
  6. 74 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java
  7. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java
  8. 55 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java
  9. 98 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatDietController.java
  10. 83 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatExerciseController.java
  11. 93 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatGoalController.java
  12. 87 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatWeightController.java
  13. 2 1
      cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ProductRecommendationController.java
  14. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java
  15. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportPayload.java
  16. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.java
  17. 42 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/DietFamilySelectedIngredient.java
  18. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMembership.java
  19. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FatDietRecord.java
  20. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FatExerciseRecord.java
  21. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FatGoal.java
  22. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/FatWeightRecord.java
  23. 55 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/GutHealthProject.java
  24. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java
  25. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java
  26. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java
  27. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java
  28. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java
  29. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SsoTicket.java
  30. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java
  31. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/DietFamilySelectedIngredientMapper.java
  32. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FatDietRecordMapper.java
  33. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FatExerciseRecordMapper.java
  34. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FatGoalMapper.java
  35. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FatWeightRecordMapper.java
  36. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/GutHealthProjectMapper.java
  37. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java
  38. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java
  39. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java
  40. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SsoTicketMapper.java
  41. 196 1
      cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
  42. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  43. 130 40
      cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java
  44. 53 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FatDietService.java
  45. 40 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FatExerciseService.java
  46. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FatGoalService.java
  47. 45 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FatWeightService.java
  48. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java
  49. 865 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GutHealthProjectService.java
  50. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  51. 267 0
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java
  52. 275 0
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java
  53. 370 0
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java
  54. 29 1
      cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java
  55. 22 4
      cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java
  56. 130 0
      cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java
  57. 61 11
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  58. 122 6
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java
  59. 16 3
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java
  60. 146 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java
  61. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java
  62. 95 0
      cfc-backend/src/main/java/com/etotem/cfc/service/SsoService.java
  63. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java
  64. 87 0
      cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java
  65. 9 1
      cfc-backend/src/main/resources/application.yml
  66. 33 0
      cfc-backend/src/test/java/com/etotem/cfc/config/DatabaseInitializerSeedTest.java
  67. 68 0
      cfc-backend/src/test/java/com/etotem/cfc/controller/admin/ReportParserAdminControllerTest.java
  68. 57 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.java
  69. 93 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java
  70. 100 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java
  71. 30 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java
  72. 86 0
      cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.java
  73. 92 0
      cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java
  74. 67 0
      cfc-backend/src/test/java/com/etotem/cfc/service/ReportSummaryServiceCompletenessTest.java
  75. 60 0
      cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java
  76. 30 2
      cfc-frontend/App.vue
  77. 75 0
      cfc-frontend/__tests__/api/api.indicator.test.js
  78. 64 0
      cfc-frontend/__tests__/utils/indicator-change.test.js
  79. 193 21
      cfc-frontend/components/ContentSharePoster.vue
  80. 302 0
      cfc-frontend/components/TrendChart.vue
  81. 12 0
      cfc-frontend/pages.json
  82. 20 1
      cfc-frontend/pages/activity/activity-detail/activity-detail.vue
  83. 571 0
      cfc-frontend/pages/family/self-check-ai.vue
  84. 4 4
      cfc-frontend/pages/health-main/index.vue
  85. 118 2
      cfc-frontend/pages/health/gut-flora-detail.vue
  86. 444 0
      cfc-frontend/pages/health/indicator-curve.vue
  87. 154 0
      cfc-frontend/pages/health/indicator-quicklook.vue
  88. 23 4
      cfc-frontend/pages/health/report-list.vue
  89. 75 29
      cfc-frontend/pages/index-home/index.vue
  90. 35 0
      cfc-frontend/pages/profile/index.vue
  91. 20 0
      cfc-frontend/utils/api.js
  92. 64 0
      cfc-frontend/utils/indicator-change.js
  93. 34 0
      cfc-frontend/utils/sso.js
  94. 21 8
      cfc-langgraph/app/agents/report_parse_agent.py
  95. 64 0
      cfc-langgraph/app/api/indicator_classify.py
  96. 89 0
      cfc-langgraph/app/api/question_generate.py
  97. 4 2
      cfc-langgraph/app/api/report_parse.py
  98. 442 0
      cfc-langgraph/app/graphs/question_generation_graph.py
  99. 2 1
      cfc-langgraph/app/main.py
  100. 27 37
      cfc-langgraph/deploy.sh

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

@@ -34,6 +34,7 @@ private static final String[] PUBLIC_PATHS = {
         "/api/auth/phone-login",
         "/api/auth/wechat-phone-login",
         "/api/auth/silent-login",
+        "/api/auth/sso-login",
         "/api/auth/auto-login",
         "/api/auth/register-with-idcard",
         "/api/auth/wechat-login",

+ 203 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/GutHealthProjectController.java

@@ -0,0 +1,203 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.GutHealthProjectService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 菌群健康调研项目(单次付费问卷)
+ *
+ * 业务流程:
+ * 1. 用户付费 19.9 元创建项目
+ * 2. 填写健康问卷(睡眠/饮食/体重/消化等菌群相关指标)
+ * 3. 生成健康建议 + 风险评分 + 一周行动任务
+ * 4. 用户获得「会员价购买菌群检测」+「一年家庭会员」资格(需手动激活)
+ * 5. 用户在小程序打卡完成一周任务后,返还同等金额的 CF 值
+ */
+@Tag(name = "菌群健康调研项目", description = "单次付费问卷 + 行动打卡 + 奖励体系")
+@RestController
+@RequestMapping("/api/gut-health")
+public class GutHealthProjectController {
+
+    @Resource
+    private GutHealthProjectService gutHealthProjectService;
+
+    /**
+     * 查询当前用户的项目状态
+     */
+    @Operation(summary = "查询项目状态")
+    @PostMapping("/project/status")
+    public Result<Map<String, Object>> getProjectStatus(@RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        return Result.success(gutHealthProjectService.getProjectStatus(userId));
+    }
+
+    /**
+     * 创建付费订单
+     */
+    @Operation(summary = "创建项目付费订单")
+    @PostMapping("/project/pay")
+    public Result<Map<String, Object>> createOrder(@RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        try {
+            return Result.success(gutHealthProjectService.createProjectOrder(userId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 支付回调(微信/虚拟支付回调后调用)
+     */
+    @Operation(summary = "支付回调")
+    @PostMapping("/project/pay-notify")
+    public Result<Boolean> payNotify(@RequestBody Map<String, String> params) {
+        String orderNo = params.get("orderNo");
+        String transactionId = params.get("transactionId");
+        String payMethod = params.get("payMethod");
+        if (orderNo == null || transactionId == null || payMethod == null) {
+            return Result.error(400, "缺少必要参数");
+        }
+        return Result.success(gutHealthProjectService.processPaymentCallback(orderNo, transactionId, payMethod));
+    }
+
+    /**
+     * 提交问卷答案,生成健康建议 + 行动任务
+     */
+    @Operation(summary = "提交问卷")
+    @PostMapping("/project/submit")
+    public Result<Map<String, Object>> submitQuestionnaire(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> answers = (List<Map<String, Object>>) params.get("answers");
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        if (answers == null || answers.isEmpty()) {
+            return Result.error(400, "请完整填写问卷答案");
+        }
+        try {
+            return Result.success(gutHealthProjectService.submitQuestionnaire(userId, projectId, answers));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 获取行动任务列表
+     */
+    @Operation(summary = "获取行动任务")
+    @PostMapping("/project/tasks")
+    public Result<List<Map<String, Object>>> getActionTasks(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        return Result.success(gutHealthProjectService.getActionTasks(userId, projectId));
+    }
+
+    /**
+     * 打卡某个任务
+     */
+    @Operation(summary = "任务打卡")
+    @PostMapping("/project/task/checkin")
+    public Result<Map<String, Object>> checkinTask(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        int taskIndex = params.get("taskIndex") != null ? ((Number) params.get("taskIndex")).intValue() : -1;
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        if (taskIndex < 0) {
+            return Result.error(400, "缺少 taskIndex");
+        }
+        try {
+            return Result.success(gutHealthProjectService.checkinTask(userId, projectId, taskIndex));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 获取打卡进度
+     */
+    @Operation(summary = "获取打卡进度")
+    @PostMapping("/project/checkin-status")
+    public Result<Map<String, Object>> getCheckinStatus(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        return Result.success(gutHealthProjectService.getCheckinStatus(userId, projectId));
+    }
+
+    /**
+     * 购买菌群检测后激活一年家庭会员
+     */
+    @Operation(summary = "购买后激活家庭会员")
+    @PostMapping("/project/activate-after-purchase")
+    public Result<Map<String, Object>> activateAfterPurchase(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        try {
+            return Result.success(gutHealthProjectService.activateAfterPurchase(userId, projectId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /**
+     * 获取菌群检测会员价购买凭证
+     */
+    @Operation(summary = "获取会员价购买凭证")
+    @PostMapping("/project/discount-voucher")
+    public Result<Map<String, Object>> getDiscountVoucher(
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> params) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        Long projectId = params.get("projectId") != null ? ((Number) params.get("projectId")).longValue() : null;
+        if (projectId == null) {
+            return Result.error(400, "缺少 projectId");
+        }
+        try {
+            return Result.success(gutHealthProjectService.getMicrobeDiscountVoucher(userId, projectId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+}

+ 118 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java

@@ -0,0 +1,118 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.IndicatorCorrelationService;
+import com.etotem.cfc.service.IndicatorTrendService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/indicator")
+public class IndicatorController {
+
+    @Resource
+    private IndicatorTrendService indicatorTrendService;
+
+    @Resource
+    private IndicatorCorrelationService indicatorCorrelationService;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    /** 校验 subjectId 属于当前用户家庭(familyId 由 JwtInterceptor 注入,必存在) */
+    private Result<Void> checkSubjectBelongs(Long subjectId, Long familyId) {
+        if (subjectId == null) {
+            return Result.error("subjectId不能为空");
+        }
+        FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getId, subjectId)
+                .eq(FamilyMember::getFamilyId, familyId)
+                .last("LIMIT 1"));
+        if (member == null) {
+            return Result.error("成员不属于当前家庭");
+        }
+        return null;
+    }
+
+    /** 指标曲线(设计 6.1) */
+    @PostMapping("/trend")
+    public Result<?> trend(@RequestBody Map<String, Object> params,
+                           @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        @SuppressWarnings("unchecked")
+        List<Object> rawIds = (List<Object>) params.get("definitionIds");
+        List<Long> definitionIds = new ArrayList<>();
+        if (rawIds != null) {
+            for (Object o : rawIds) {
+                if (o instanceof Number) {
+                    definitionIds.add(((Number) o).longValue());
+                } else if (o != null) {
+                    definitionIds.add(Long.valueOf(o.toString()));
+                }
+            }
+        }
+        return Result.success(indicatorTrendService.trend(subjectId, definitionIds, familyId));
+    }
+
+    /** 变化幅度排名(设计 6.2) */
+    @PostMapping("/change-ranking")
+    public Result<?> changeRanking(@RequestBody Map<String, Object> params,
+                                   @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        String reportType = params.get("reportType") != null ? params.get("reportType").toString() : null;
+        String category = params.get("category") != null ? params.get("category").toString() : null;
+        String scope = params.get("scope") != null ? params.get("scope").toString() : "adjacent";
+        String orderBy = params.get("orderBy") != null ? params.get("orderBy").toString() : "magnitude";
+        Integer limit = params.get("limit") != null
+                ? Integer.valueOf(params.get("limit").toString()) : 50;
+        return Result.success(indicatorTrendService.changeRanking(
+                subjectId, reportType, category, scope, orderBy, limit, familyId));
+    }
+
+    /** 指标速查(设计 6.3) */
+    @PostMapping("/quick-lookup")
+    public Result<?> quickLookup(@RequestBody Map<String, Object> params,
+                                 @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        String category = params.get("category") != null ? params.get("category").toString() : null;
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 30;
+        return Result.success(indicatorTrendService.quickLookup(subjectId, category, page, pageSize, familyId));
+    }
+
+    /** 指标关联查询(设计 6.4) */
+    @PostMapping("/correlation/list")
+    public Result<?> correlationList(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Long definitionId = com.etotem.cfc.util.ParamUtils.getLong(params.get("definitionId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        return Result.success(indicatorCorrelationService.listRelations(subjectId, definitionId));
+    }
+}

+ 53 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminTaskReminderConfigController.java

@@ -0,0 +1,53 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.TaskReminderConfigDTO;
+import com.etotem.cfc.service.TaskReminderConfigService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+
+@RestController
+@RequestMapping("/api/admin/task/reminder-config")
+public class AdminTaskReminderConfigController {
+
+    @Resource
+    private TaskReminderConfigService taskReminderConfigService;
+
+    @PostMapping("/get")
+    public Result<TaskReminderConfigDTO> getConfig(@RequestParam("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("缺少 userId");
+        }
+        return Result.success(taskReminderConfigService.getConfig(userId));
+    }
+
+    @PostMapping("/save")
+    public Result<Void> saveConfig(@RequestBody AdminTaskReminderConfigSaveDTO dto) {
+        if (dto == null || dto.getUserId() == null) {
+            return Result.error("缺少 userId");
+        }
+        try {
+            taskReminderConfigService.saveConfig(dto.getUserId(), dto);
+            return Result.success(null);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    public static class AdminTaskReminderConfigSaveDTO extends TaskReminderConfigDTO {
+        private Long userId;
+
+        public Long getUserId() {
+            return userId;
+        }
+
+        public void setUserId(Long userId) {
+            this.userId = userId;
+        }
+    }
+}

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.controller.admin;
+
+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.IndicatorCorrelation;
+import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
+import com.etotem.cfc.service.IndicatorCorrelationService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/indicator/correlation")
+public class IndicatorCorrelationAdminController {
+
+    @Resource
+    private IndicatorCorrelationService indicatorCorrelationService;
+
+    @Resource
+    private IndicatorCorrelationMapper indicatorCorrelationMapper;
+
+    /** 人工定义/编辑关联对 */
+    @PostMapping("/save")
+    public Result<?> save(@RequestBody Map<String, Object> params,
+                          @RequestAttribute("userId") Long userId,
+                          @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权操作");
+        }
+        Long defA = ParamUtils.getLong(params.get("definitionIdA"));
+        Long defB = ParamUtils.getLong(params.get("definitionIdB"));
+        if (defA == null || defB == null) {
+            return Result.error("definitionIdA/definitionIdB 不能为空");
+        }
+        String direction = params.get("direction") != null ? params.get("direction").toString() : "positive";
+        BigDecimal manualStrength = params.get("manualStrength") != null
+                ? new BigDecimal(params.get("manualStrength").toString()) : BigDecimal.ZERO;
+        String description = params.get("description") != null ? params.get("description").toString() : null;
+        Boolean manualLocked = params.get("manualLocked") != null
+                && Boolean.parseBoolean(params.get("manualLocked").toString());
+        String sourceNote = params.get("sourceNote") != null ? params.get("sourceNote").toString() : null;
+        try {
+            indicatorCorrelationService.saveCorrelation(
+                    defA, defB, direction, manualStrength, description, manualLocked, sourceNote, userId);
+            return Result.success(null);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    /** 关联对分页列表(含校准状态) */
+    @PostMapping("/list")
+    public Result<Page<IndicatorCorrelation>> list(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权操作");
+        }
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
+        Page<IndicatorCorrelation> p = new Page<>(page, size);
+        indicatorCorrelationMapper.selectPage(p,
+                new LambdaQueryWrapper<IndicatorCorrelation>()
+                        .orderByDesc(IndicatorCorrelation::getUpdatedAt));
+        return Result.success(p);
+    }
+
+    /** 手动触发全校准或单对校准(单对:body 传 definitionIdA/definitionIdB 或 pairId) */
+    @PostMapping("/calibrate")
+    public Result<?> calibrate(@RequestBody Map<String, Object> params,
+                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权操作");
+        }
+        Long pairId = ParamUtils.getLong(params.get("pairId"));
+        if (pairId != null) {
+            IndicatorCorrelation pair = indicatorCorrelationMapper.selectById(pairId);
+            if (pair == null) {
+                return Result.error("关联对不存在");
+            }
+            // 单对校准通过全量校准的按对逻辑实现(此处简化为触发全量,全量内部逐对幂等)
+            int n = indicatorCorrelationService.calibrateAll();
+            return Result.success("校准完成,共更新 " + n + " 对");
+        }
+        int n = indicatorCorrelationService.calibrateAll();
+        return Result.success("校准完成,共更新 " + n + " 对");
+    }
+}

+ 74 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java

@@ -0,0 +1,74 @@
+package com.etotem.cfc.controller.admin;
+
+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.IndicatorMappingRule;
+import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
+import com.etotem.cfc.service.IndicatorMappingService;
+import com.etotem.cfc.util.ParamUtils;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/indicator/mapping")
+public class IndicatorMappingAdminController {
+
+    @Resource
+    private IndicatorMappingService indicatorMappingService;
+
+    @Resource
+    private IndicatorMappingRuleMapper indicatorMappingRuleMapper;
+
+    /** 批量导入映射种子(JSON/CSV 数组:[{rawName, definitionId?}]) */
+    @PostMapping("/import")
+    public Result<?> importSeeds(@RequestBody Map<String, Object> params,
+                                 @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权操作");
+        }
+        String sourceType = params.get("sourceType") != null ? params.get("sourceType").toString() : null;
+        if (sourceType == null || sourceType.isEmpty()) {
+            return Result.error("sourceType 不能为空");
+        }
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> seeds = (List<Map<String, Object>>) params.get("seeds");
+        if (seeds == null || seeds.isEmpty()) {
+            return Result.error("seeds 不能为空");
+        }
+        int count = indicatorMappingService.importSeeds(sourceType, seeds);
+        return Result.success("导入完成,共 " + count + " 条");
+    }
+
+    /** 映射规则分页(含未匹配 raw 的 review 列表:status=0 为 ai_fallback 待确认) */
+    @PostMapping("/list")
+    public Result<Page<IndicatorMappingRule>> list(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权操作");
+        }
+        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
+        Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
+        Page<IndicatorMappingRule> p = new Page<>(page, size);
+        LambdaQueryWrapper<IndicatorMappingRule> qw = new LambdaQueryWrapper<IndicatorMappingRule>()
+                .orderByAsc(IndicatorMappingRule::getSourceType)
+                .orderByAsc(IndicatorMappingRule::getRawName);
+        Object status = params.get("status");
+        if (status != null) {
+            qw.eq(IndicatorMappingRule::getStatus, Integer.valueOf(status.toString()));
+        }
+        Object sourceType = params.get("sourceType");
+        if (sourceType != null && !sourceType.toString().isEmpty()) {
+            qw.eq(IndicatorMappingRule::getSourceType, sourceType.toString());
+        }
+        indicatorMappingRuleMapper.selectPage(p, qw);
+        return Result.success(p);
+    }
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java

@@ -83,6 +83,10 @@ public class ReportParserAdminController {
         if (type.getDisplayName() == null || type.getDisplayName().isEmpty()) {
             return Result.error("displayName 不能为空");
         }
+        if (type.getExpectedIndicators() != null && !type.getExpectedIndicators().trim().isEmpty()
+                && !isValidJsonStringArray(type.getExpectedIndicators())) {
+            return Result.error("expectedIndicators 必须为 JSON 字符串数组");
+        }
         if (type.getId() != null) {
             type.setUpdatedAt(new Date());
             typeRegistryMapper.updateById(type);
@@ -105,6 +109,24 @@ public class ReportParserAdminController {
         return Result.success();
     }
 
+    /** 校验字符串是否为 JSON 字符串数组,如 ["总分","百分位"] */
+    private boolean isValidJsonStringArray(String json) {
+        try {
+            com.fasterxml.jackson.databind.JsonNode node = objectMapper.readTree(json);
+            if (node == null || !node.isArray()) {
+                return false;
+            }
+            for (com.fasterxml.jackson.databind.JsonNode el : node) {
+                if (!el.isTextual()) {
+                    return false;
+                }
+            }
+            return true;
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
     @PostMapping("/types/toggle")
     public Result<Void> toggleType(@RequestBody Map<String, Object> params) {
         String typeId = (String) params.get("typeId");

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.dto.RegisterWithIdCardDTO;
 import com.etotem.cfc.dto.RegisterWithInviteCodeDTO;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.SsoService;
 import com.etotem.cfc.service.UserService;
 import com.etotem.cfc.service.VerificationCodeService;
 import io.jsonwebtoken.Claims;
@@ -39,6 +40,9 @@ public class AuthController {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private SsoService ssoService;
+
     /**
      * 发送验证码
      */
@@ -336,4 +340,55 @@ public class AuthController {
         }
     }
 
+    /**
+     * 跨小程序单点登录 - 签发一次性票据(需登录)。
+     * 请求体: { "target": "train" };返回 { ticket, target }。
+     * 前端拿到 ticket 后通过 uni.navigateToMiniProgram 跳转到目标小程序的落地页。
+     */
+    @Operation(summary = "SSO 签发一次性票据")
+    @PostMapping("/sso-ticket")
+    public Result<Map<String, Object>> ssoTicket(@RequestBody Map<String, String> body,
+                                                 @RequestAttribute("userId") Long userId) {
+        if (userId == null || userId <= 0) {
+            return Result.error(401, "请先登录");
+        }
+        String target = body != null ? body.get("target") : null;
+        if (!SsoService.SYS_TRAIN.equals(target)) {
+            return Result.error("不支持的目标系统");
+        }
+        try {
+            String ticket = ssoService.issueTicket(SsoService.SYS_CFC, target, userId, userId);
+            Map<String, Object> data = new HashMap<>();
+            data.put("ticket", ticket);
+            data.put("target", target);
+            return Result.success(data);
+        } catch (Exception e) {
+            log.error("签发 SSO 票据失败: userId={}", userId, e);
+            return Result.error("签发票据失败,请稍后重试");
+        }
+    }
+
+    /**
+     * 跨小程序单点登录 - 消费一次性票据换取 cfc 登录态(公开接口)。
+     * 请求体: { "ticket": "..." }。
+     */
+    @Operation(summary = "SSO 票据换取登录态")
+    @PostMapping("/sso-login")
+    public Result<LoginResultDTO> ssoLogin(@RequestBody Map<String, String> body) {
+        String ticket = body != null ? body.get("ticket") : null;
+        if (ticket == null || ticket.trim().isEmpty()) {
+            return Result.error("票据不能为空");
+        }
+        Long cfcUserId = ssoService.consumeTicket(ticket, SsoService.SYS_CFC);
+        if (cfcUserId == null) {
+            return Result.error("票据无效或已过期,请重新进入");
+        }
+        try {
+            LoginResultDTO result = userService.ssoLogin(cfcUserId);
+            return Result.success(result);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
 }

+ 98 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatDietController.java

@@ -0,0 +1,98 @@
+package com.etotem.cfc.controller.fatloss;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FatDietRecord;
+import com.etotem.cfc.service.FatDietService;
+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.*;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "减脂-饮食记录")
+@RestController
+@RequestMapping("/api/fatloss/diet")
+public class FatDietController {
+
+    @Resource
+    private FatDietService fatDietService;
+
+    @Operation(summary = "获取饮食记录列表")
+    @PostMapping("/list")
+    public Result<List<FatDietRecord>> list(@RequestBody(required = false) Map<String, Object> params,
+                                            @RequestAttribute("userId") Long userId,
+                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
+        if (memberId == null) return Result.error("memberId不能为空");
+        Date startDate = parseDateParam(params == null ? null : params.get("startDate"));
+        Date endDate = parseDateParam(params == null ? null : params.get("endDate"));
+        return Result.success(fatDietService.list(memberId, startDate, endDate));
+    }
+
+    @Operation(summary = "创建饮食记录")
+    @PostMapping("/create")
+    public Result<FatDietRecord> create(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute("userId") Long userId,
+                                        @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        FatDietRecord record = new FatDietRecord();
+        record.setMemberId(ParamUtils.getLong(params.get("memberId"), currentMemberId));
+        record.setMealType((String) params.get("mealType"));
+        record.setFoodName((String) params.get("foodName"));
+        record.setFoodWeight(toBigDecimal(params.get("foodWeight")));
+        record.setCalories(toInteger(params.get("calories")));
+        record.setProtein(toBigDecimal(params.get("protein")));
+        record.setFat(toBigDecimal(params.get("fat")));
+        record.setCarbs(toBigDecimal(params.get("carbs")));
+        record.setEatDate(parseDateParam(params.get("eatDate")));
+        record.setRemark((String) params.get("remark"));
+        if (record.getMemberId() == null) return Result.error("memberId不能为空");
+        if (record.getEatDate() == null) return Result.error("进餐日期不能为空");
+        if (record.getFoodName() == null || record.getFoodName().isEmpty()) return Result.error("食物名称不能为空");
+        return Result.success(fatDietService.create(record));
+    }
+
+    @Operation(summary = "删除饮食记录")
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestBody Map<String, Object> params) {
+        Long id = ParamUtils.getLong(params.get("id"));
+        if (id == null) return Result.error("id不能为空");
+        return Result.success(fatDietService.delete(id));
+    }
+
+    @Operation(summary = "获取当日总热量")
+    @PostMapping("/calories-summary")
+    public Result<Integer> caloriesSummary(@RequestBody(required = false) Map<String, Object> params,
+                                           @RequestAttribute("userId") Long userId,
+                                           @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
+        if (memberId == null) return Result.error("memberId不能为空");
+        Date date = parseDateParam(params == null ? null : params.get("date"));
+        if (date == null) date = new Date();
+        return Result.success(fatDietService.sumCalories(memberId, date));
+    }
+
+    private BigDecimal toBigDecimal(Object obj) {
+        if (obj == null) return null;
+        String s = obj.toString().trim();
+        if (s.isEmpty()) return null;
+        try { return new BigDecimal(s); } catch (NumberFormatException e) { return null; }
+    }
+
+    private Integer toInteger(Object obj) {
+        if (obj == null) return 0;
+        String s = obj.toString().trim();
+        if (s.isEmpty()) return 0;
+        try { return Integer.parseInt(s); } catch (NumberFormatException e) { return 0; }
+    }
+
+    private Date parseDateParam(Object obj) {
+        if (obj == null) return null;
+        try { return new SimpleDateFormat("yyyy-MM-dd").parse(obj.toString()); } catch (Exception e) { return null; }
+    }
+}

+ 83 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatExerciseController.java

@@ -0,0 +1,83 @@
+package com.etotem.cfc.controller.fatloss;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FatExerciseRecord;
+import com.etotem.cfc.service.FatExerciseService;
+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.*;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "减脂-运动记录")
+@RestController
+@RequestMapping("/api/fatloss/exercise")
+public class FatExerciseController {
+
+    @Resource
+    private FatExerciseService fatExerciseService;
+
+    @Operation(summary = "获取运动记录列表")
+    @PostMapping("/list")
+    public Result<List<FatExerciseRecord>> list(@RequestBody(required = false) Map<String, Object> params,
+                                                @RequestAttribute("userId") Long userId,
+                                                @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
+        if (memberId == null) return Result.error("memberId不能为空");
+        Date startDate = parseDateParam(params == null ? null : params.get("startDate"));
+        Date endDate = parseDateParam(params == null ? null : params.get("endDate"));
+        return Result.success(fatExerciseService.list(memberId, startDate, endDate));
+    }
+
+    @Operation(summary = "创建运动记录")
+    @PostMapping("/create")
+    public Result<FatExerciseRecord> create(@RequestBody Map<String, Object> params,
+                                            @RequestAttribute("userId") Long userId,
+                                            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        FatExerciseRecord record = new FatExerciseRecord();
+        record.setMemberId(ParamUtils.getLong(params.get("memberId"), currentMemberId));
+        record.setExerciseType((String) params.get("exerciseType"));
+        record.setDurationMin(toInteger(params.get("durationMin")));
+        record.setDistanceKm(toBigDecimal(params.get("distanceKm")));
+        record.setCaloriesBurned(toInteger(params.get("caloriesBurned")));
+        record.setExerciseDate(parseDateParam(params.get("exerciseDate")));
+        record.setRemark((String) params.get("remark"));
+        if (record.getMemberId() == null) return Result.error("memberId不能为空");
+        if (record.getExerciseDate() == null) return Result.error("运动日期不能为空");
+        if (record.getExerciseType() == null || record.getExerciseType().isEmpty()) return Result.error("运动类型不能为空");
+        return Result.success(fatExerciseService.create(record));
+    }
+
+    @Operation(summary = "删除运动记录")
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestBody Map<String, Object> params) {
+        Long id = ParamUtils.getLong(params.get("id"));
+        if (id == null) return Result.error("id不能为空");
+        return Result.success(fatExerciseService.delete(id));
+    }
+
+    private BigDecimal toBigDecimal(Object obj) {
+        if (obj == null) return null;
+        String s = obj.toString().trim();
+        if (s.isEmpty()) return null;
+        try { return new BigDecimal(s); } catch (NumberFormatException e) { return null; }
+    }
+
+    private Integer toInteger(Object obj) {
+        if (obj == null) return 0;
+        String s = obj.toString().trim();
+        if (s.isEmpty()) return 0;
+        try { return Integer.parseInt(s); } catch (NumberFormatException e) { return 0; }
+    }
+
+    private Date parseDateParam(Object obj) {
+        if (obj == null) return null;
+        try { return new SimpleDateFormat("yyyy-MM-dd").parse(obj.toString()); } catch (Exception e) { return null; }
+    }
+}

+ 93 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatGoalController.java

@@ -0,0 +1,93 @@
+package com.etotem.cfc.controller.fatloss;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FatGoal;
+import com.etotem.cfc.service.FatGoalService;
+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.*;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Map;
+
+@Tag(name = "减脂-目标管理")
+@RestController
+@RequestMapping("/api/fatloss/goal")
+public class FatGoalController {
+
+    @Resource
+    private FatGoalService fatGoalService;
+
+    @Operation(summary = "获取当前减脂目标")
+    @PostMapping("/active")
+    public Result<FatGoal> active(@RequestBody(required = false) Map<String, Object> params,
+                                  @RequestAttribute("userId") Long userId,
+                                  @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
+        if (memberId == null) return Result.error("memberId不能为空");
+        return Result.success(fatGoalService.getActive(memberId));
+    }
+
+    @Operation(summary = "创建减脂目标")
+    @PostMapping("/create")
+    public Result<FatGoal> create(@RequestBody Map<String, Object> params,
+                                  @RequestAttribute("userId") Long userId,
+                                  @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        FatGoal goal = new FatGoal();
+        goal.setMemberId(ParamUtils.getLong(params.get("memberId"), currentMemberId));
+        goal.setStartWeight(toBigDecimal(params.get("startWeight")));
+        goal.setTargetWeight(toBigDecimal(params.get("targetWeight")));
+        goal.setTargetBodyFat(toBigDecimal(params.get("targetBodyFat")));
+        goal.setTargetDate(parseDateParam(params.get("targetDate")));
+        goal.setStartDate(parseDateParam(params.get("startDate")));
+        if (goal.getMemberId() == null) return Result.error("memberId不能为空");
+        if (goal.getTargetWeight() == null) return Result.error("目标体重不能为空");
+        if (goal.getStartWeight() == null) return Result.error("初始体重不能为空");
+        if (goal.getStartDate() == null) return Result.error("开始日期不能为空");
+        return Result.success(fatGoalService.create(goal));
+    }
+
+    @Operation(summary = "更新减脂目标")
+    @PostMapping("/update")
+    public Result<FatGoal> update(@RequestBody Map<String, Object> params) {
+        FatGoal goal = new FatGoal();
+        goal.setId(ParamUtils.getLong(params.get("id")));
+        if (goal.getId() == null) return Result.error("id不能为空");
+        goal.setTargetWeight(toBigDecimal(params.get("targetWeight")));
+        goal.setTargetBodyFat(toBigDecimal(params.get("targetBodyFat")));
+        goal.setTargetDate(parseDateParam(params.get("targetDate")));
+        return Result.success(fatGoalService.update(goal));
+    }
+
+    @Operation(summary = "完成减脂目标")
+    @PostMapping("/complete")
+    public Result<FatGoal> complete(@RequestBody Map<String, Object> params) {
+        Long id = ParamUtils.getLong(params.get("id"));
+        if (id == null) return Result.error("id不能为空");
+        return Result.success(fatGoalService.complete(id));
+    }
+
+    @Operation(summary = "放弃减脂目标")
+    @PostMapping("/abandon")
+    public Result<FatGoal> abandon(@RequestBody Map<String, Object> params) {
+        Long id = ParamUtils.getLong(params.get("id"));
+        if (id == null) return Result.error("id不能为空");
+        return Result.success(fatGoalService.abandon(id));
+    }
+
+    private BigDecimal toBigDecimal(Object obj) {
+        if (obj == null) return null;
+        String s = obj.toString().trim();
+        if (s.isEmpty()) return null;
+        try { return new BigDecimal(s); } catch (NumberFormatException e) { return null; }
+    }
+
+    private Date parseDateParam(Object obj) {
+        if (obj == null) return null;
+        try { return new SimpleDateFormat("yyyy-MM-dd").parse(obj.toString()); } catch (Exception e) { return null; }
+    }
+}

+ 87 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/fatloss/FatWeightController.java

@@ -0,0 +1,87 @@
+package com.etotem.cfc.controller.fatloss;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FatWeightRecord;
+import com.etotem.cfc.service.FatWeightService;
+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.*;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "减脂-体重管理")
+@RestController
+@RequestMapping("/api/fatloss/weight")
+public class FatWeightController {
+
+    @Resource
+    private FatWeightService fatWeightService;
+
+    @Operation(summary = "获取体重记录列表")
+    @PostMapping("/list")
+    public Result<List<FatWeightRecord>> list(@RequestBody(required = false) Map<String, Object> params,
+                                              @RequestAttribute("userId") Long userId,
+                                              @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(params == null ? null : params.get("memberId"), currentMemberId);
+        if (memberId == null) return Result.error("memberId不能为空");
+        Date startDate = parseDateParam(params == null ? null : params.get("startDate"));
+        Date endDate = parseDateParam(params == null ? null : params.get("endDate"));
+        return Result.success(fatWeightService.list(memberId, startDate, endDate));
+    }
+
+    @Operation(summary = "创建体重记录")
+    @PostMapping("/create")
+    public Result<FatWeightRecord> create(@RequestBody Map<String, Object> params,
+                                          @RequestAttribute("userId") Long userId,
+                                          @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        FatWeightRecord record = new FatWeightRecord();
+        record.setMemberId(ParamUtils.getLong(params.get("memberId"), currentMemberId));
+        record.setWeight(toBigDecimal(params.get("weight")));
+        record.setBodyFatPercent(toBigDecimal(params.get("bodyFatPercent")));
+        record.setWaistCircumference(toBigDecimal(params.get("waistCircumference")));
+        record.setMeasureDate(parseDateParam(params.get("measureDate")));
+        record.setRemark((String) params.get("remark"));
+        if (record.getMemberId() == null) return Result.error("memberId不能为空");
+        if (record.getWeight() == null) return Result.error("体重不能为空");
+        if (record.getMeasureDate() == null) return Result.error("测量日期不能为空");
+        return Result.success(fatWeightService.create(record));
+    }
+
+    @Operation(summary = "更新体重记录")
+    @PostMapping("/update")
+    public Result<FatWeightRecord> update(@RequestBody Map<String, Object> params) {
+        FatWeightRecord record = new FatWeightRecord();
+        record.setId(ParamUtils.getLong(params.get("id")));
+        if (record.getId() == null) return Result.error("id不能为空");
+        record.setWeight(toBigDecimal(params.get("weight")));
+        record.setBodyFatPercent(toBigDecimal(params.get("bodyFatPercent")));
+        record.setWaistCircumference(toBigDecimal(params.get("waistCircumference")));
+        return Result.success(fatWeightService.update(record));
+    }
+
+    @Operation(summary = "删除体重记录")
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestBody Map<String, Object> params) {
+        Long id = ParamUtils.getLong(params.get("id"));
+        if (id == null) return Result.error("id不能为空");
+        return Result.success(fatWeightService.delete(id));
+    }
+
+    private BigDecimal toBigDecimal(Object obj) {
+        if (obj == null) return null;
+        String s = obj.toString().trim();
+        if (s.isEmpty()) return null;
+        try { return new BigDecimal(s); } catch (NumberFormatException e) { return null; }
+    }
+
+    private Date parseDateParam(Object obj) {
+        if (obj == null) return null;
+        try { return new SimpleDateFormat("yyyy-MM-dd").parse(obj.toString()); } catch (Exception e) { return null; }
+    }
+}

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/recommendation/ProductRecommendationController.java

@@ -27,6 +27,7 @@ public class ProductRecommendationController {
     @Operation(summary = "获取维度页推荐商品")
     @PostMapping("/dimension-products")
     public Result<List<Map<String, Object>>> getDimensionProducts(@RequestBody Map<String, Object> params,
+                                                                  @RequestAttribute(value = "userId") Long userId,
                                                                   @RequestAttribute(value = "familyId", required = false) Long familyId) {
         if (familyId == null) {
             return Result.noFamily("请先创建或加入家庭");
@@ -43,7 +44,7 @@ public class ProductRecommendationController {
         int limit = limitObj != null ? toInt(limitObj) : 6;
 
         List<Map<String, Object>> result = productRecommendationService.getDimensionRecommendations(
-                dimensionCode, familyId, memberId, excludeIds, limit);
+                dimensionCode, familyId, memberId, excludeIds, limit, userId);
 
         return Result.success(result);
     }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java

@@ -18,6 +18,7 @@ public class CreateProductOrderDTO {
     private Integer deliveryMethod = 1; // 1=快递 2=自提
     private Integer pointsUsed; // 积分抵扣数量
     private Long consigneeId;         // 收货人ID
+    private Long addressId;             // 收货地址ID(user_address.id),与 addressSnapshot 二选一
     private Map<String, String> purchaseInfo; // 购买信息(字段key→值)
 
     private List<Long> giftItemIds; // 选中的赠品项ID列表(来自 product_gift_items.id)
@@ -42,5 +43,7 @@ public class CreateProductOrderDTO {
     public Integer getDeliveryMethod() { return deliveryMethod; }
     public Integer getPointsUsed() { return pointsUsed; }
     public Long getConsigneeId() { return consigneeId; }
+    public Long getAddressId() { return addressId; }
+    public void setAddressId(Long addressId) { this.addressId = addressId; }
     public Map<String, String> getPurchaseInfo() { return purchaseInfo; }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportPayload.java

@@ -104,6 +104,7 @@ public class ParsedReportPayload {
         private String description;
         private String category;
         private String level;
+        private String status;
 
         public String getBacteriaName() { return bacteriaName; }
         public void setBacteriaName(String bacteriaName) { this.bacteriaName = bacteriaName; }
@@ -121,6 +122,8 @@ public class ParsedReportPayload {
         public void setCategory(String category) { this.category = category; }
         public String getLevel() { return level; }
         public void setLevel(String level) { this.level = level; }
+        public String getStatus() { return status; }
+        public void setStatus(String status) { this.status = status; }
     }
 
     public static class FoodItem {

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.java

@@ -22,6 +22,7 @@ public class ReportSummaryVO {
   private String status;
   private String detailRoute;
   private Date createdAt;
+  private String collectValidation;
 
   public static String getReportTypeName(String reportType) {
     if (reportType == null) return "未知";

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/DietFamilySelectedIngredient.java

@@ -0,0 +1,42 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("diet_family_selected_ingredients")
+public class DietFamilySelectedIngredient implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    /** 家庭ID */
+    private Long familyId;
+    /** 食材ID */
+    private Long foodId;
+    /** 食材名称 */
+    private String foodName;
+    /** 推荐分(0-100) */
+    private Integer score;
+    /** 推荐理由 */
+    private String reason;
+    /** 分类 */
+    private String category;
+    /** 每100克卡路里 */
+    private Integer caloriesPer100g;
+    /** 烹饪难度(1=快手,2=中等,3=耗时) */
+    private Integer cookingLevel;
+    /** 营养密度分(0-100) */
+    private Integer nutritionScore;
+    /** 均衡化推荐理由 */
+    private String balanceReason;
+    /** 排序序号 */
+    private Integer sortOrder;
+    /** 创建时间 */
+    private Date createdAt;
+    /** 更新时间 */
+    private Date updatedAt;
+}

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyMembership.java

@@ -17,7 +17,7 @@ public class FamilyMembership implements Serializable {
 
     private Long familyId;
 
-    private String levelCode;  // FREE/BASIC/PROFESSIONAL/ENTERPRISE
+    private String levelCode;  // FREE/PROFESSIONAL/ENTERPRISE
 
     private Date startDate;
 

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FatDietRecord.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("fat_diet_record")
+public class FatDietRecord {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private String mealType;
+    private String foodName;
+    private BigDecimal foodWeight;
+    private Integer calories;
+    private BigDecimal protein;
+    private BigDecimal fat;
+    private BigDecimal carbs;
+    private Date eatDate;
+    private String remark;
+    private Date createdAt;
+}

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FatExerciseRecord.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("fat_exercise_record")
+public class FatExerciseRecord {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private String exerciseType;
+    private Integer durationMin;
+    private BigDecimal distanceKm;
+    private Integer caloriesBurned;
+    private Date exerciseDate;
+    private String remark;
+    private Date createdAt;
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FatGoal.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("fat_goal")
+public class FatGoal {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private BigDecimal targetWeight;
+    private BigDecimal startWeight;
+    private BigDecimal targetBodyFat;
+    private Date targetDate;
+    private String status;
+    private Date startDate;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FatWeightRecord.java

@@ -0,0 +1,23 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("fat_weight_record")
+public class FatWeightRecord {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long memberId;
+    private BigDecimal weight;
+    private BigDecimal bodyFatPercent;
+    private BigDecimal waistCircumference;
+    private Date measureDate;
+    private String remark;
+    private Date createdAt;
+}

+ 55 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GutHealthProject.java

@@ -0,0 +1,55 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("gut_health_projects")
+public class GutHealthProject implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private Long familyId;
+
+    private Long memberId;
+
+    private String status;
+
+    private String questions;
+
+    private String healthRecommendations;
+
+    private String riskScores;
+
+    private Integer microbeTestRecommended;
+
+    private Date paidAt;
+
+    private Date completedAt;
+
+    private Integer memberDiscountQualified;
+
+    private Integer familyMembershipQualified;
+
+    private Integer familyMembershipActivated;
+
+    private Integer cfRefundAmount;
+
+    private Integer cfRefunded;
+
+    private Integer completedTasksCount;
+
+    private String actionTasks;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 35 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java

@@ -0,0 +1,35 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Data
+@TableName("indicator_correlations")
+public class IndicatorCorrelation {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long definitionIdA;
+    private Long definitionIdB;
+    private String relationType;
+    private String direction;
+    private BigDecimal manualStrength;
+    private BigDecimal computedStrength;
+    private String method;
+    private Integer sampleSize;
+    private String confidence;
+    private BigDecimal effectiveStrength;
+    private Integer manualLocked;
+    private String cohortScope;
+    private String status;
+    private Date computedAt;
+    private Long createdBy;
+    private String description;
+    private String sourceNote;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java

@@ -19,6 +19,11 @@ public class IndicatorDefinition {
     private String refRange;
     private Integer sortOrder;
     private Integer status;
+    private String sourceKind;
+    private String reportTypeScope;
+    private String valueType;
+    private String goodDirection;
+    private String dimensionCode;
     private Date createdAt;
     private Date updatedAt;
 
@@ -44,6 +49,16 @@ public class IndicatorDefinition {
     public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
     public Integer getStatus() { return status; }
     public void setStatus(Integer status) { this.status = status; }
+    public String getSourceKind() { return sourceKind; }
+    public void setSourceKind(String sourceKind) { this.sourceKind = sourceKind; }
+    public String getReportTypeScope() { return reportTypeScope; }
+    public void setReportTypeScope(String reportTypeScope) { this.reportTypeScope = reportTypeScope; }
+    public String getValueType() { return valueType; }
+    public void setValueType(String valueType) { this.valueType = valueType; }
+    public String getGoodDirection() { return goodDirection; }
+    public void setGoodDirection(String goodDirection) { this.goodDirection = goodDirection; }
+    public String getDimensionCode() { return dimensionCode; }
+    public void setDimensionCode(String dimensionCode) { this.dimensionCode = dimensionCode; }
     public Date getCreatedAt() { return createdAt; }
     public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
     public Date getUpdatedAt() { return updatedAt; }

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java

@@ -0,0 +1,24 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+@Data
+@TableName("indicator_mapping_rules")
+public class IndicatorMappingRule {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String sourceType;
+    private String rawName;
+    private String aliasText;
+    private Long definitionId;
+    private String matchType;
+    private Integer priority;
+    private Integer status;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java

@@ -16,6 +16,12 @@ public class IndicatorValue {
     private String value;
     private BigDecimal numericValue;
     private String remark;
+    private Long subjectId;
+    private Long familyId;
+    private Long reportId;
+    private java.sql.Date reportDate;
+    private String unit;
+    private String status;
     private Date recordedAt;
 
     public Long getId() { return id; }
@@ -32,6 +38,18 @@ public class IndicatorValue {
     public void setNumericValue(BigDecimal numericValue) { this.numericValue = numericValue; }
     public String getRemark() { return remark; }
     public void setRemark(String remark) { this.remark = remark; }
+    public Long getSubjectId() { return subjectId; }
+    public void setSubjectId(Long subjectId) { this.subjectId = subjectId; }
+    public Long getFamilyId() { return familyId; }
+    public void setFamilyId(Long familyId) { this.familyId = familyId; }
+    public Long getReportId() { return reportId; }
+    public void setReportId(Long reportId) { this.reportId = reportId; }
+    public java.sql.Date getReportDate() { return reportDate; }
+    public void setReportDate(java.sql.Date reportDate) { this.reportDate = reportDate; }
+    public String getUnit() { return unit; }
+    public void setUnit(String unit) { this.unit = unit; }
+    public String getStatus() { return status; }
+    public void setStatus(String status) { this.status = status; }
     public Date getRecordedAt() { return recordedAt; }
     public void setRecordedAt(Date recordedAt) { this.recordedAt = recordedAt; }
 }

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -55,7 +55,7 @@ public class Product implements Serializable {
     /** 最低可见会员级别编码(NULL=全部可见): FREE/FAMILY/PROVIDER/PREMIUM/LIFETIME */
     private String minVisibleLevel;
 
-    /** 享受会员价所需的最低会员级别编码(NULL=所有会员均可;BASIC/FAMILY/PREMIUM等) */
+    /** 享受会员价所需的最低会员级别编码(NULL=所有会员均可;FAMILY/PREMIUM等) */
     private String minDiscountLevel;
 
     // 佣金系统 - 平台利润率(千分比)

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SsoTicket.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 跨小程序单点登录一次性票据(cfc ↔ train 双向免登)。
+ * cfc 与 train 共用本库(zxyj),签发方写入,目标方原子占用(used 0→1)。
+ */
+@Data
+@TableName("sso_ticket")
+public class SsoTicket implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 一次性票据(SecureRandom 生成) */
+    private String ticket;
+
+    /** 签发方系统: cfc / train */
+    private String sourceSystem;
+
+    /** 目标系统: cfc / train */
+    private String targetSystem;
+
+    /** 签发方用户ID(cfc=users.id / train=train_user.id) */
+    private Long sourceUserId;
+
+    /** cfc 统一会员ID(users.id),跨系统换票依据 */
+    private Long cfcUserId;
+
+    /** 是否已使用: 0=未用 1=已用 */
+    private Integer used;
+
+    /** 过期时间 */
+    private Date expireAt;
+
+    private Date createdAt;
+}

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.job;
+
+import com.etotem.cfc.service.IndicatorCorrelationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+
+/** 指标关联每日自动校准(Spearman;样本不足保留 manual_strength) */
+@Component
+public class IndicatorCalibrationJob {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorCalibrationJob.class);
+
+    @Resource
+    private IndicatorCorrelationService indicatorCorrelationService;
+
+    /** 每日 03:00 全量校准 */
+    @Scheduled(cron = "0 0 3 * * ?")
+    public void calibrateDaily() {
+        try {
+            int updated = indicatorCorrelationService.calibrateAll();
+            log.info("每日指标关联校准完成,更新 {} 对", updated);
+        } catch (Exception e) {
+            log.error("每日指标关联校准失败: {}", e.getMessage(), e);
+        }
+    }
+}

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

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

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

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

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

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

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

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

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

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

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

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

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.IndicatorCorrelation;
+
+public interface IndicatorCorrelationMapper extends BaseMapper<IndicatorCorrelation> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.IndicatorMappingRule;
+
+public interface IndicatorMappingRuleMapper extends BaseMapper<IndicatorMappingRule> {
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java

@@ -2,8 +2,22 @@ package com.etotem.cfc.mapper;
 
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.etotem.cfc.entity.IndicatorValue;
+import org.apache.ibatis.annotations.Insert;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 
 @Mapper
 public interface IndicatorValueMapper extends BaseMapper<IndicatorValue> {
+
+    /** 幂等写:同 (source_type, source_id, definition_id) 覆盖更新(uk_source_definition) */
+    @Insert("INSERT INTO indicator_values " +
+            "(source_type, source_id, definition_id, value, numeric_value, remark, " +
+            " subject_id, family_id, report_id, report_date, unit, status, recorded_at) " +
+            "VALUES (#{v.sourceType}, #{v.sourceId}, #{v.definitionId}, #{v.value}, #{v.numericValue}, #{v.remark}, " +
+            " #{v.subjectId}, #{v.familyId}, #{v.reportId}, #{v.reportDate}, #{v.unit}, #{v.status}, NOW()) " +
+            "ON DUPLICATE KEY UPDATE " +
+            " value = VALUES(value), numeric_value = VALUES(numeric_value), remark = VALUES(remark), " +
+            " subject_id = VALUES(subject_id), family_id = VALUES(family_id), report_id = VALUES(report_id), " +
+            " report_date = VALUES(report_date), unit = VALUES(unit), status = VALUES(status), recorded_at = NOW()")
+    int upsertMapping(@Param("v") IndicatorValue v);
 }

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

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

+ 196 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -15,7 +15,9 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import javax.annotation.Resource;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.scheduling.annotation.Async;
+import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import java.util.*;
 import java.util.stream.Collectors;
@@ -69,6 +71,24 @@ public class AIService {
     @Value("${dify.callback-secret:}")
     private String callbackSecret;
 
+    @Value("${aikanshe.appkey:}")
+    private String aikansheAppkey;
+
+    @Value("${aikanshe.base-url:}")
+    private String aikansheBaseUrl;
+
+    @Value("${aikanshe.token:}")
+    private String aikansheToken;
+
+    @Value("${aikanshe.desease:}")
+    private String aikansheDesease;
+
+    @Value("${aikanshe.confidence:}")
+    private double aikansheConfidence;
+
+    @Value("${aikanshe.lang:}")
+    private String aikansheLang;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     private HttpHeaders authHeaders() {
@@ -251,20 +271,195 @@ public class AIService {
 
     /**
      * 舌诊图像分析
-     * 调用 LangGraph TongueDiagnosisAgent(glm-5 视觉模型)返回结构化舌诊结果
+     * 先调用 aikanshe.com API,失败时回退至 LangGraph TongueDiagnosisAgent
      */
     public Map<String, Object> sendTongueDiagnosis(byte[] imageBytes, String filename, Long userId) {
         if (imageBytes == null || imageBytes.length == 0) {
             return mockTongueResult();
         }
+        // 第一步:尝试调用 aikanshe.com API
+        Map<String, Object> aikansheResult = callAikansheTongueDiagnosis(imageBytes, filename, userId);
+        if (aikansheResult != null && !aikansheResult.isEmpty()
+                && aikansheResult.containsKey("overall_assessment")) {
+            log.info("使用 aikanshe.com API 结果");
+            return aikansheResult;
+        }
+        log.info("aikanshe.com API 无结果或失败,回退至 LangGraph");
+        // 第二步:回退至 LangGraph
         Map<String, Object> result = aiGateway.analyzeTongue(imageBytes, filename, userId);
         if (result != null && !result.isEmpty()) {
+            log.info("使用 LangGraph 结果");
             return result;
         }
         log.warn("LangGraph tongue diagnosis 未返回结果, 使用 mock 兜底");
         return mockTongueResult();
     }
 
+    /**
+     * 调用 愛看舌 AI 舌诊 API
+     * 支持 appkey 认证或 JWT token 认证
+     */
+    private Map<String, Object> callAikansheTongueDiagnosis(byte[] imageBytes, String filename, Long userId) {
+        // 上传图片并获取URL(这里先存储,实际生产中应使用已有的 storageService)
+        // 对于 aikanshe.com,我们使用 imageUrl 模式,假设图片已经上传或使用 data URL
+        String imageBase64 = Base64.getEncoder().encodeToString(imageBytes);
+        String imageDataUrl = "data:image/jpeg;base64," + imageBase64;
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("age", userId != null ? userId : 0); // 实际项目中应传入真实年龄
+        body.put("male", 1); // 默认男性,实际应根据用户性别传入
+        body.put("name", "用户"); // 实际应根据用户昵称传入
+        body.put("imageUrl", imageDataUrl);
+        body.put("appkey", aikansheAppkey);
+        body.put("desease", aikansheDesease);
+        body.put("confidence", aikansheConfidence);
+        body.put("lang", aikansheLang);
+
+        String url = aikansheBaseUrl + "/agency/quanxi";
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+
+        // 使用 appkey 或 token 认证
+        if (aikansheAppkey != null && !aikansheAppkey.isEmpty()) {
+            headers.set("x-appkey", aikansheAppkey);
+        } else if (aikansheToken != null && !aikansheToken.isEmpty()) {
+            headers.setBearerAuth(aikansheToken);
+        }
+
+        String bodyJson;
+        try {
+            bodyJson = objectMapper.writeValueAsString(body);
+        } catch (Exception e) {
+            log.warn("愛看舌 API 请求体序列化失败: {}", e.getMessage());
+            return null;
+        }
+        HttpEntity<String> entity = new HttpEntity<>(bodyJson, headers);
+try {
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                if (root.has("code") && root.get("code").asText().equals("20000")) {
+                    JsonNode data = root.get("data");
+                    Map<String, Object> result = new LinkedHashMap<>();
+                    if (data.has("feel")) result.put("overall_assessment", data.get("feel").asText());
+                    if (data.has("recomand")) result.put("indicators", parseAikansheIndicators(data));
+                    if (data.has("id")) result.put("recordId", data.get("id").asLong());
+                    if (data.has("createTime")) result.put("createTime", data.get("createTime").asText());
+                    log.info("愛看舌 API 调用成功");
+                    return result;
+                } else {
+                    log.warn("愛看舌 API 返回错误代码: {}", root.get("code").asText());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("愛看舌 API 调用异常: {}", e.getMessage());
+        }
+        // 返回 null 表示调用失败,由上层 sendTongueDiagnosis 决定是否回退
+        return null;
+    }
+
+    private List<Map<String, Object>> parseAikansheIndicators(JsonNode data) {
+        List<Map<String, Object>> indicators = new ArrayList<>();
+
+        // --- 舌色 ---
+        if (data.has("feel")) {
+            String feel = data.get("feel").asText();
+            boolean found = false;
+            for (String line : feel.split("\\n")) {
+                if (line.contains("舌色") || line.contains("舌")) {
+                    String[] parts = line.split(":|:");
+                    if (parts.length >= 2) {
+                        String color = parts[1].trim().split("[,, ]")[0];
+                        if (isValidTongueColor(color)) {
+                            indicators.add(makeIndicator("tongue_color", color));
+                            found = true;
+                            break;
+                        }
+                    }
+                }
+            }
+            if (!found) {
+                indicators.add(makeIndicator("tongue_color", "淡红"));
+            }
+        } else {
+            indicators.add(makeIndicator("tongue_color", "淡红"));
+        }
+
+        // --- 苔色 ---
+        if (data.has("botai")) {
+            int botaiVal = data.get("botai").asInt(0);
+            indicators.add(makeIndicator("coating_color", botaiVal == 1 ? "有苔" : "无苔"));
+        } else {
+            indicators.add(makeIndicator("coating_color", "无苔"));
+        }
+
+        // --- 苔质 ---
+        if (data.has("botai")) {
+            int botaiVal = data.get("botai").asInt(0);
+            indicators.add(makeIndicator("coating_texture", botaiVal == 1 ? "滑腻" : "润"));
+        } else {
+            indicators.add(makeIndicator("coating_texture", "润"));
+        }
+
+        // --- 裂纹 ---
+        if (data.has("bagang")) {
+            String bagang = data.get("bagang").asText();
+            indicators.add(makeIndicator("fissure", (bagang.contains("实") || bagang.contains("虚")) ? "有" : "无"));
+        } else {
+            indicators.add(makeIndicator("fissure", "无"));
+        }
+
+        // --- 齿痕 ---
+        if (data.has("chihen")) {
+            int chihenVal = data.get("chihen").asInt(0);
+            indicators.add(makeIndicator("teeth_mark", chihenVal == 1 ? "有齿痕" : "无齿痕"));
+        } else {
+            indicators.add(makeIndicator("teeth_mark", "无齿痕"));
+        }
+
+        // --- 舌下络脉 ---
+        if (data.has("dianci")) {
+            int dianciVal = data.get("dianci").asInt(0);
+            indicators.add(makeIndicator("sublingual_vein", dianciVal == 1 ? "有点刺" : "无点刺"));
+        } else {
+            indicators.add(makeIndicator("sublingual_vein", "无点刺"));
+        }
+
+        // --- 体质 ---
+        if (data.has("desease")) {
+            String desease = data.get("desease").asText();
+            if (desease.contains("气虚") || desease.contains("阳虚")) {
+                indicators.add(makeIndicator("constitution", "气虚质"));
+            } else if (desease.contains("阴虚") || desease.contains("阴虚火旺")) {
+                indicators.add(makeIndicator("constitution", "阴虚质"));
+            } else if (desease.contains("血瘀") || desease.contains("气血两虚")) {
+                indicators.add(makeIndicator("constitution", "血瘀质"));
+            } else {
+                indicators.add(makeIndicator("constitution", "平和质"));
+            }
+        } else {
+            indicators.add(makeIndicator("constitution", "平和质"));
+        }
+
+        return indicators;
+    }
+
+    private boolean isValidTongueColor(String color) {
+        String[] validColors = {"淡红", "淡白", "红", "绛", "青", "紫", "正常"};
+        for (String c : validColors) {
+            if (color.contains(c) || c.contains(color)) return true;
+        }
+        return false;
+    }
+
+    private Map<String, Object> makeIndicator(String code, String value) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("code", code);
+        m.put("value", value);
+        return m;
+    }
+
     /**
      * 模拟舌诊结果(开发阶段使用)
      */

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -445,6 +445,37 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 指标归类兜底:将未命中的原始指标名交给 LangGraph 归类为已有/新定义。
+     * @return 建议 JSON 字符串(含 code/name)或 null(关闭/熔断/失败)
+     */
+    public String classifyIndicator(String sourceType, String rawName, java.util.List<String> candidates) {
+        if (!enabled || isCircuitOpen()) {
+            return null;
+        }
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("source_type", sourceType);
+            body.put("raw_name", rawName);
+            if (candidates != null) {
+                com.fasterxml.jackson.databind.node.ArrayNode arr = body.putArray("candidates");
+                candidates.forEach(arr::add);
+            }
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/indicator/classify";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                consecutiveFailures.set(0);
+                return response.getBody();
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway classifyIndicator 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
     /**
      * 识别图片中的食材
      */

+ 130 - 40
cfc-backend/src/main/java/com/etotem/cfc/service/DietIngredientService.java

@@ -1,7 +1,10 @@
 package com.etotem.cfc.service;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.dto.IngredientRecommendation;
+import com.etotem.cfc.entity.DietFamilySelectedIngredient;
 import com.etotem.cfc.entity.Food;
+import com.etotem.cfc.mapper.DietFamilySelectedIngredientMapper;
 import com.etotem.cfc.mapper.FoodMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -9,6 +12,7 @@ import org.springframework.stereotype.Service;
 import javax.annotation.Resource;
 import java.time.LocalDate;
 import java.util.*;
+import java.util.stream.Collectors;
 
 @Slf4j
 @Service
@@ -20,18 +24,39 @@ public class DietIngredientService {
     @Resource
     private FoodMapper foodMapper;
 
-    // 会话内食材选择(按 familyId 隔离)
-    private final Map<Long, List<IngredientRecommendation>> selectedIngredients = new HashMap<>();
+    @Resource
+    private DietFamilySelectedIngredientMapper selectedIngredientMapper;
+
+    // 会话内 refresh 种子(重启后重置,不影响持久化的食材选择)
     private final Map<Long, Integer> refreshSeed = new HashMap<>();
 
+    // ============================================================
+    // 共餐食材推荐(持久化)
+    // ============================================================
+
     public Map<String, Object> suggestIngredients(Long familyId) {
+        // 优先从数据库加载已有选择
+        List<DietFamilySelectedIngredient> dbItems = selectedIngredientMapper.selectList(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId)
+                        .orderByAsc(DietFamilySelectedIngredient::getSortOrder));
+
+        if (!dbItems.isEmpty()) {
+            List<IngredientRecommendation> existing = dbItemsToRecommendations(dbItems);
+            Map<String, Object> result = new HashMap<>();
+            result.put("ingredients", existing);
+            result.put("selectedCount", existing.size());
+            return result;
+        }
+
+        // 无历史选择:重新计算一批并持久化
         List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now());
-        selectedIngredients.put(familyId, suggestions);
+        persistSelectedIngredients(familyId, suggestions);
         refreshSeed.put(familyId, 0);
-        
+
         Map<String, Object> result = new HashMap<>();
         result.put("ingredients", suggestions);
-        result.put("selectedCount", 0);
+        result.put("selectedCount", suggestions.size());
         return result;
     }
 
@@ -39,61 +64,67 @@ public class DietIngredientService {
         Integer seed = refreshSeed.getOrDefault(familyId, 0) + 1;
         refreshSeed.put(familyId, seed);
 
-        // 重新生成(通过 seed 偏移截取,真正换一批)
+        // 重新计算(含 seed 偏移)
         List<IngredientRecommendation> suggestions = beijingNutritionService.generateIngredientList(familyId, LocalDate.now(), seed);
-        selectedIngredients.put(familyId, suggestions);
+        // 清空并重新持久化
+        selectedIngredientMapper.delete(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId));
+        persistSelectedIngredients(familyId, suggestions);
 
         Map<String, Object> result = new HashMap<>();
         result.put("ingredients", suggestions);
-        result.put("selectedCount", 0);
+        result.put("selectedCount", suggestions.size());
         return result;
     }
 
     public Map<String, Object> addIngredient(Long familyId, Long foodId, String name) {
-        List<IngredientRecommendation> selected = selectedIngredients.getOrDefault(familyId, new ArrayList<>());
-        
-        // 检查是否已存在
-        boolean exists = selected.stream().anyMatch(f -> f.getFoodId() != null && f.getFoodId().equals(foodId));
-        if (!exists) {
-            IngredientRecommendation item = new IngredientRecommendation();
-            item.setFoodId(foodId);
-            item.setName(name);
-            item.setScore(50);
-            item.setReason("手动添加");
-            selected.add(item);
-            selectedIngredients.put(familyId, selected);
+        // 幂等插入
+        Long count = selectedIngredientMapper.selectCount(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId)
+                        .eq(DietFamilySelectedIngredient::getFoodId, foodId));
+        if (count != null && count > 0) {
+            // 已存在,返回当前列表
+            return listIngredients(familyId);
         }
-        
-        Map<String, Object> result = new HashMap<>();
-        result.put("ingredients", selected);
-        result.put("selectedCount", selected.size());
-        return result;
+
+        DietFamilySelectedIngredient item = new DietFamilySelectedIngredient();
+        item.setFamilyId(familyId);
+        item.setFoodId(foodId);
+        item.setFoodName(name);
+        item.setScore(50);
+        item.setReason("手动添加");
+        item.setSortOrder(0);
+        selectedIngredientMapper.insert(item);
+
+        return listIngredients(familyId);
     }
 
     public Map<String, Object> removeIngredient(Long familyId, Long foodId) {
-        List<IngredientRecommendation> selected = selectedIngredients.getOrDefault(familyId, new ArrayList<>());
-        selected.removeIf(f -> f.getFoodId() != null && f.getFoodId().equals(foodId));
-        selectedIngredients.put(familyId, selected);
-        
-        Map<String, Object> result = new HashMap<>();
-        result.put("ingredients", selected);
-        result.put("selectedCount", selected.size());
-        return result;
+        selectedIngredientMapper.delete(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId)
+                        .eq(DietFamilySelectedIngredient::getFoodId, foodId));
+        return listIngredients(familyId);
     }
 
     public Map<String, Object> confirmIngredients(Long familyId, Map<String, Object> request) {
-        List<IngredientRecommendation> selected = selectedIngredients.getOrDefault(familyId, new ArrayList<>());
-        
+        List<IngredientRecommendation> selected = getFromDb(familyId);
+
         Map<String, Object> result = new HashMap<>();
         result.put("selectedFoods", selected);
         result.put("message", "食材已确认,正在生成食谱...");
-        
-        // 清空会话选择
-        selectedIngredients.remove(familyId);
-        
+
+        // 确认后将当前选中食材持久化保留,不删除(持久化语义)
+        // 前端如需清空,可调用 removeIngredient 或 refreshIngredients
         return result;
     }
 
+    // ============================================================
+    // 食材搜索
+    // ============================================================
+
     /**
      * 搜索食材(供"添加食材"弹窗使用)
      *
@@ -103,7 +134,7 @@ public class DietIngredientService {
     public Map<String, Object> searchFoods(String keyword) {
         List<Map<String, Object>> foods = new ArrayList<>();
         List<Food> all = foodMapper.selectList(
-                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Food>()
+                new LambdaQueryWrapper<Food>()
                         .eq(Food::getStatus, "active")
                         .orderByAsc(Food::getSortOrder)
         );
@@ -123,4 +154,63 @@ public class DietIngredientService {
         result.put("foods", foods);
         return result;
     }
+
+    // ============================================================
+    // 内部辅助
+    // ============================================================
+
+    private List<IngredientRecommendation> getFromDb(Long familyId) {
+        return dbItemsToRecommendations(selectedIngredientMapper.selectList(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId)
+                        .orderByAsc(DietFamilySelectedIngredient::getSortOrder)));
+    }
+
+    private Map<String, Object> listIngredients(Long familyId) {
+        List<IngredientRecommendation> selected = getFromDb(familyId);
+        Map<String, Object> result = new HashMap<>();
+        result.put("ingredients", selected);
+        result.put("selectedCount", selected.size());
+        return result;
+    }
+
+    /** 将 DB 实体转换为 IngredientRecommendation DTO */
+    private List<IngredientRecommendation> dbItemsToRecommendations(List<DietFamilySelectedIngredient> items) {
+        return items.stream().map(item -> {
+            IngredientRecommendation rec = new IngredientRecommendation();
+            rec.setFoodId(item.getFoodId());
+            rec.setName(item.getFoodName());
+            rec.setScore(item.getScore());
+            rec.setReason(item.getReason());
+            rec.setCategory(item.getCategory());
+            rec.setCaloriesPer100g(item.getCaloriesPer100g());
+            rec.setCookingLevel(item.getCookingLevel());
+            rec.setNutritionScore(item.getNutritionScore());
+            rec.setBalanceReason(item.getBalanceReason());
+            return rec;
+        }).collect(Collectors.toList());
+    }
+
+    /** 将推荐列表持久化到 DB */
+    private void persistSelectedIngredients(Long familyId, List<IngredientRecommendation> suggestions) {
+        selectedIngredientMapper.delete(
+                new LambdaQueryWrapper<DietFamilySelectedIngredient>()
+                        .eq(DietFamilySelectedIngredient::getFamilyId, familyId));
+        for (int i = 0; i < suggestions.size(); i++) {
+            IngredientRecommendation s = suggestions.get(i);
+            DietFamilySelectedIngredient item = new DietFamilySelectedIngredient();
+            item.setFamilyId(familyId);
+            item.setFoodId(s.getFoodId());
+            item.setFoodName(s.getName());
+            item.setScore(s.getScore());
+            item.setReason(s.getReason());
+            item.setCategory(s.getCategory());
+            item.setCaloriesPer100g(s.getCaloriesPer100g());
+            item.setCookingLevel(s.getCookingLevel());
+            item.setNutritionScore(s.getNutritionScore());
+            item.setBalanceReason(s.getBalanceReason());
+            item.setSortOrder(i);
+            selectedIngredientMapper.insert(item);
+        }
+    }
 }

+ 53 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FatDietService.java

@@ -0,0 +1,53 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FatDietRecord;
+import com.etotem.cfc.mapper.FatDietRecordMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class FatDietService {
+
+    @Resource
+    private FatDietRecordMapper fatDietRecordMapper;
+
+    public List<FatDietRecord> list(Long memberId, Date startDate, Date endDate) {
+        LambdaQueryWrapper<FatDietRecord> wrapper = new LambdaQueryWrapper<FatDietRecord>()
+                .eq(FatDietRecord::getMemberId, memberId)
+                .orderByDesc(FatDietRecord::getEatDate);
+        if (startDate != null) {
+            wrapper.ge(FatDietRecord::getEatDate, startDate);
+        }
+        if (endDate != null) {
+            wrapper.le(FatDietRecord::getEatDate, endDate);
+        }
+        return fatDietRecordMapper.selectList(wrapper);
+    }
+
+    public FatDietRecord create(FatDietRecord record) {
+        record.setCreatedAt(new Date());
+        fatDietRecordMapper.insert(record);
+        return record;
+    }
+
+    public boolean delete(Long id) {
+        return fatDietRecordMapper.deleteById(id) > 0;
+    }
+
+    /** 统计指定日期总热量 */
+    public int sumCalories(Long memberId, Date date) {
+        List<FatDietRecord> records = fatDietRecordMapper.selectList(
+                new LambdaQueryWrapper<FatDietRecord>()
+                        .eq(FatDietRecord::getMemberId, memberId)
+                        .eq(FatDietRecord::getEatDate, date));
+        int total = 0;
+        for (FatDietRecord r : records) {
+            if (r.getCalories() != null) total += r.getCalories();
+        }
+        return total;
+    }
+}

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FatExerciseService.java

@@ -0,0 +1,40 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FatExerciseRecord;
+import com.etotem.cfc.mapper.FatExerciseRecordMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class FatExerciseService {
+
+    @Resource
+    private FatExerciseRecordMapper fatExerciseRecordMapper;
+
+    public List<FatExerciseRecord> list(Long memberId, Date startDate, Date endDate) {
+        LambdaQueryWrapper<FatExerciseRecord> wrapper = new LambdaQueryWrapper<FatExerciseRecord>()
+                .eq(FatExerciseRecord::getMemberId, memberId)
+                .orderByDesc(FatExerciseRecord::getExerciseDate);
+        if (startDate != null) {
+            wrapper.ge(FatExerciseRecord::getExerciseDate, startDate);
+        }
+        if (endDate != null) {
+            wrapper.le(FatExerciseRecord::getExerciseDate, endDate);
+        }
+        return fatExerciseRecordMapper.selectList(wrapper);
+    }
+
+    public FatExerciseRecord create(FatExerciseRecord record) {
+        record.setCreatedAt(new Date());
+        fatExerciseRecordMapper.insert(record);
+        return record;
+    }
+
+    public boolean delete(Long id) {
+        return fatExerciseRecordMapper.deleteById(id) > 0;
+    }
+}

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FatGoalService.java

@@ -0,0 +1,61 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FatGoal;
+import com.etotem.cfc.mapper.FatGoalMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+@Service
+public class FatGoalService {
+
+    @Resource
+    private FatGoalMapper fatGoalMapper;
+
+    public FatGoal getActive(Long memberId) {
+        return fatGoalMapper.selectOne(
+                new LambdaQueryWrapper<FatGoal>()
+                        .eq(FatGoal::getMemberId, memberId)
+                        .eq(FatGoal::getStatus, "active")
+                        .orderByDesc(FatGoal::getCreatedAt)
+                        .last("LIMIT 1"));
+    }
+
+    public FatGoal create(FatGoal goal) {
+        goal.setStatus("active");
+        goal.setCreatedAt(new Date());
+        goal.setUpdatedAt(new Date());
+        fatGoalMapper.insert(goal);
+        return goal;
+    }
+
+    public FatGoal update(FatGoal goal) {
+        goal.setUpdatedAt(new Date());
+        fatGoalMapper.updateById(goal);
+        return goal;
+    }
+
+    /** 完成目标 */
+    public FatGoal complete(Long id) {
+        FatGoal goal = fatGoalMapper.selectById(id);
+        if (goal != null) {
+            goal.setStatus("completed");
+            goal.setUpdatedAt(new Date());
+            fatGoalMapper.updateById(goal);
+        }
+        return goal;
+    }
+
+    /** 放弃目标 */
+    public FatGoal abandon(Long id) {
+        FatGoal goal = fatGoalMapper.selectById(id);
+        if (goal != null) {
+            goal.setStatus("abandoned");
+            goal.setUpdatedAt(new Date());
+            fatGoalMapper.updateById(goal);
+        }
+        return goal;
+    }
+}

+ 45 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FatWeightService.java

@@ -0,0 +1,45 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FatWeightRecord;
+import com.etotem.cfc.mapper.FatWeightRecordMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class FatWeightService {
+
+    @Resource
+    private FatWeightRecordMapper fatWeightRecordMapper;
+
+    public List<FatWeightRecord> list(Long memberId, Date startDate, Date endDate) {
+        LambdaQueryWrapper<FatWeightRecord> wrapper = new LambdaQueryWrapper<FatWeightRecord>()
+                .eq(FatWeightRecord::getMemberId, memberId)
+                .orderByDesc(FatWeightRecord::getMeasureDate);
+        if (startDate != null) {
+            wrapper.ge(FatWeightRecord::getMeasureDate, startDate);
+        }
+        if (endDate != null) {
+            wrapper.le(FatWeightRecord::getMeasureDate, endDate);
+        }
+        return fatWeightRecordMapper.selectList(wrapper);
+    }
+
+    public FatWeightRecord create(FatWeightRecord record) {
+        record.setCreatedAt(new Date());
+        fatWeightRecordMapper.insert(record);
+        return record;
+    }
+
+    public FatWeightRecord update(FatWeightRecord record) {
+        fatWeightRecordMapper.updateById(record);
+        return record;
+    }
+
+    public boolean delete(Long id) {
+        return fatWeightRecordMapper.deleteById(id) > 0;
+    }
+}

+ 13 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java

@@ -38,6 +38,9 @@ public class FoodRecommendService {
     @Resource
     private HealthReportService healthReportService;
 
+    @Resource
+    private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+
     @Resource
     private FoodService foodService;
 
@@ -157,6 +160,16 @@ public class FoodRecommendService {
 
             log.info("用户{}食材{}已从最新报告中移除", userId, removed.getFoodId());
         }
+
+        // 归一化管道:全量快照最新食材推荐指数到 indicator_values(幂等覆盖)
+        try {
+            List<FoodRecommendIndex> finalIndices = foodRecommendIndexMapper.selectList(
+                    new LambdaQueryWrapper<FoodRecommendIndex>()
+                            .eq(FoodRecommendIndex::getUserId, userId));
+            normalizeIndicatorPipeline.snapshotFoodIndices(userId, finalIndices);
+        } catch (Exception e) {
+            log.warn("食材指数归一化快照失败 userId={}: {}", userId, e.getMessage());
+        }
     }
 
     public List<FoodRecommendIndex> getIndexByUser(Long userId) {

+ 865 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GutHealthProjectService.java

@@ -0,0 +1,865 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.OrderNoGenerator;
+import com.etotem.cfc.dto.QuotaStatusDTO;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 菌群健康调研项目(单次付费问卷)
+ * <p>
+ * 业务流程:
+ * 1. 用户付费 19.9 元创建项目
+ * 2. 填写健康问卷(睡眠/饮食/体重/消化等菌群相关指标)
+ * 3. 生成健康建议 + 风险评分 + 一周行动任务
+ * 4. 用户获得「会员价购买菌群检测」资格凭证(不自动开通会员)
+ * 5. 用户在小程序/商城使用会员价购买菌群检测商品
+ * 6. 购买成功后 → 自动开通一年家庭会员
+ * 7. 用户在小程序打卡完成一周任务后,返还同等金额的 CF 值
+ */
+@Slf4j
+@Service
+public class GutHealthProjectService {
+
+    /** 默认项目价格(分) */
+    private static final int DEFAULT_PRICE = 1990;
+    /** 任务列表默认总数(一周 7 天) */
+    private static final int DEFAULT_TASK_TOTAL = 7;
+
+    @Resource
+    private GutHealthProjectMapper projectMapper;
+
+    @Resource
+    private PaymentOrderMapper paymentOrderMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private FamilyMembershipMapper familyMembershipMapper;
+
+    @Resource
+    private PlatformPointsService platformPointsService;
+
+    @Resource
+    private SysConfigService sysConfigService;
+
+    @Resource
+    private MemberUpgradeRecordMapper memberUpgradeRecordMapper;
+
+    @Resource
+    private MembershipService membershipService;
+
+    // ==================== 问卷题库 ====================
+
+    /** 问卷题目定义(菌群健康相关指标) */
+    public static List<Map<String, Object>> getQuestionnaireQuestions() {
+        List<Map<String, Object>> questions = new ArrayList<>();
+
+        // 1. 睡眠质量
+        Map<String, Object> q1 = new LinkedHashMap<>();
+        q1.put("dimension", "sleep");
+        q1.put("label", "睡眠质量");
+        q1.put("question", "您最近两周的睡眠质量如何?");
+        q1.put("options", Arrays.asList(
+                mapOf("text", "很好,入睡快且无夜间醒来", "score", 0),
+                mapOf("text", "尚可,偶尔入睡困难或早醒", "score", 1),
+                mapOf("text", "较差,经常入睡困难或夜间多次醒来", "score", 2),
+                mapOf("text", "很差,几乎每晚失眠或严重早醒", "score", 3)));
+        questions.add(q1);
+
+        // 2. 饮食结构
+        Map<String, Object> q2 = new LinkedHashMap<>();
+        q2.put("dimension", "diet");
+        q2.put("label", "饮食结构");
+        q2.put("question", "您的日常饮食结构如何?(荤素搭配、蔬菜水果摄入)");
+        q2.put("options", Arrays.asList(
+                mapOf("text", "均衡饮食,蔬菜水果充足", "score", 0),
+                mapOf("text", "基本均衡,但蔬菜水果偏少", "score", 1),
+                mapOf("text", "偏荤或偏素,蔬菜水果摄入不足", "score", 2),
+                mapOf("text", "高油高糖高盐为主,极少蔬果", "score", 3)));
+        questions.add(q2);
+
+        // 3. 体重状况
+        Map<String, Object> q3 = new LinkedHashMap<>();
+        q3.put("dimension", "weight");
+        q3.put("label", "体重状况");
+        q3.put("question", "您的体重状况?(或身高体重比)");
+        q3.put("options", Arrays.asList(
+                mapOf("text", "体重正常(BMI 18.5-23.9)", "score", 0),
+                mapOf("text", "偏瘦或轻微超重(BMI 24-26)", "score", 1),
+                mapOf("text", "超重(BMI 26-28)", "score", 2),
+                mapOf("text", "肥胖(BMI ≥28)或严重偏瘦", "score", 3)));
+        questions.add(q3);
+
+        // 4. 消化状况
+        Map<String, Object> q4 = new LinkedHashMap<>();
+        q4.put("dimension", "digestion");
+        q4.put("label", "消化状况");
+        q4.put("question", "您近期的消化功能如何?");
+        q4.put("options", Arrays.asList(
+                mapOf("text", "消化良好,无腹胀腹泻便秘", "score", 0),
+                mapOf("text", "偶尔腹胀、排气或排便不规律", "score", 1),
+                mapOf("text", "经常腹胀、腹泻或便秘交替", "score", 2),
+                mapOf("text", "长期肠胃不适,严重影响生活", "score", 3)));
+        questions.add(q4);
+
+        // 5. 运动习惯
+        Map<String, Object> q5 = new LinkedHashMap<>();
+        q5.put("dimension", "exercise");
+        q5.put("label", "运动习惯");
+        q5.put("question", "您每周的运动频率如何?");
+        q5.put("options", Arrays.asList(
+                mapOf("text", "每周3次以上,每次30分钟以上", "score", 0),
+                mapOf("text", "每周1-2次,强度适中", "score", 1),
+                mapOf("text", "偶尔运动,不规律", "score", 2),
+                mapOf("text", "几乎不运动", "score", 3)));
+        questions.add(q5);
+
+        // 6. 压力水平
+        Map<String, Object> q6 = new LinkedHashMap<>();
+        q6.put("dimension", "stress");
+        q6.put("label", "压力水平");
+        q6.put("question", "您近期的精神压力状态如何?");
+        q6.put("options", Arrays.asList(
+                mapOf("text", "轻松愉快,压力很小", "score", 0),
+                mapOf("text", "偶尔有压力,能自我调节", "score", 1),
+                mapOf("text", "经常感到压力,难以放松", "score", 2),
+                mapOf("text", "长期高压焦虑,影响睡眠和食欲", "score", 3)));
+        questions.add(q6);
+
+        // 7. 膳食纤维
+        Map<String, Object> q7 = new LinkedHashMap<>();
+        q7.put("dimension", "fiber");
+        q7.put("label", "膳食纤维");
+        q7.put("question", "您每天的主食和膳食纤维摄入情况?");
+        q7.put("options", Arrays.asList(
+                mapOf("text", "全谷物/粗粮占主食一半以上", "score", 0),
+                mapOf("text", "粗细搭配,有一定全谷物", "score", 1),
+                mapOf("text", "以精米白面为主,很少粗粮", "score", 2),
+                mapOf("text", "完全精制主食,不吃粗粮豆类", "score", 3)));
+        questions.add(q7);
+
+        // 8. 抗生素与药物
+        Map<String, Object> q8 = new LinkedHashMap<>();
+        q8.put("dimension", "antibiotic");
+        q8.put("label", "抗生素使用");
+        q8.put("question", "近半年您是否使用过抗生素或长期服药?");
+        q8.put("options", Arrays.asList(
+                mapOf("text", "未使用过", "score", 0),
+                mapOf("text", "偶尔使用(1次以内)", "score", 1),
+                mapOf("text", "使用过2-3次", "score", 2),
+                mapOf("text", "多次使用或长期服药", "score", 3)));
+        questions.add(q8);
+
+        return questions;
+    }
+
+    private static Map<String, Object> mapOf(Object... kv) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        for (int i = 0; i + 1 < kv.length; i += 2) {
+            m.put(String.valueOf(kv[i]), kv[i + 1]);
+        }
+        return m;
+    }
+
+    // ==================== 业务方法 ====================
+
+    /**
+     * 获取当前用户的项目状态
+     * @return Map: {hasProject, project, questions, price}
+     */
+    public Map<String, Object> getProjectStatus(Long userId) {
+        Map<String, Object> result = new HashMap<>();
+        GutHealthProject project = getLatestProject(userId);
+        result.put("hasProject", project != null);
+        result.put("project", project != null ? toProjectVO(project) : null);
+        result.put("questions", getQuestionnaireQuestions());
+        result.put("price", getProjectPrice());
+        return result;
+    }
+
+    /**
+     * 创建项目付费订单
+     */
+    public Map<String, Object> createProjectOrder(Long userId) {
+        User user = userMapper.selectById(userId);
+        if (user == null) {
+            throw new IllegalArgumentException("用户不存在");
+        }
+
+        // 已存在未完成项目,禁止重复购买
+        GutHealthProject existing = getLatestProject(userId);
+        if (existing != null && "pending".equals(existing.getStatus())) {
+            throw new RuntimeException("您已有进行中的菌群健康调研项目,请先完成问卷");
+        }
+
+        int price = getProjectPrice();
+
+        PaymentOrder order = new PaymentOrder();
+        order.setOrderNo(OrderNoGenerator.generate("GH"));
+        order.setFamilyId(user.getFamilyId());
+        order.setLevelCode("GUT_HEALTH");
+        order.setPaymentType("one_time");
+        order.setPeriod("once");
+        order.setAmount(price);
+        order.setStatus("pending");
+        order.setPeriodNo(1);
+        order.setCreatedAt(new Date());
+        order.setUpdatedAt(new Date());
+        paymentOrderMapper.insert(order);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("orderNo", order.getOrderNo());
+        result.put("amount", order.getAmount());
+        return result;
+    }
+
+    /**
+     * 处理支付成功回调:创建项目记录
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public boolean processPaymentCallback(String orderNo, String transactionId, String payMethod) {
+        PaymentOrder order = paymentOrderMapper.selectOne(
+                new LambdaQueryWrapper<PaymentOrder>()
+                        .eq(PaymentOrder::getOrderNo, orderNo));
+        if (order == null) {
+            return false;
+        }
+        if ("paid".equals(order.getStatus())) {
+            return true; // 幂等
+        }
+
+        order.setStatus("paid");
+        order.setTransactionId(transactionId);
+        order.setPayMethod(payMethod);
+        order.setPaidAt(new Date());
+        order.setUpdatedAt(new Date());
+        paymentOrderMapper.updateById(order);
+
+        // 创建项目记录
+        GutHealthProject project = new GutHealthProject();
+        Long userId = resolveUserIdByOrder(order);
+        project.setUserId(userId);
+        project.setFamilyId(order.getFamilyId());
+        project.setStatus("pending");
+        project.setMicrobeTestRecommended(0);
+        project.setMemberDiscountQualified(0);
+        project.setFamilyMembershipQualified(0);
+        project.setFamilyMembershipActivated(0);
+        project.setCfRefundAmount(order.getAmount());
+        project.setCfRefunded(0);
+        project.setCompletedTasksCount(0);
+        project.setPaidAt(new Date());
+        project.setCreatedAt(new Date());
+        project.setUpdatedAt(new Date());
+        projectMapper.insert(project);
+
+        log.info("菌群健康项目创建成功: orderNo={}, projectId={}, userId={}, amount={}",
+                orderNo, project.getId(), project.getUserId(), order.getAmount());
+        return true;
+    }
+
+    private Long resolveUserIdByOrder(PaymentOrder order) {
+        // 从 family_memberships 或用户表回查
+        if (order.getFamilyId() == null) return null;
+        List<User> users = userMapper.selectList(
+                new LambdaQueryWrapper<User>().eq(User::getFamilyId, order.getFamilyId()));
+        if (users != null && !users.isEmpty()) {
+            return users.get(0).getId();
+        }
+        return null;
+    }
+
+    /**
+     * 提交问卷答案,生成健康建议 + 行动任务 + 奖励资格
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> submitQuestionnaire(Long userId, Long projectId, List<Map<String, Object>> answers) {
+        GutHealthProject project = projectMapper.selectById(projectId);
+        if (project == null || !userId.equals(project.getUserId())) {
+            throw new RuntimeException("项目不存在或无权操作");
+        }
+        if ("completed".equals(project.getStatus())) {
+            throw new RuntimeException("该项目已完成,无需重复提交");
+        }
+
+        // 保存答案
+        ObjectMapper mapper = new ObjectMapper();
+        try {
+            project.setQuestions(mapper.writeValueAsString(answers));
+        } catch (Exception e) {
+            project.setQuestions(answers.toString());
+        }
+
+        // 计算风险评分
+        Map<String, Integer> riskScores = computeRiskScores(answers);
+        project.setRiskScores(safeJson(riskScores));
+
+        // 生成健康建议
+        List<Map<String, Object>> recommendations = generateRecommendations(riskScores, answers);
+        project.setHealthRecommendations(safeJson(recommendations));
+
+        // 判断是否推荐菌群检测(任一维度风险 >= 2)
+        boolean recommendTest = riskScores.values().stream().anyMatch(s -> s >= 2);
+        project.setMicrobeTestRecommended(recommendTest ? 1 : 0);
+
+        // 生成一周行动任务
+        List<Map<String, Object>> tasks = generateActionTasks(riskScores);
+        project.setActionTasks(safeJson(tasks));
+        project.setCompletedTasksCount(0);
+
+        // 授予会员价购买菌群检测资格(不自动开通会员)
+        project.setMemberDiscountQualified(1);
+        project.setStatus("completed");
+        project.setFamilyMembershipQualified(0);
+        project.setFamilyMembershipActivated(0);
+        project.setUpdatedAt(new Date());
+        projectMapper.updateById(project);
+        project.setFamilyMembershipActivated(0);
+        project.setCompletedAt(new Date());
+        project.setUpdatedAt(new Date());
+        projectMapper.updateById(project);
+
+        // 返回结果
+        Map<String, Object> result = new HashMap<>();
+        result.put("project", toProjectVO(project));
+        result.put("riskScores", riskScores);
+        result.put("recommendations", recommendations);
+        result.put("microbeTestRecommended", recommendTest);
+        result.put("tasks", tasks);
+        return result;
+    }
+
+    // ==================== 任务打卡 ====================
+
+    /**
+     * 获取行动任务列表
+     */
+    public List<Map<String, Object>> getActionTasks(Long userId, Long projectId) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null || project.getActionTasks() == null) {
+            return Collections.emptyList();
+        }
+        return parseJsonList(project.getActionTasks());
+    }
+
+    /**
+     * 打卡某个任务
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> checkinTask(Long userId, Long projectId, int taskIndex) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null) {
+            throw new RuntimeException("项目不存在或无权操作");
+        }
+        if (!"completed".equals(project.getStatus())) {
+            throw new RuntimeException("请先完成问卷后再打卡");
+        }
+        if (project.getCfRefunded() != null && project.getCfRefunded() == 1) {
+            throw new RuntimeException("任务已全部完成,CF值已返还");
+        }
+
+        List<Map<String, Object>> tasks = parseJsonList(project.getActionTasks());
+        if (taskIndex < 0 || taskIndex >= tasks.size()) {
+            throw new RuntimeException("任务索引越界");
+        }
+
+        Map<String, Object> task = tasks.get(taskIndex);
+        if (Boolean.TRUE.equals(task.get("done"))) {
+            throw new RuntimeException("该任务已打卡");
+        }
+
+        task.put("done", true);
+        task.put("doneAt", new Date().getTime());
+        project.setActionTasks(safeJson(tasks));
+
+        int doneCount = (int) tasks.stream().filter(t -> Boolean.TRUE.equals(t.get("done"))).count();
+        project.setCompletedTasksCount(doneCount);
+        project.setUpdatedAt(new Date());
+        projectMapper.updateById(project);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("doneCount", doneCount);
+        result.put("totalTasks", tasks.size());
+        result.put("allDone", doneCount >= tasks.size());
+
+        // 全部完成 → 返还 CF 值
+        if (doneCount >= tasks.size()) {
+            result.put("cfRefunded", refundCf(project));
+        } else {
+            result.put("cfRefunded", false);
+        }
+        return result;
+    }
+
+    /**
+     * 获取打卡进度
+     */
+    public Map<String, Object> getCheckinStatus(Long userId, Long projectId) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null) {
+            return Collections.emptyMap();
+        }
+        List<Map<String, Object>> tasks = parseJsonList(project.getActionTasks());
+        int doneCount = (int) tasks.stream().filter(t -> Boolean.TRUE.equals(t.get("done"))).count();
+        int total = tasks.size();
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("projectId", project.getId());
+        result.put("tasks", tasks);
+        result.put("doneCount", doneCount);
+        result.put("totalTasks", total);
+        result.put("allDone", total > 0 && doneCount >= total);
+        result.put("cfRefunded", project.getCfRefunded() != null && project.getCfRefunded() == 1);
+        result.put("cfRefundAmount", project.getCfRefundAmount() != null ? project.getCfRefundAmount() : 0);
+        result.put("memberDiscountQualified", project.getMemberDiscountQualified() != null && project.getMemberDiscountQualified() == 1);
+        result.put("familyMembershipQualified", project.getFamilyMembershipQualified() != null && project.getFamilyMembershipQualified() == 1);
+        result.put("familyMembershipActivated", project.getFamilyMembershipActivated() != null && project.getFamilyMembershipActivated() == 1);
+        return result;
+    }
+
+    /**
+     * 全部任务完成 → 返还同等金额 CF 值
+     */
+    private boolean refundCf(GutHealthProject project) {
+        if (project.getCfRefunded() != null && project.getCfRefunded() == 1) {
+            return true; // 幂等
+        }
+        int amount = project.getCfRefundAmount() != null ? project.getCfRefundAmount() : 0;
+        if (amount <= 0) {
+            amount = getProjectPrice();
+        }
+        try {
+            platformPointsService.earn(project.getUserId(), amount, "GUT_HEALTH", project.getId(),
+                    "菌群健康调研一周打卡完成,返还CF值");
+            project.setCfRefunded(1);
+            project.setUpdatedAt(new Date());
+            projectMapper.updateById(project);
+            log.info("菌群健康项目CF值返还: userId={}, projectId={}, amount={}", project.getUserId(), project.getId(), amount);
+            return true;
+        } catch (Exception e) {
+            log.error("CF值返还失败: userId={}, projectId={}", project.getUserId(), project.getId(), e);
+            return false;
+        }
+    }
+
+    // ==================== 奖励激活 ====================
+
+    /**
+     * 激活一年家庭会员(从资格中激活)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> activateFamilyMembership(Long userId, Long projectId) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null) {
+            throw new RuntimeException("项目不存在或无权操作");
+        }
+        if (project.getFamilyMembershipQualified() == null || project.getFamilyMembershipQualified() != 1) {
+            throw new RuntimeException("您尚未获得一年家庭会员资格,请先完成健康问卷");
+        }
+        if (project.getFamilyMembershipActivated() != null && project.getFamilyMembershipActivated() == 1) {
+            throw new RuntimeException("一年家庭会员已激活,请勿重复操作");
+        }
+
+        // 通过会员体系开通一年家庭会员
+        Long familyId = project.getFamilyId() != null ? project.getFamilyId() : getUserFamilyId(userId);
+        if (familyId == null) {
+            throw new RuntimeException("请先创建或加入家庭");
+        }
+        Date expireTime = addYears(new Date(), 1);
+
+        FamilyMembership membership = new FamilyMembership();
+        membership.setFamilyId(familyId);
+        membership.setLevelCode("FAMILY");
+        membership.setStartDate(new Date());
+        membership.setEndDate(expireTime);
+        membership.setPaymentMethod("gut_health_reward");
+        membership.setPaymentStatus("paid");
+        membership.setOrderNo("GH-" + project.getId());
+        membership.setAmount(0);
+        membership.setCreatedAt(new Date());
+        membership.setUpdatedAt(new Date());
+        familyMembershipMapper.insert(membership);
+
+        // 同步全家会员身份
+        try {
+            java.lang.reflect.Method syncMethod = MembershipService.class.getDeclaredMethod(
+                    "syncMembershipToFamilyMembers", Long.class, String.class, Date.class, String.class, Long.class);
+            syncMethod.setAccessible(true);
+            syncMethod.invoke(membershipService, familyId, "FAMILY", expireTime, "gut_health_reward", null);
+        } catch (Exception e) {
+            log.warn("同步家庭会员失败,将单独更新用户: {}", e.getMessage());
+            // 兜底:直接更新用户
+            User u = userMapper.selectById(userId);
+            if (u != null) {
+                u.setMemberLevel("FAMILY");
+                u.setMemberExpireTime(expireTime);
+                u.setUpdatedAt(new Date());
+                userMapper.updateById(u);
+            }
+        }
+
+        project.setFamilyMembershipActivated(1);
+        project.setFamilyMembershipQualified(1);
+        project.setUpdatedAt(new Date());
+        projectMapper.updateById(project);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("activated", true);
+        result.put("levelCode", "FAMILY");
+        result.put("expireTime", expireTime);
+        return result;
+    }
+
+    /**
+     * 购买菌群检测后激活一年家庭会员
+     * <p>
+     * 流程:用户使用会员价购买菌群检测商品 → 调用本方法 → 自动开通一年家庭会员
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public Map<String, Object> activateAfterPurchase(Long userId, Long projectId) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null) {
+            throw new RuntimeException("项目不存在或无权操作");
+        }
+        if (project.getMemberDiscountQualified() == null || project.getMemberDiscountQualified() != 1) {
+            throw new RuntimeException("您尚未获得会员价购买菌群检测资格,请先完成健康问卷");
+        }
+        if (project.getFamilyMembershipActivated() != null && project.getFamilyMembershipActivated() == 1) {
+            throw new RuntimeException("一年家庭会员已激活,请勿重复操作");
+        }
+
+        // 通过会员体系开通一年家庭会员
+        Long familyId = project.getFamilyId() != null ? project.getFamilyId() : getUserFamilyId(userId);
+        if (familyId == null) {
+            throw new RuntimeException("请先创建或加入家庭");
+        }
+        Date expireTime = addYears(new Date(), 1);
+
+        FamilyMembership membership = new FamilyMembership();
+        membership.setFamilyId(familyId);
+        membership.setLevelCode("FAMILY");
+        membership.setStartDate(new Date());
+        membership.setEndDate(expireTime);
+        membership.setPaymentMethod("gut_health_reward");
+        membership.setPaymentStatus("paid");
+        membership.setOrderNo("GH-" + project.getId());
+        membership.setAmount(0);
+        membership.setCreatedAt(new Date());
+        membership.setUpdatedAt(new Date());
+        familyMembershipMapper.insert(membership);
+
+        // 方案B:全家同步会员身份(通过会员体系公共入口)
+        try {
+            membershipService.setFamilyMembershipLevel(familyId, "FAMILY", expireTime, "gut_health_purchase", "菌群检测购买赠送");
+        } catch (Exception e) {
+            log.warn("同步家庭会员失败,将单独更新用户: {}", e.getMessage());
+            // 兜底:直接更新用户
+            User u = userMapper.selectById(userId);
+            if (u != null) {
+                u.setMemberLevel("FAMILY");
+                u.setMemberExpireTime(expireTime);
+                u.setUpdatedAt(new Date());
+                userMapper.updateById(u);
+            }
+        }
+
+        project.setFamilyMembershipActivated(1);
+        project.setUpdatedAt(new Date());
+        projectMapper.updateById(project);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("activated", true);
+        result.put("levelCode", "FAMILY");
+        result.put("expireTime", expireTime);
+        return result;
+    }
+
+    /**
+     * 获取会员价购买菌群检测凭证
+     */
+    public Map<String, Object> getMicrobeDiscountVoucher(Long userId, Long projectId) {
+        GutHealthProject project = getOwnedProject(userId, projectId);
+        if (project == null) {
+            throw new RuntimeException("项目不存在或无权操作");
+        }
+        if (project.getMemberDiscountQualified() == null || project.getMemberDiscountQualified() != 1) {
+            throw new RuntimeException("您尚未获得会员价购买资格,请先完成健康问卷");
+        }
+
+        Map<String, Object> voucher = new HashMap<>();
+        voucher.put("qualified", true);
+        voucher.put("voucherCode", "GH-DISCOUNT-" + project.getId());
+        voucher.put("validUntil", addYears(new Date(), 1));
+        voucher.put("projectId", project.getId());
+        return voucher;
+    }
+
+    // ==================== 风险评分与建议生成 ====================
+
+    /**
+     * 计算各维度风险评分(0-3分)
+     */
+    private Map<String, Integer> computeRiskScores(List<Map<String, Object>> answers) {
+        Map<String, Integer> scores = new LinkedHashMap<>();
+        List<Map<String, Object>> questions = getQuestionnaireQuestions();
+
+        for (int i = 0; i < questions.size(); i++) {
+            Map<String, Object> question = questions.get(i);
+            String dimension = (String) question.get("dimension");
+            int score = 0;
+            if (i < answers.size() && answers.get(i) != null) {
+                Object answer = answers.get(i).get("score") != null
+                        ? answers.get(i).get("score")
+                        : answers.get(i).get("answer");
+                if (answer instanceof Number) {
+                    score = ((Number) answer).intValue();
+                } else if (answer != null) {
+                    // 尝试按选项文本匹配
+                    List<Map<String, Object>> options = (List<Map<String, Object>>) question.get("options");
+                    for (Map<String, Object> opt : options) {
+                        if (answer.toString().equals(opt.get("text"))) {
+                            score = ((Number) opt.get("score")).intValue();
+                            break;
+                        }
+                    }
+                }
+            }
+            scores.put(dimension, score);
+        }
+        return scores;
+    }
+
+    /**
+     * 根据风险评分生成健康建议
+     */
+    private List<Map<String, Object>> generateRecommendations(Map<String, Integer> riskScores, List<Map<String, Object>> answers) {
+        List<Map<String, Object>> recommendations = new ArrayList<>();
+
+        // 睡眠
+        int sleep = riskScores.getOrDefault("sleep", 0);
+        if (sleep >= 2) {
+            recommendations.add(rec("睡眠", "高风险", "睡眠质量差与肠道菌群失衡密切相关,建议规律作息(23点前入睡),睡前1小时远离电子屏幕,可尝试睡前温水泡脚或冥想。若长期失眠,建议进一步评估肠道健康。"));
+        } else if (sleep == 1) {
+            recommendations.add(rec("睡眠", "中风险", "睡眠质量略有波动,建议保持固定作息,增加日间户外活动,晚餐避免过饱,观察改善情况。"));
+        }
+
+        // 饮食
+        int diet = riskScores.getOrDefault("diet", 0);
+        if (diet >= 2) {
+            recommendations.add(rec("饮食", "高风险", "饮食结构偏向高油高糖或缺乏蔬果,会抑制有益菌生长。建议每日蔬菜摄入500g以上,水果200g左右,减少油炸和加工食品,增加发酵食品(酸奶、纳豆、泡菜)。"));
+        } else if (diet == 1) {
+            recommendations.add(rec("饮食", "中风险", "饮食结构基本均衡但蔬果摄入偏少,建议每天增加一份蔬菜或水果,粗细粮搭配,促进膳食纤维多样化。"));
+        }
+
+        // 体重
+        int weight = riskScores.getOrDefault("weight", 0);
+        if (weight >= 2) {
+            recommendations.add(rec("体重", "高风险", "体重超标与肠道菌群多样性下降相关。建议控制精制碳水摄入,增加优质蛋白和膳食纤维,每周进行150分钟以上中等强度运动,可考虑菌群检测了解自身菌群结构。"));
+        } else if (weight == 1) {
+            recommendations.add(rec("体重", "中风险", "体重处于临界范围,建议调整饮食结构并保持规律运动,将体重控制在健康范围。"));
+        }
+
+        // 消化
+        int digestion = riskScores.getOrDefault("digestion", 0);
+        if (digestion >= 2) {
+            recommendations.add(rec("消化", "高风险", "频繁腹胀、腹泻或便秘提示肠道菌群可能失衡。建议每日补充膳食纤维,喝足量水(1500-2000ml),规律三餐,避免暴饮暴食。如症状持续,强烈建议进行菌群检测归因分析。"));
+        } else if (digestion == 1) {
+            recommendations.add(rec("消化", "中风险", "消化功能偶有波动,建议记录饮食日记,找出易引起不适的食物,逐步建立规律饮食习惯。"));
+        }
+
+        // 运动
+        int exercise = riskScores.getOrDefault("exercise", 0);
+        if (exercise >= 2) {
+            recommendations.add(rec("运动", "高风险", "缺乏规律运动会减少肠道菌群多样性。建议每周至少3次30分钟以上中等强度运动(快走、慢跑、游泳),可从每天15分钟开始逐步增加。"));
+        } else if (exercise == 1) {
+            recommendations.add(rec("运动", "中风险", "运动频率偏低,建议每周增加1-2次规律运动,运动后补充水分和适量蛋白质。"));
+        }
+
+        // 压力
+        int stress = riskScores.getOrDefault("stress", 0);
+        if (stress >= 2) {
+            recommendations.add(rec("压力", "高风险", "长期高压通过脑-肠轴影响肠道功能。建议每天留出10-15分钟正念呼吸或冥想,培养一项放松爱好,必要时寻求专业心理支持。"));
+        } else if (stress == 1) {
+            recommendations.add(rec("压力", "中风险", "压力水平可控,建议保持现有放松习惯,增加运动释放压力。"));
+        }
+
+        // 膳食纤维
+        int fiber = riskScores.getOrDefault("fiber", 0);
+        if (fiber >= 2) {
+            recommendations.add(rec("膳食纤维", "高风险", "膳食纤维摄入不足是肠道菌群失衡常见原因。建议主食增加燕麦、糙米、藜麦等全谷物,每天摄入豆类、坚果,保证纤维来源多样化。"));
+        } else if (fiber == 1) {
+            recommendations.add(rec("膳食纤维", "中风险", "全谷物比例偏低,建议逐步用粗粮替代部分精制主食,每周3-4次。"));
+        }
+
+        // 抗生素
+        int antibiotic = riskScores.getOrDefault("antibiotic", 0);
+        if (antibiotic >= 2) {
+            recommendations.add(rec("抗生素使用", "高风险", "近期抗生素使用可能已影响肠道菌群平衡。建议增加益生菌和益生元摄入(酸奶、发酵食品、菊粉),均衡饮食帮助菌群恢复。"));
+        } else if (antibiotic == 1) {
+            recommendations.add(rec("抗生素使用", "中风险", "曾有抗生素使用史,建议日常增加发酵食品摄入,帮助肠道菌群维持平衡。"));
+        }
+
+        if (recommendations.isEmpty()) {
+            recommendations.add(rec("整体", "低风险", "您的各项指标总体良好,建议保持现有健康生活方式,定期关注肠道健康。"));
+        }
+        return recommendations;
+    }
+
+    private Map<String, Object> rec(String dimension, String level, String advice) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("dimension", dimension);
+        m.put("level", level);
+        m.put("advice", advice);
+        return m;
+    }
+
+    /**
+     * 根据风险评分生成一周行动任务
+     */
+    private List<Map<String, Object>> generateActionTasks(Map<String, Integer> riskScores) {
+        List<Map<String, Object>> tasks = new ArrayList<>();
+
+        // 任务 1: 睡眠调整
+        if (riskScores.getOrDefault("sleep", 0) >= 1) {
+            tasks.add(task("每晚23:00前入睡,记录实际入睡时间", "sleep", "改善睡眠-肠道节律"));
+        } else {
+            tasks.add(task("保持现有作息,记录一周睡眠时长", "sleep", "巩固良好睡眠习惯"));
+        }
+
+        // 任务 2: 饮食调整
+        if (riskScores.getOrDefault("diet", 0) >= 1) {
+            tasks.add(task("每天增加一份蔬菜(至少500g),减少油炸食品", "diet", "优化饮食结构"));
+        } else {
+            tasks.add(task("保持均衡饮食,每天记录三餐构成", "diet", "维持健康饮食"));
+        }
+
+        // 任务 3: 运动
+        if (riskScores.getOrDefault("exercise", 0) >= 1) {
+            tasks.add(task("每天快走或慢跑20-30分钟", "exercise", "提升菌群多样性"));
+        } else {
+            tasks.add(task("每周3次30分钟运动(跑步/游泳/健身操)", "exercise", "维持菌群多样性"));
+        }
+
+        // 任务 4: 膳食纤维
+        if (riskScores.getOrDefault("fiber", 0) >= 1) {
+            tasks.add(task("每天摄入一种全谷物或粗粮(燕麦/糙米/藜麦)", "fiber", "补充膳食纤维"));
+        } else {
+            tasks.add(task("保持全谷物摄入,记录每日粗粮种类", "fiber", "维持纤维摄入"));
+        }
+
+        // 任务 5: 饮水
+        tasks.add(task("每天饮水1500-2000ml,记录饮水量", "hydration", "促进肠道蠕动"));
+
+        // 任务 6: 压力管理
+        if (riskScores.getOrDefault("stress", 0) >= 1) {
+            tasks.add(task("每天10分钟正念呼吸或冥想", "stress", "调节脑-肠轴"));
+        } else {
+            tasks.add(task("每天10分钟放松活动(听音乐/散步/阅读)", "stress", "保持轻松状态"));
+        }
+
+        // 任务 7: 消化观察
+        if (riskScores.getOrDefault("digestion", 0) >= 1) {
+            tasks.add(task("记录每日排便情况和腹胀感受", "digestion", "观察消化功能变化"));
+        } else {
+            tasks.add(task("记录一周消化状况,留意异常信号", "digestion", "持续关注消化健康"));
+        }
+
+        return tasks;
+    }
+
+    private Map<String, Object> task(String content, String category, String goal) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("content", content);
+        m.put("category", category);
+        m.put("goal", goal);
+        m.put("done", false);
+        return m;
+    }
+
+    // ==================== 辅助方法 ====================
+
+    private GutHealthProject getLatestProject(Long userId) {
+        return projectMapper.selectOne(
+                new LambdaQueryWrapper<GutHealthProject>()
+                        .eq(GutHealthProject::getUserId, userId)
+                        .orderByDesc(GutHealthProject::getId)
+                        .last("LIMIT 1"));
+    }
+
+    private GutHealthProject getOwnedProject(Long userId, Long projectId) {
+        if (projectId == null) return null;
+        GutHealthProject project = projectMapper.selectById(projectId);
+        if (project == null || !userId.equals(project.getUserId())) return null;
+        return project;
+    }
+
+    private int getProjectPrice() {
+        String priceStr = sysConfigService.getValue("gut_health_project_price");
+        if (priceStr != null) {
+            try {
+                return Integer.parseInt(priceStr);
+            } catch (NumberFormatException ignored) {
+            }
+        }
+        return DEFAULT_PRICE;
+    }
+
+    private Long getUserFamilyId(Long userId) {
+        User user = userMapper.selectById(userId);
+        return user != null ? user.getFamilyId() : null;
+    }
+
+    private Date addYears(Date date, int years) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        cal.add(Calendar.YEAR, years);
+        return cal.getTime();
+    }
+
+    private Map<String, Object> toProjectVO(GutHealthProject project) {
+        Map<String, Object> vo = new LinkedHashMap<>();
+        vo.put("id", project.getId());
+        vo.put("userId", project.getUserId());
+        vo.put("status", project.getStatus());
+        vo.put("paidAt", project.getPaidAt());
+        vo.put("completedAt", project.getCompletedAt());
+        vo.put("microbeTestRecommended", project.getMicrobeTestRecommended());
+        vo.put("memberDiscountQualified", project.getMemberDiscountQualified());
+        vo.put("familyMembershipQualified", project.getFamilyMembershipQualified());
+        vo.put("familyMembershipActivated", project.getFamilyMembershipActivated());
+        vo.put("cfRefundAmount", project.getCfRefundAmount());
+        vo.put("cfRefunded", project.getCfRefunded());
+        vo.put("completedTasksCount", project.getCompletedTasksCount());
+        return vo;
+    }
+
+    private String safeJson(Object obj) {
+        try {
+            return new ObjectMapper().writeValueAsString(obj);
+        } catch (Exception e) {
+            return obj.toString();
+        }
+    }
+
+    private List<Map<String, Object>> parseJsonList(String json) {
+        if (json == null || json.isEmpty()) {
+            return new ArrayList<>();
+        }
+        try {
+            return new ObjectMapper().readValue(json, new com.fasterxml.jackson.core.type.TypeReference<List<Map<String, Object>>>() {});
+        } catch (Exception e) {
+            return new ArrayList<>();
+        }
+    }
+}

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

@@ -112,6 +112,9 @@ public class HealthReportService {
     @Resource
     private FoodMapper foodMapper;
 
+    @Resource
+    private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+
     /**
      * 创建健康报告(含指标列表)
      */
@@ -1912,6 +1915,15 @@ public class HealthReportService {
         } catch (Exception e) {
             log.warn("编辑后刷新展示块失败 reportId={}: {}", reportId, e.getMessage());
         }
+
+        // 归一化管道:编辑后重写统一指标层(同事务;payload 为编辑后最新值)
+        try {
+            ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
+            oldReport.setSubjectId(subjectId != null && subjectId > 0 ? subjectId : oldSubjectId);
+            normalizeIndicatorPipeline.processReport(oldReport, editedPayload);
+        } catch (Exception e) {
+            log.warn("归一化管道刷新失败 reportId={}: {}", reportId, e.getMessage());
+        }
     }
 
     private Integer toInteger(Object value) {

+ 267 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java

@@ -0,0 +1,267 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.IndicatorCorrelation;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import com.etotem.cfc.util.SpearmanUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * 指标关联:人工定义(候选对+方向+人工强度)+ 真实数据校准(Spearman)。
+ * 对序规则:写入前按 definition.code 字典序比较,小的存 a、大的存 b(唯一键对称)。
+ * effective_strength:有效样本 >= 30 取 computed,否则取 manual;manual_locked=1 时校准不覆盖。
+ */
+@Service
+public class IndicatorCorrelationService {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorCorrelationService.class);
+
+    @Resource
+    private IndicatorCorrelationMapper correlationMapper;
+
+    @Resource
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @Resource
+    private IndicatorValueMapper valueMapper;
+
+    /** 有效样本阈值:>=30 才用 computed 覆盖 manual(设计 4.6) */
+    private static final int EFFECTIVE_SAMPLE_THRESHOLD = 30;
+
+    /** 人工定义/编辑关联对(强制对序) */
+    public void saveCorrelation(Long defA, Long defB, String direction, BigDecimal manualStrength,
+                                String description, Boolean manualLocked, String sourceNote, Long createdBy) {
+        if (defA == null || defB == null || defA.equals(defB)) {
+            throw new IllegalArgumentException("关联对不能为空且不能相同");
+        }
+        IndicatorDefinition da = definitionMapper.selectById(defA);
+        IndicatorDefinition db = definitionMapper.selectById(defB);
+        if (da == null || db == null) {
+            throw new IllegalArgumentException("指标定义不存在: " + defA + "/" + defB);
+        }
+        Long a = da.getCode().compareTo(db.getCode()) <= 0 ? defA : defB;
+        Long b = da.getCode().compareTo(db.getCode()) <= 0 ? defB : defA;
+
+        // 已存在则该对更新
+        IndicatorCorrelation existing = correlationMapper.selectOne(new LambdaQueryWrapper<IndicatorCorrelation>()
+                .eq(IndicatorCorrelation::getDefinitionIdA, a)
+                .eq(IndicatorCorrelation::getDefinitionIdB, b)
+                .last("LIMIT 1"));
+        IndicatorCorrelation c = existing != null ? existing : new IndicatorCorrelation();
+        c.setDefinitionIdA(a);
+        c.setDefinitionIdB(b);
+        if (c.getRelationType() == null) c.setRelationType("correlation");
+        c.setDirection(direction);
+        c.setManualStrength(manualStrength);
+        c.setEffectiveStrength(manualStrength); // 未校准时 = manual
+        if (manualLocked != null) c.setManualLocked(manualLocked ? 1 : 0);
+        if (c.getManualLocked() == null) c.setManualLocked(0);
+        c.setDescription(description);
+        c.setSourceNote(sourceNote);
+        c.setCreatedBy(createdBy);
+        if (c.getCohortScope() == null) c.setCohortScope("subject");
+        if (c.getStatus() == null || !c.getStatus().equals("inactive")) c.setStatus("active");
+        if (existing != null) {
+            c.setUpdatedAt(new Date());
+            correlationMapper.updateById(c);
+        } else {
+            c.setCreatedAt(new Date());
+            c.setUpdatedAt(new Date());
+            correlationMapper.insert(c);
+        }
+        log.info("关联对已保存: {} ({} ↔ {})", a, b, direction);
+    }
+
+    /** 关联查询(设计 6.4):返回该指标的全部关联 + 当前成员的配对证据 */
+    public Map<String, Object> listRelations(Long subjectId, Long definitionId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        List<Map<String, Object>> relations = new ArrayList<>();
+        if (definitionId == null) {
+            data.put("relations", relations);
+            return data;
+        }
+        List<IndicatorCorrelation> pairs = correlationMapper.selectList(new LambdaQueryWrapper<IndicatorCorrelation>()
+                .and(w -> w.eq(IndicatorCorrelation::getDefinitionIdA, definitionId)
+                        .or().eq(IndicatorCorrelation::getDefinitionIdB, definitionId))
+                .eq(IndicatorCorrelation::getStatus, "active"));
+
+        for (IndicatorCorrelation pair : pairs) {
+            Long otherId = pair.getDefinitionIdA().equals(definitionId)
+                    ? pair.getDefinitionIdB() : pair.getDefinitionIdA();
+            IndicatorDefinition other = definitionMapper.selectById(otherId);
+            Map<String, Object> rel = new LinkedHashMap<>();
+            rel.put("pairId", pair.getId());
+            Map<String, Object> indA = new LinkedHashMap<>();
+            indA.put("definitionId", pair.getDefinitionIdA());
+            IndicatorDefinition defA = definitionMapper.selectById(pair.getDefinitionIdA());
+            indA.put("name", defA != null ? defA.getName() : null);
+            rel.put("indicatorA", indA);
+            Map<String, Object> indB = new LinkedHashMap<>();
+            indB.put("definitionId", pair.getDefinitionIdB());
+            indB.put("name", other != null ? other.getName() : null);
+            rel.put("indicatorB", indB);
+            rel.put("direction", pair.getDirection());
+            rel.put("effectiveStrength", pair.getEffectiveStrength());
+            rel.put("strengthSource", pair.getComputedStrength() != null
+                    && pair.getSampleSize() != null && pair.getSampleSize() >= EFFECTIVE_SAMPLE_THRESHOLD
+                    ? "computed" : "manual");
+            rel.put("sampleSize", pair.getSampleSize());
+            rel.put("confidence", pair.getConfidence());
+            rel.put("manualLocked", pair.getManualLocked() != null && pair.getManualLocked() == 1);
+            rel.put("description", pair.getDescription());
+
+            // 该成员自己的配对数据支撑(如有)
+            Map<String, Object> evidence = subjectEvidence(subjectId, pair.getDefinitionIdA(), pair.getDefinitionIdB());
+            rel.put("subjectEvidence", evidence);
+            relations.add(rel);
+        }
+        data.put("relations", relations);
+        return data;
+    }
+
+    /** 成员级配对证据:点数 + 个人 rho + 是否足够(>=3) */
+    private Map<String, Object> subjectEvidence(Long subjectId, Long defA, Long defB) {
+        Map<String, Object> evidence = new LinkedHashMap<>();
+        evidence.put("points", 0);
+        evidence.put("personalRho", 0.0);
+        evidence.put("hasEnough", false);
+        if (subjectId == null) {
+            return evidence;
+        }
+        List<IndicatorValue> rowsA = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .eq(IndicatorValue::getDefinitionId, defA)
+                .isNotNull(IndicatorValue::getReportDate)
+                .orderByAsc(IndicatorValue::getReportDate));
+        List<IndicatorValue> rowsB = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .eq(IndicatorValue::getDefinitionId, defB)
+                .isNotNull(IndicatorValue::getReportDate)
+                .orderByAsc(IndicatorValue::getReportDate));
+        if (rowsA.size() < 3 || rowsB.size() < 3) {
+            return evidence;
+        }
+        // 按 report_date 对齐:累计最近 12 个共同日期点
+        List<java.math.BigDecimal> xs = new ArrayList<>();
+        List<java.math.BigDecimal> ys = new ArrayList<>();
+        int i = 0;
+        int j = 0;
+        while (i < rowsA.size() && j < rowsB.size() && xs.size() < 12) {
+            int cmp = rowsA.get(i).getReportDate().compareTo(rowsB.get(j).getReportDate());
+            if (cmp == 0) {
+                if (rowsA.get(i).getNumericValue() != null && rowsB.get(j).getNumericValue() != null) {
+                    xs.add(rowsA.get(i).getNumericValue());
+                    ys.add(rowsB.get(j).getNumericValue());
+                }
+                i++;
+                j++;
+            } else if (cmp < 0) {
+                i++;
+            } else {
+                j++;
+            }
+        }
+        if (xs.size() >= 3) {
+            evidence.put("points", xs.size());
+            evidence.put("personalRho", SpearmanUtil.spearmanRho(xs, ys));
+            evidence.put("hasEnough", true);
+        }
+        return evidence;
+    }
+
+    /**
+     * 全量校准(管理端手动触发 / @Scheduled 每日):
+     * 对 status=active 且 manual_locked=0 的关联对,按 subject 分组配对序列 → Spearman → Fisher z 聚合。
+     */
+    public int calibrateAll() {
+        List<IndicatorCorrelation> pairs = correlationMapper.selectList(new LambdaQueryWrapper<IndicatorCorrelation>()
+                .eq(IndicatorCorrelation::getStatus, "active"));
+        int updated = 0;
+        for (IndicatorCorrelation pair : pairs) {
+            if (pair.getManualLocked() != null && pair.getManualLocked() == 1) {
+                continue;
+            }
+            if (calibratePair(pair)) {
+                updated++;
+            }
+        }
+        log.info("指标关联校准完成,共更新 {} 对", updated);
+        return updated;
+    }
+
+    private boolean calibratePair(IndicatorCorrelation pair) {
+        // 全部观测(含所有 subject),按 (subject_id, report_date) 分组配对
+        List<IndicatorValue> rowsA = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdA())
+                .isNotNull(IndicatorValue::getReportDate));
+        List<IndicatorValue> rowsB = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getDefinitionId, pair.getDefinitionIdB())
+                .isNotNull(IndicatorValue::getReportDate));
+        // key = subjectId + '-' + reportDate
+        Map<String, IndicatorValue> mapA = rowsA.stream().filter(v -> v.getSubjectId() != null)
+                .collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x));
+        Map<String, IndicatorValue> mapB = rowsB.stream().filter(v -> v.getSubjectId() != null)
+                .collect(Collectors.toMap(v -> v.getSubjectId() + "-" + v.getReportDate(), v -> v, (x, y) -> x));
+
+        // 按 subject 分组配对点
+        Map<Long, List<java.math.BigDecimal[]>> bySubject = new java.util.HashMap<>();
+        int sampleSize = 0;
+        for (Map.Entry<String, IndicatorValue> e : mapA.entrySet()) {
+            IndicatorValue vb = mapB.get(e.getKey());
+            if (vb == null) continue;
+            IndicatorValue va = e.getValue();
+            if (va.getNumericValue() == null || vb.getNumericValue() == null) continue;
+            bySubject.computeIfAbsent(va.getSubjectId(), k -> new ArrayList<>())
+                    .add(new java.math.BigDecimal[]{va.getNumericValue(), vb.getNumericValue()});
+            sampleSize++;
+        }
+
+        if (sampleSize < 3) {
+            return false;
+        }
+        List<Double> rhos = new ArrayList<>();
+        for (List<java.math.BigDecimal[]> points : bySubject.values()) {
+            if (points.size() < 3) {
+                continue;
+            }
+            List<java.math.BigDecimal> xs = points.stream().map(p -> p[0]).collect(Collectors.toList());
+            List<java.math.BigDecimal> ys = points.stream().map(p -> p[1]).collect(Collectors.toList());
+            rhos.add(SpearmanUtil.spearmanRho(xs, ys));
+        }
+        if (rhos.isEmpty()) {
+            return false;
+        }
+        double avgRho = SpearmanUtil.fisherZAverage(rhos.stream().mapToDouble(Double::doubleValue).toArray());
+        String confidence = sampleSize >= 30 ? "high" : (sampleSize >= 10 ? "medium" : "low");
+
+        if (sampleSize >= EFFECTIVE_SAMPLE_THRESHOLD) {
+            pair.setEffectiveStrength(BigDecimal.valueOf(avgRho).setScale(3, RoundingMode.HALF_UP));
+        }
+        pair.setComputedStrength(BigDecimal.valueOf(avgRho).setScale(3, RoundingMode.HALF_UP));
+        pair.setMethod("spearman");
+        pair.setSampleSize(sampleSize);
+        pair.setConfidence(confidence);
+        pair.setComputedAt(new Date());
+        pair.setUpdatedAt(new Date());
+        correlationMapper.updateById(pair);
+        log.info("校准关联对 {}: rho={}, n={}, confidence={}",
+                pair.getId(), avgRho, sampleSize, confidence);
+        return true;
+    }
+}

+ 275 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java

@@ -0,0 +1,275 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorMappingRule;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 指标归一化映射:raw_name → definition_id。
+ * 解析顺序:exact 命中映射规则 → fuzzy(CJK 二元组相似度)→ AI 兜底(登记 pending)→ 惰性注册新定义。
+ * 单一写者模式的「身份解析」环节;规则表是权威,管理端可 review 修正。
+ */
+@Service
+public class IndicatorMappingService {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorMappingService.class);
+
+    @Resource
+    private IndicatorMappingRuleMapper ruleMapper;
+
+    @Resource
+    private IndicatorDefinitionMapper definitionMapper;
+
+    // 循环依赖破环:AiGateway→BasicQuotaService→MembershipService→…→HealthReportService→本类。
+    // @Lazy 仅对 @Autowired 生效(非 @Resource);AI 兜底按需触发,懒加载可安全打破 Spring Boot 2.7 默认的循环引用禁令。
+    @Lazy
+    @Autowired
+    private AiGateway aiGateway;
+
+    @Resource
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    /** fuzzy 相似度阈值(CJK 二元组 Dice 系数),低于则视为未命中 */
+    private static final double FUZZY_THRESHOLD = 0.5;
+
+    /**
+     * 解析原始指标名为 definition_id。
+     *
+     * @param sourceType 报告类型(gut_flora 等)
+     * @param rawName    报告原始名称(指标名/菌名/食材名,已 trim)
+     */
+    public Long resolve(String sourceType, String rawName) {
+        if (sourceType == null || rawName == null || rawName.trim().isEmpty()) {
+            return null;
+        }
+        String name = rawName.trim();
+
+        // 1) exact 命中规则表
+        IndicatorMappingRule rule = ruleMapper.selectOne(new LambdaQueryWrapper<IndicatorMappingRule>()
+                .eq(IndicatorMappingRule::getSourceType, sourceType)
+                .eq(IndicatorMappingRule::getRawName, name)
+                .orderByDesc(IndicatorMappingRule::getPriority)
+                .last("LIMIT 1"));
+        if (rule != null && rule.getDefinitionId() != null) {
+            return rule.getDefinitionId();
+        }
+
+        // 2) fuzzy:与同来源全部启用规则比较 CJK 二元组相似度
+        List<IndicatorMappingRule> candidates = ruleMapper.selectList(new LambdaQueryWrapper<IndicatorMappingRule>()
+                .eq(IndicatorMappingRule::getSourceType, sourceType)
+                .eq(IndicatorMappingRule::getStatus, 1));
+        IndicatorMappingRule best = null;
+        double bestScore = 0;
+        for (IndicatorMappingRule c : candidates) {
+            double s = bigramSimilarity(name, c.getRawName());
+            if (s > bestScore) {
+                bestScore = s;
+                best = c;
+            }
+        }
+        if (best != null && bestScore >= FUZZY_THRESHOLD) {
+            return best.getDefinitionId();
+        }
+
+        // 3) AI 兜底:只建议,登记 pending(status=0)规则待管理端确认
+        String aiJson = aiGateway != null
+                ? aiGateway.classifyIndicator(sourceType, name, Collections.emptyList())
+                : null;
+        if (aiJson != null) {
+            try {
+                JsonNode suggestion = objectMapper.readTree(aiJson);
+                if (suggestion != null && suggestion.hasNonNull("code")) {
+                    IndicatorDefinition def = definitionMapper.selectOne(new LambdaQueryWrapper<IndicatorDefinition>()
+                            .eq(IndicatorDefinition::getCode, suggestion.get("code").asText())
+                            .last("LIMIT 1"));
+                    if (def != null) {
+                        registerRule(sourceType, name, def.getId(), "ai_fallback", 0);
+                        log.info("AI 兜底建议:sourceType={}, raw={} → defId={}(pending 待确认)",
+                                sourceType, name, def.getId());
+                        return def.getId();
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("AI 归类建议解析失败 raw={}: {}", name, e.getMessage());
+            }
+        }
+
+        // 4) 惰性注册:新 definition + auto_created 规则
+        return lazyRegister(sourceType, name);
+    }
+
+    /** 惰性注册新定义(code 规则:indicator.<cat>.<name> / bacteria.<name> / food.<name>) */
+    private Long lazyRegister(String sourceType, String rawName) {
+        DefinitionCode code = buildCode(sourceType, rawName);
+        IndicatorDefinition def = new IndicatorDefinition();
+        def.setDomain("physical");
+        def.setCategory(code.category);
+        def.setCode(code.code);
+        def.setName(rawName);
+        def.setDataType("number");
+        def.setSourceKind(code.sourceKind);
+        def.setReportTypeScope(sourceType);
+        def.setValueType(inferValueType(sourceType));
+        def.setGoodDirection("neutral");
+        def.setSortOrder(0);
+        def.setStatus(1);
+        def.setCreatedAt(new Date());
+        def.setUpdatedAt(new Date());
+        definitionMapper.insert(def);
+        registerRule(sourceType, rawName, def.getId(), "auto_created", 1);
+        log.info("惰性注册指标定义 sourceType={}, raw={} → defId={}", sourceType, rawName, def.getId());
+        return def.getId();
+    }
+
+    private void registerRule(String sourceType, String rawName, Long definitionId, String matchType, int status) {
+        IndicatorMappingRule rule = new IndicatorMappingRule();
+        rule.setSourceType(sourceType);
+        rule.setRawName(rawName);
+        rule.setDefinitionId(definitionId);
+        rule.setMatchType(matchType);
+        rule.setPriority(0);
+        rule.setStatus(status);
+        rule.setCreatedAt(new Date());
+        rule.setUpdatedAt(new Date());
+        try {
+            ruleMapper.insert(rule);
+        } catch (Exception e) {
+            log.warn("登记映射规则冲突(已存在?),忽略 sourceType={}, raw={}: {}", sourceType, rawName, e.getMessage());
+        }
+    }
+
+    // ---------- 内部工具 ----------
+
+    private static class DefinitionCode {
+        String code;
+        String category;
+        String sourceKind;
+    }
+
+    private DefinitionCode buildCode(String sourceType, String rawName) {
+        DefinitionCode dc = new DefinitionCode();
+        if ("food_recommend".equals(sourceType)) {
+            dc.sourceKind = "food_index";
+            dc.category = "食材";
+            dc.code = "food." + rawName;
+        } else if (isBacteriaContext(sourceType)) {
+            dc.sourceKind = "bacteria";
+            dc.category = "菌属";
+            dc.code = "bacteria." + rawName;
+        } else {
+            dc.sourceKind = "report_indicator";
+            dc.category = "报告指标";
+            dc.code = "indicator." + rawName;
+        }
+        // code 有唯一约束,过长/含特殊字符时安全截断
+        if (dc.code.length() > 190) {
+            dc.code = dc.code.substring(0, 190);
+        }
+        return dc;
+    }
+
+    private boolean isBacteriaContext(String sourceType) {
+        return sourceType != null && sourceType.startsWith("gut_flora");
+    }
+
+    private String inferValueType(String sourceType) {
+        return isBacteriaContext(sourceType) ? "numeric" : "numeric";
+    }
+
+    /**
+     * CJK 二元组 Dice 相似度:中文按字符二元组,ASCII 按词/字符二元组混合。
+     * 0~1;1 = 完全相同(但 exact 已先行命中)。
+     */
+    double bigramSimilarity(String a, String b) {
+        if (a == null || b == null) {
+            return 0;
+        }
+        if (a.equals(b)) {
+            return 1;
+        }
+        List<String> gramsA = bigrams(a);
+        List<String> gramsB = bigrams(b);
+        if (gramsA.isEmpty() || gramsB.isEmpty()) {
+            return 0;
+        }
+        int common = 0;
+        for (String g : gramsA) {
+            if (gramsB.contains(g)) {
+                common++;
+            }
+        }
+        return (2.0 * common) / (gramsA.size() + gramsB.size());
+    }
+
+    private List<String> bigrams(String s) {
+        if (s == null || s.length() < 2) {
+            List<String> single = new ArrayList<>();
+            if (s != null && !s.isEmpty()) {
+                single.add(s);
+            }
+            return single;
+        }
+        List<String> result = new ArrayList<>();
+        for (int i = 0; i < s.length() - 1; i++) {
+            result.add(s.substring(i, i + 2));
+        }
+        return result;
+    }
+
+    /** 数值型解析:去掉 %/~/>/< 与空格后尝试 BigDecimal */
+    public BigDecimal parseNumericValue(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String cleaned = raw.replaceAll("[%<>~\\s]", "").trim();
+        if (cleaned.isEmpty()) {
+            return null;
+        }
+        try {
+            return new BigDecimal(cleaned);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /** 批量种子导入(管理端调用):seed 列表 [{rawName, definitionId?, category?, unit?}] */
+    public int importSeeds(String sourceType, List<Map<String, Object>> seeds) {
+        int count = 0;
+        for (Map<String, Object> seed : seeds) {
+            String rawName = seed.get("rawName") != null ? seed.get("rawName").toString() : null;
+            if (rawName == null || rawName.trim().isEmpty()) {
+                continue;
+            }
+            Object defIdObj = seed.get("definitionId");
+            Long defId;
+            if (defIdObj instanceof Number) {
+                defId = ((Number) defIdObj).longValue();
+            } else if (defIdObj != null) {
+                defId = Long.valueOf(defIdObj.toString());
+            } else {
+                defId = lazyRegister(sourceType, rawName);
+            }
+            registerRule(sourceType, rawName.trim(), defId, "exact", 1);
+            count++;
+        }
+        log.info("种子导入完成 sourceType={}, seeds={}", sourceType, count);
+        return count;
+    }
+}

+ 370 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java

@@ -0,0 +1,370 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
+import com.etotem.cfc.mapper.HealthDimensionScoreMapper;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 指标视图服务:trend(曲线)/ change-ranking(变化排名)/ quick-lookup(速查)。
+ * 只读 indicator_values;五维/七维评分不写时序,quick-lookup API 层单独合入 dimensionScores 小节。
+ */
+@Service
+public class IndicatorTrendService {
+
+    private static final Logger log = LoggerFactory.getLogger(IndicatorTrendService.class);
+
+    @Resource
+    private IndicatorValueMapper valueMapper;
+
+    @Resource
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @Resource
+    private FiveDimensionScoreMapper fiveDimensionScoreMapper;
+
+    @Resource
+    private HealthDimensionScoreMapper healthDimensionScoreMapper;
+
+    /** 指标曲线:按 definitionId 分组、report_date 升序返回点序列 */
+    public Map<String, Object> trend(Long subjectId, List<Long> definitionIds, Long familyId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        List<Map<String, Object>> items = new ArrayList<>();
+        if (definitionIds == null || definitionIds.isEmpty() || subjectId == null) {
+            data.put("items", items);
+            return data;
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .in(IndicatorValue::getDefinitionId, definitionIds)
+                .isNotNull(IndicatorValue::getReportDate)
+                .orderByAsc(IndicatorValue::getReportDate));
+
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId,
+                        LinkedHashMap::new, Collectors.toList()));
+
+        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", e.getKey());
+            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
+                    id -> definitionMapper.selectById(id));
+            item.put("name", def != null ? def.getName() : "指标" + e.getKey());
+            item.put("unit", def != null ? def.getUnit() : null);
+            List<Map<String, Object>> points = new ArrayList<>();
+            for (IndicatorValue v : e.getValue()) {
+                Map<String, Object> p = new LinkedHashMap<>();
+                p.put("reportDate", v.getReportDate() != null
+                        ? new SimpleDateFormat("yyyy-MM-dd").format(v.getReportDate()) : null);
+                p.put("value", v.getValue());
+                p.put("numericValue", v.getNumericValue());
+                p.put("unit", v.getUnit());
+                p.put("status", v.getStatus());
+                points.add(p);
+            }
+            item.put("points", points);
+            items.add(item);
+        }
+        data.put("items", items);
+        return data;
+    }
+
+    /** 变化幅度排名(设计 6.2)。scope=adjacent 相邻两次(默认)/ first-vs-latest 首尾对比。 */
+    public Map<String, Object> changeRanking(Long subjectId, String reportType, String category,
+                                             String scope, String orderBy, Integer limit, Long familyId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        List<Map<String, Object>> items = new ArrayList<>();
+        int improved = 0;
+        int worsened = 0;
+        int flat = 0;
+
+        LambdaQueryWrapper<IndicatorValue> qw = new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .isNotNull(IndicatorValue::getReportDate);
+        if (reportType != null && !reportType.isEmpty()) {
+            qw.eq(IndicatorValue::getSourceType, reportType);
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
+
+        // 按 definitionId 分组
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
+        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            List<IndicatorValue> sorted = e.getValue().stream()
+                    .sorted((a, b) -> a.getReportDate().compareTo(b.getReportDate()))
+                    .collect(Collectors.toList());
+            if (sorted.size() < 2) {
+                continue;
+            }
+            IndicatorValue before = sorted.get(0);
+            IndicatorValue after = sorted.get(sorted.size() - 1);
+            if ("adjacent".equals(scope)) {
+                before = sorted.get(sorted.size() - 2);
+            }
+            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
+                    id -> definitionMapper.selectById(id));
+            String defCategory = def != null ? def.getCategory() : null;
+            if (category != null && !category.isEmpty() && !category.equals(defCategory)) {
+                continue;
+            }
+            BigDecimal bv = before.getNumericValue();
+            BigDecimal av = after.getNumericValue();
+            if (bv == null || av == null) {
+                continue;
+            }
+            BigDecimal delta = av.subtract(bv);
+            BigDecimal deltaPct = bv.compareTo(BigDecimal.ZERO) != 0
+                    ? delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+            String movement = "flat";
+            if (delta.compareTo(BigDecimal.ZERO) > 0) movement = "up";
+            else if (delta.compareTo(BigDecimal.ZERO) < 0) movement = "down";
+
+            String trendLabel = trendLabel(def, movement, delta);
+            if ("改善".equals(trendLabel)) improved++;
+            else if ("恶化".equals(trendLabel)) worsened++;
+            else flat++;
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", e.getKey());
+            item.put("name", def != null ? def.getName() : "指标" + e.getKey());
+            item.put("category", defCategory);
+            item.put("sourceKind", def != null ? def.getSourceKind() : null);
+            item.put("unit", def != null ? def.getUnit() : null);
+            item.put("goodDirection", def != null ? def.getGoodDirection() : "neutral");
+            Map<String, Object> beforeMap = new LinkedHashMap<>();
+            beforeMap.put("reportDate", fmtDate(before.getReportDate()));
+            beforeMap.put("value", before.getValue());
+            Map<String, Object> afterMap = new LinkedHashMap<>();
+            afterMap.put("reportDate", fmtDate(after.getReportDate()));
+            afterMap.put("value", after.getValue());
+            item.put("before", beforeMap);
+            item.put("after", afterMap);
+            item.put("delta", delta);
+            item.put("deltaPct", deltaPct);
+            item.put("movement", movement);
+            item.put("trendLabel", trendLabel);
+            items.add(item);
+        }
+
+        // 排序:magnitude 按 Δ 绝对值降序(默认);up/down 按 Δ 正/负降序
+        if ("up".equals(orderBy)) {
+            items.sort((a, b) -> ((BigDecimal) b.get("delta")).compareTo((BigDecimal) a.get("delta")));
+        } else if ("down".equals(orderBy)) {
+            items.sort((a, b) -> ((BigDecimal) a.get("delta")).compareTo((BigDecimal) b.get("delta")));
+        } else {
+            items.sort((a, b) -> ((BigDecimal) b.get("delta")).abs()
+                    .compareTo(((BigDecimal) a.get("delta")).abs()));
+        }
+
+        int limitVal = limit != null && limit > 0 ? limit : 50;
+        if (items.size() > limitVal) {
+            items = new ArrayList<>(items.subList(0, limitVal));
+        }
+
+        data.put("items", items);
+        Map<String, Object> overall = new LinkedHashMap<>();
+        overall.put("improvedCount", improved);
+        overall.put("worsenedCount", worsened);
+        overall.put("flatCount", flat);
+        data.put("overall", overall);
+        return data;
+    }
+
+    /** 指标速查(设计 6.3):分类分页 + 五维/七维评分小节 */
+    public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
+                                           Integer pageSize, Long familyId) {
+        Map<String, Object> data = new LinkedHashMap<>();
+        int pageVal = page != null && page > 0 ? page : 1;
+        int sizeVal = pageSize != null && pageSize > 0 ? pageSize : 30;
+
+        // 该成员全部观测(按时序取最新一条)
+        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .isNotNull(IndicatorValue::getDefinitionId));
+        Map<Long, List<IndicatorValue>> byDef = rows.stream()
+                .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
+        Map<Long, IndicatorDefinition> defCache = new HashMap<>();
+        List<Map<String, Object>> allItems = new ArrayList<>();
+
+        for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+            IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
+                    id -> definitionMapper.selectById(id));
+            if (def == null) {
+                continue;
+            }
+            if (category != null && !category.isEmpty() && !category.equals(def.getCategory())) {
+                continue;
+            }
+            List<IndicatorValue> sorted = e.getValue().stream()
+                    .sorted((a, b) -> a.getReportDate() == null ? -1
+                            : (b.getReportDate() == null ? 1 : b.getReportDate().compareTo(a.getReportDate())))
+                    .collect(Collectors.toList());
+            IndicatorValue latest = sorted.get(0);
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("definitionId", e.getKey());
+            item.put("name", def.getName());
+            item.put("category", def.getCategory());
+            item.put("value", latest.getValue());
+            item.put("unit", latest.getUnit() != null ? latest.getUnit() : def.getUnit());
+            item.put("refRange", def.getRefRange());
+            item.put("status", latest.getStatus());
+            item.put("trend", trendOf(sorted));
+            item.put("trendLabel", trendLabel(def, trendOf(sorted), deltaOf(sorted)));
+            item.put("latestReportDate", latest.getReportDate() != null
+                    ? new SimpleDateFormat("yyyy-MM-dd").format(latest.getReportDate()) : null);
+            item.put("pointsCount", sorted.size());
+            allItems.add(item);
+        }
+
+        // categories 汇总(含 total)
+        List<Map<String, Object>> categories = new ArrayList<>();
+        Map<String, Long> catCount = allItems.stream()
+                .collect(Collectors.groupingBy(i -> (String) i.get("category"), Collectors.counting()));
+        catCount.forEach((name, total) -> {
+            Map<String, Object> c = new LinkedHashMap<>();
+            c.put("name", name);
+            c.put("total", total);
+            categories.add(c);
+        });
+
+        // 分页
+        int fromIndex = Math.min((pageVal - 1) * sizeVal, allItems.size());
+        int toIndex = Math.min(fromIndex + sizeVal, allItems.size());
+        List<Map<String, Object>> pageItems = allItems.isEmpty()
+                ? new ArrayList<>()
+                : new ArrayList<>(allItems.subList(fromIndex, toIndex));
+
+        data.put("categories", categories);
+        data.put("items", pageItems);
+        data.put("page", pageVal);
+        data.put("pageSize", sizeVal);
+        data.put("total", allItems.size());
+        data.put("dimensionScores", loadDimensionScores(subjectId));
+        return data;
+    }
+
+    /** 五维/七维评分小节(不写 indicator_values,API 层 join 原表) */
+    private List<Map<String, Object>> loadDimensionScores(Long subjectId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        // 五维:five_dimension_scores 按 memberId + assessedAt 最新
+        try {
+            result.addAll(fiveDimensionScoreMapper.selectList(
+                    new LambdaQueryWrapper<com.etotem.cfc.entity.FiveDimensionScore>()
+                            .eq(com.etotem.cfc.entity.FiveDimensionScore::getMemberId, subjectId)
+                            .orderByDesc(com.etotem.cfc.entity.FiveDimensionScore::getAssessedAt)
+                            .last("LIMIT 10"))
+                    .stream().map(s -> {
+                        Map<String, Object> m = new LinkedHashMap<>();
+                        m.put("dimensionCode", s.getDimensionCode());
+                        m.put("score", s.getScore());
+                        m.put("assessedAt", s.getAssessedAt() != null
+                                ? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessedAt()) : null);
+                        m.put("source", "five_dimension");
+                        return m;
+                    }).collect(Collectors.toList()));
+        } catch (Exception e) {
+            log.warn("加载五维评分失败 subjectId={}: {}", subjectId, e.getMessage());
+        }
+        // 七维:health_dimension_scores 按 memberId + assessDate 最新
+        try {
+            result.addAll(healthDimensionScoreMapper.selectList(
+                    new LambdaQueryWrapper<com.etotem.cfc.entity.HealthDimensionScore>()
+                            .eq(com.etotem.cfc.entity.HealthDimensionScore::getMemberId, subjectId)
+                            .orderByDesc(com.etotem.cfc.entity.HealthDimensionScore::getAssessDate)
+                            .last("LIMIT 10"))
+                    .stream().map(s -> {
+                        Map<String, Object> m = new LinkedHashMap<>();
+                        m.put("dimensionCode", s.getDimension());
+                        m.put("score", s.getScore());
+                        m.put("assessedAt", s.getAssessDate() != null
+                                ? new SimpleDateFormat("yyyy-MM-dd").format(s.getAssessDate()) : null);
+                        m.put("source", "health_dimension");
+                        return m;
+                    }).collect(Collectors.toList()));
+        } catch (Exception e) {
+            log.warn("加载七维评分失败 subjectId={}: {}", subjectId, e.getMessage());
+        }
+        return result;
+    }
+
+    // ---------- 工具 ----------
+
+    private String fmtDate(Date d) {
+        return d != null ? new SimpleDateFormat("yyyy-MM-dd").format(d) : null;
+    }
+
+    /** 序列趋势:末尾两条比较 */
+    private String trendOf(List<IndicatorValue> sorted) {
+        if (sorted.size() < 2) {
+            return "flat";
+        }
+        IndicatorValue latest = sorted.get(0);
+        IndicatorValue prev = sorted.get(1);
+        if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
+            return "flat";
+        }
+        int cmp = latest.getNumericValue().compareTo(prev.getNumericValue());
+        return cmp > 0 ? "up" : (cmp < 0 ? "down" : "flat");
+    }
+
+    private BigDecimal deltaOf(List<IndicatorValue> sorted) {
+        if (sorted.size() < 2) {
+            return BigDecimal.ZERO;
+        }
+        IndicatorValue latest = sorted.get(0);
+        IndicatorValue prev = sorted.get(1);
+        if (latest.getNumericValue() == null || prev.getNumericValue() == null) {
+            return BigDecimal.ZERO;
+        }
+        return latest.getNumericValue().subtract(prev.getNumericValue());
+    }
+
+    /**
+     * 变化着色/标签规则(设计第 8 节):
+     * good_direction × 数值变化方向 → 改善/恶化/中性。
+     */
+    private String trendLabel(IndicatorDefinition def, String movement, BigDecimal delta) {
+        if ("flat".equals(movement) || delta == null || delta.compareTo(BigDecimal.ZERO) == 0) {
+            return "中性";
+        }
+        if (def == null || def.getGoodDirection() == null) {
+            return "中性";
+        }
+        boolean up = "up".equals(movement);
+        switch (def.getGoodDirection()) {
+            case "higher_is_better":
+                return up ? "改善" : "恶化";
+            case "lower_is_better":
+                return up ? "恶化" : "改善";
+            case "in_range_is_better":
+                // 一期简化:无 refRange 解析,回落为中性
+                return "中性";
+            default:
+                return "中性";
+        }
+    }
+}

+ 29 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/KeyPersonService.java

@@ -318,7 +318,7 @@ public Result<OctopusKeyPerson> addKeyPerson(Long userId, String name, String or
         kp.setDepartment(department != null ? department.trim() : null);
         kp.setTitle(title != null ? title.trim() : null);
         kp.setFirstMeetScene(firstMeetScene != null ? firstMeetScene.trim() : null);
-        kp.setFirstMeetTime(firstMeetTime != null ? java.sql.Timestamp.valueOf(firstMeetTime) : null);
+        kp.setFirstMeetTime(parseFlexibleTimestamp(firstMeetTime));
         kp.setKnowWay(knowWay != null ? knowWay.trim() : null);
         kp.setContactInfo(contactInfo != null ? contactInfo.trim() : null);
         kp.setReferrerId(referrerId);
@@ -327,6 +327,34 @@ public Result<OctopusKeyPerson> addKeyPerson(Long userId, String name, String or
         return Result.success(kp);
     }
 
+    /**
+     * 宽松解析首次见面时间,兼容前端实际发送的三种格式:
+     * - 仅日期 "2026-10-10"(小程序 date picker)
+     * - ISO 8601 "2026-10-10T14:30:00" / 带毫秒 "2026-10-10T14:30:00.123"
+     * - MySQL/Timestamp.toString 风格 "2026-10-10 14:30:00" / "2026-10-10 14:30:00.0"
+     * 空值/解析失败返回 null(字段为选填,沿用 addEffect 对 happenedAt 的宽容处理)。
+     */
+    private java.util.Date parseFlexibleTimestamp(String s) {
+        if (s == null) return null;
+        s = s.trim();
+        if (s.isEmpty()) return null;
+        String[] patterns = {
+                "yyyy-MM-dd'T'HH:mm:ss.SSS",
+                "yyyy-MM-dd'T'HH:mm:ss",
+                "yyyy-MM-dd HH:mm:ss.SSS",
+                "yyyy-MM-dd HH:mm:ss",
+                "yyyy-MM-dd"
+        };
+        for (String p : patterns) {
+            try {
+                java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat(p);
+                sdf.setLenient(false);
+                return new java.sql.Timestamp(sdf.parse(s).getTime());
+            } catch (Exception ignored) { /* 尝试下一种格式 */ }
+        }
+        return null;
+    }
+
     public Result<OctopusKeyPerson> updateKeyPerson(Long userId, Long id, Map<String, Object> params) {
         OctopusKeyPerson kp = keyPersonMapper.selectById(id);
         if (kp == null || !kp.getUserId().equals(userId)) {

+ 22 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java

@@ -49,7 +49,11 @@ public class LangGraphReportParseService {
      * 转换后无有效指标数据时返回 null。
      */
     public ParsedReportPayload.Payload parseByScript(String pdfPath) {
-        Map<String, Object> data = postParse("/api/v1/report/parse", pdfPath);
+        return parseByScript(pdfPath, null);
+    }
+
+    public ParsedReportPayload.Payload parseByScript(String pdfPath, List<String> indicators) {
+        Map<String, Object> data = postParse("/api/v1/report/parse", pdfPath, indicators);
         if (data == null) {
             return null;
         }
@@ -64,9 +68,13 @@ public class LangGraphReportParseService {
      * 均无有效数据时返回 null,调用方回退 opencode。
      */
     public ParsedReportPayload.Payload parseWithDetect(String pdfPath) {
+        return parseWithDetect(pdfPath, null);
+    }
+
+    public ParsedReportPayload.Payload parseWithDetect(String pdfPath, List<String> indicators) {
         String detected = detectType(pdfPath);
         if (detected != null && !DETECTED_GENERIC.equals(detected)) {
-            ParsedReportPayload.Payload typed = parseByType(pdfPath, detected);
+            ParsedReportPayload.Payload typed = parseByType(pdfPath, detected, indicators);
             if (typed != null) {
                 log.info("LangGraph 识别到类型 {} 并解析成功", detected);
                 return typed;
@@ -75,7 +83,7 @@ public class LangGraphReportParseService {
         } else {
             log.info("LangGraph 无法识别报告类型(generic),尝试算法脚本解析");
         }
-        return parseByScript(pdfPath);
+        return parseByScript(pdfPath, indicators);
     }
 
     /** 调 /report/detect-type 识别报告类型,失败返回 null */
@@ -89,10 +97,13 @@ public class LangGraphReportParseService {
     }
 
     /** 调 /report/parse-typed 按指定类型解析(brain_status/cognitive_aptitude/scanned_image) */
-    private ParsedReportPayload.Payload parseByType(String pdfPath, String reportType) {
+    private ParsedReportPayload.Payload parseByType(String pdfPath, String reportType, List<String> indicators) {
         Map<String, Object> body = new HashMap<>();
         body.put("file_path", pdfPath);
         body.put("report_type", reportType);
+        if (indicators != null && !indicators.isEmpty()) {
+            body.put("indicators", indicators);
+        }
         Map<String, Object> data = postParse("/api/v1/report/parse-typed", body);
         if (data == null) {
             return null;
@@ -105,8 +116,15 @@ public class LangGraphReportParseService {
 
     /** 调 LangGraph 端点(仅为 file_path 场景),返回 data 字典;网络错误/非200/空 data 返回 null */
     private Map<String, Object> postParse(String path, String pdfPath) {
+        return postParse(path, pdfPath, null);
+    }
+
+    private Map<String, Object> postParse(String path, String pdfPath, List<String> indicators) {
         Map<String, Object> body = new HashMap<>();
         body.put("file_path", pdfPath);
+        if (indicators != null && !indicators.isEmpty()) {
+            body.put("indicators", indicators);
+        }
         return postParse(path, body);
     }
 

+ 130 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java

@@ -0,0 +1,130 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ParsedReportPayload;
+import com.etotem.cfc.entity.FoodRecommendIndex;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.List;
+
+/**
+ * 归一化管道 —— 统一指标层「单一写者」。
+ * 报告入库/编辑流程照旧写 health_indicators(不动现有消费者),
+ * 本管道在同流程/同事务内将 指标+菌属+食材 幂等写入 indicator_values。
+ * 唯一键 (source_type, source_id, definition_id) 保证重解析只覆盖更新。
+ */
+@Service
+public class NormalizeIndicatorPipeline {
+
+    private static final Logger log = LoggerFactory.getLogger(NormalizeIndicatorPipeline.class);
+
+    @Resource
+    private IndicatorMappingService mappingService;
+
+    @Resource
+    private IndicatorValueMapper valueMapper;
+
+    /**
+     * 报告指标 + 菌属归一化写入。
+     * 调用点:insertReport()(subjectId 块后)/ updateReportFromPayload()(步骤 5 后)。
+     * 无 subject 的报告不入时序(无法归属观测对象)。
+     *
+     * @param report  已入库/已更新的报告(reportDate 须为真实日期或 null)
+     * @param payload 解析载荷(gutFlora 从 payload 读;indicators 从 payload 读,与 health_indicators 同源)
+     */
+    public void processReport(HealthReport report, ParsedReportPayload.Payload payload) {
+        if (report == null || report.getSubjectId() == null || report.getId() == null) {
+            return;
+        }
+        Long subjectId = report.getSubjectId();
+        Long familyId = report.getFamilyId();
+        Long reportId = report.getId();
+        String sourceType = report.getReportType();
+        Date reportDate = report.getReportDate() != null
+                ? new Date(report.getReportDate().getTime()) : null;
+
+        int written = 0;
+        if (payload != null && payload.getIndicators() != null) {
+            for (ParsedReportPayload.Indicator ind : payload.getIndicators()) {
+                if (ind.getIndicatorName() == null || ind.getIndicatorName().trim().isEmpty()) {
+                    continue;
+                }
+                Long defId = mappingService.resolve(sourceType, ind.getIndicatorName().trim());
+                if (defId == null) {
+                    continue;
+                }
+                IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
+                v.setValue(ind.getIndicatorValue());
+                v.setNumericValue(mappingService.parseNumericValue(ind.getIndicatorValue()));
+                v.setUnit(ind.getUnit());
+                v.setStatus(ind.getStatus());
+                valueMapper.upsertMapping(v);
+                written++;
+            }
+        }
+        if (payload != null && payload.getGutFlora() != null) {
+            for (ParsedReportPayload.Flora flora : payload.getGutFlora()) {
+                if (flora.getBacteriaName() == null || flora.getBacteriaName().trim().isEmpty()) {
+                    continue;
+                }
+                Long defId = mappingService.resolve(sourceType, flora.getBacteriaName().trim());
+                if (defId == null) {
+                    continue;
+                }
+                IndicatorValue v = baseValue(sourceType, reportId, defId, subjectId, familyId, reportDate);
+                v.setValue(flora.getBacteriaValue());
+                v.setNumericValue(mappingService.parseNumericValue(flora.getBacteriaValue()));
+                v.setUnit("%");
+                v.setStatus(flora.getStatus());
+                valueMapper.upsertMapping(v);
+                written++;
+            }
+        }
+        log.info("归一化管道 reportId={}, subjectId={}, type={},写入 {} 条观测", reportId, subjectId, sourceType, written);
+    }
+
+    /**
+     * 食材推荐指数全量快照(钩子 3:FoodRecommendService.recalculateForUser 尾部调用)。
+     * source_type=food_recommend,source_id=food_recommend_idx.id,幂等覆盖。
+     */
+    public void snapshotFoodIndices(Long userId, List<FoodRecommendIndex> indices) {
+        if (userId == null || indices == null || indices.isEmpty()) {
+            return;
+        }
+        for (FoodRecommendIndex idx : indices) {
+            if (idx.getFoodName() == null || idx.getFoodName().trim().isEmpty()) {
+                continue;
+            }
+            Long defId = mappingService.resolve("food_recommend", idx.getFoodName().trim());
+            if (defId == null) {
+                continue;
+            }
+            IndicatorValue v = baseValue("food_recommend", idx.getId(), defId, userId, null, null);
+            v.setValue(idx.getIndexScore() != null ? idx.getIndexScore().toString() : null);
+            v.setNumericValue(idx.getIndexScore() != null
+                    ? BigDecimal.valueOf(idx.getIndexScore()) : null);
+            v.setReportId(idx.getHealthReportId());
+            valueMapper.upsertMapping(v);
+        }
+        log.info("食材指数快照 userId={}, 共 {} 条", userId, indices.size());
+    }
+
+    private IndicatorValue baseValue(String sourceType, Long sourceId, Long defId,
+                                     Long subjectId, Long familyId, Date reportDate) {
+        IndicatorValue v = new IndicatorValue();
+        v.setSourceType(sourceType);
+        v.setSourceId(sourceId);
+        v.setDefinitionId(defId);
+        v.setSubjectId(subjectId);
+        v.setFamilyId(familyId);
+        v.setReportDate(reportDate);
+        return v;
+    }
+}

+ 61 - 11
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -10,6 +10,8 @@ import com.etotem.cfc.dto.ProductOrderDTO;
 import com.etotem.cfc.entity.AfterSalesRequest;
 import com.etotem.cfc.entity.AssessmentProduct;
 import com.etotem.cfc.entity.Consignee;
+import com.etotem.cfc.entity.UserAddress;
+import com.etotem.cfc.mapper.UserAddressMapper;
 import com.etotem.cfc.entity.Coupon;
 import com.etotem.cfc.entity.FamilyCoupon;
 import com.etotem.cfc.entity.Product;
@@ -181,6 +183,9 @@ public class ProductOrderService {
     @Resource
     private com.etotem.cfc.mapper.ConsigneeMapper consigneeMapper;
 
+    @Resource
+    private UserAddressMapper userAddressMapper;
+
     @Resource
     private ProductOrderGiftMapper productOrderGiftMapper;
 
@@ -256,8 +261,7 @@ public class ProductOrderService {
         order.setCoverImage(product.getCoverImage());
         order.setDiscountAmount(discountAmount);
         order.setCouponId(dto.getCouponId());
-        order.setAddressSnapshot(dto.getAddressSnapshot());
-        order.setConsigneeId(dto.getConsigneeId());
+        resolveOrderAddress(order, dto, buyerId);
         if (dto.getPurchaseInfo() != null && !dto.getPurchaseInfo().isEmpty()) {
             order.setPurchaseInfo(JSON.toJSONString(dto.getPurchaseInfo()));
         }
@@ -297,6 +301,44 @@ public class ProductOrderService {
      * 配送方式与履约校验(设计文档十一节)
      * 分支校验 + 生成快照 + 扣减时间段余量;返回 error!=null 表示校验失败
      */
+    /** 落库前统一整理收货地址:优先直接快照,其次 addressId(user_address),最后 consigneeId(consignees) */
+    private void resolveOrderAddress(ProductOrder order, CreateProductOrderDTO dto, Long buyerId) {
+        String snapshot = dto.getAddressSnapshot();
+        Long consigneeId = dto.getConsigneeId();
+        if ((snapshot == null || snapshot.isEmpty()) && dto.getAddressId() != null) {
+            UserAddress ua = userAddressMapper.selectById(dto.getAddressId());
+            if (ua != null && buyerId.equals(ua.getUserId())) {
+                Map<String, Object> snap = new HashMap<>();
+                snap.put("receiverName", ua.getReceiverName());
+                snap.put("phone", ua.getPhone());
+                snap.put("province", ua.getProvince());
+                snap.put("city", ua.getCity());
+                snap.put("district", ua.getDistrict());
+                snap.put("street", ua.getStreet());
+                snap.put("isDefault", ua.getIsDefault());
+                snap.put("source", "user_address");
+                snapshot = JSON.toJSONString(snap);
+                consigneeId = ua.getId();
+            }
+        } else if ((snapshot == null || snapshot.isEmpty()) && dto.getConsigneeId() != null) {
+            Consignee c = consigneeMapper.selectById(dto.getConsigneeId());
+            if (c != null && buyerId.equals(c.getUserId())) {
+                Map<String, Object> snap = new HashMap<>();
+                snap.put("receiverName", c.getName());
+                snap.put("phone", c.getPhone());
+                snap.put("province", c.getProvince());
+                snap.put("city", c.getCity());
+                snap.put("district", c.getDistrict());
+                snap.put("street", c.getStreet());
+                snap.put("source", "consignee");
+                snapshot = JSON.toJSONString(snap);
+                consigneeId = c.getId();
+            }
+        }
+        order.setAddressSnapshot(snapshot);
+        order.setConsigneeId(consigneeId);
+    }
+
     private FulfillValidation validateFulfill(Product product, CreateProductOrderDTO dto, Long buyerId) {
         FulfillValidation fv = new FulfillValidation();
         Integer productMethod = product.getDeliveryMethod() != null ? product.getDeliveryMethod() : 1;
@@ -316,16 +358,25 @@ public class ProductOrderService {
 
         switch (actualMethod) {
             case 1:
-                // 快递:校验收货地址存在且属于当前用户
-                if (dto.getConsigneeId() == null) {
+                // 快递:收货地址三选一校验:直接快照 > addressId(user_address) > consigneeId(consignees)
+                if (dto.getAddressSnapshot() != null && !dto.getAddressSnapshot().isEmpty()) {
+                    // 直接提供快照,校验通过
+                } else if (dto.getAddressId() != null) {
+                    UserAddress ua = userAddressMapper.selectById(dto.getAddressId());
+                    if (ua == null || !buyerId.equals(ua.getUserId())) {
+                        fv.error = "收货地址不存在";
+                        return fv;
+                    }
+                } else if (dto.getConsigneeId() != null) {
+                    Consignee consignee = consigneeMapper.selectById(dto.getConsigneeId());
+                    if (consignee == null || !buyerId.equals(consignee.getUserId())) {
+                        fv.error = "收货地址不存在";
+                        return fv;
+                    }
+                } else {
                     fv.error = "请选择收货地址";
                     return fv;
                 }
-                Consignee consignee = consigneeMapper.selectById(dto.getConsigneeId());
-                if (consignee == null || !buyerId.equals(consignee.getUserId())) {
-                    fv.error = "收货地址不存在";
-                    return fv;
-                }
                 break;
             case 2: {
                 // 自提:校验自取地点 + 时间段
@@ -558,8 +609,7 @@ public class ProductOrderService {
         order.setCoverImage(firstItem.getCoverImage() != null ? firstItem.getCoverImage() : firstProduct.getCoverImage());
         order.setDiscountAmount(multiDiscountAmount);
         order.setCouponId(dto.getCouponId());
-        order.setAddressSnapshot(dto.getAddressSnapshot());
-        order.setConsigneeId(dto.getConsigneeId());
+        resolveOrderAddress(order, dto, buyerId);
         if (dto.getPurchaseInfo() != null && !dto.getPurchaseInfo().isEmpty()) {
             order.setPurchaseInfo(JSON.toJSONString(dto.getPurchaseInfo()));
         }

+ 122 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -43,6 +43,18 @@ public class ProductRecommendationService {
     @Resource
     private RecommendationProductAssociationMapper recommendationProductAssociationMapper;
 
+    @Resource
+    private ReferralTreeMapper referralTreeMapper;
+
+    @Resource
+    private SupplySystemMemberMapper supplySystemMemberMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private SupplySystemService supplySystemService;
+
     /**
      * 维度页推荐
      *
@@ -51,11 +63,12 @@ public class ProductRecommendationService {
      * @param memberId         成员ID(可选)
      * @param excludeProductIds 排除的商品ID列表
      * @param limit            返回数量
+     * @param userId           当前登录用户ID(用于供应商优先级计算,可为null)
      * @return 推荐商品列表
      */
     public List<Map<String, Object>> getDimensionRecommendations(
             String dimensionCode, Long familyId, Long memberId,
-            List<Long> excludeProductIds, int limit) {
+            List<Long> excludeProductIds, int limit, Long userId) {
 
         // 1. 获取已购商品ID(90天内)
         Set<Long> purchasedIds = getPurchasedProductIds(familyId, 90);
@@ -183,7 +196,7 @@ public class ProductRecommendationService {
         }
 
         // 5. 最终排序并返回
-        return sortAndReturn(candidates, limit);
+        return sortAndReturn(candidates, limit, userId);
     }
 
     /**
@@ -402,12 +415,115 @@ public class ProductRecommendationService {
     }
 
     /**
-     * 根据 matchScore 和 strength 进行最终排序
+     * 计算供应商优先级加分
+     * <p>
+     * 规则1:若直接引荐人是某产品供应商(supply_system.admin),优先推荐其体系内产品
+     * 规则2:沿引荐链向上追溯,找到任一层级是供应商的引荐人,优先推荐其产品
+     * 规则3:同一供应商体系引荐的用户越多,权重越高(按体系成员数加权)
+     * 补充规则:用户所属多个供应商体系时,其关联产品均优先推荐
+     */
+    private Map<Long, Double> getSupplierPriorityScores(Long userId) {
+        if (userId == null) return Collections.emptyMap();
+
+        // 1. 收集引荐链上的所有祖先(直接推荐人、间接推荐人)
+        List<ReferralTree> ancestorNodes = referralTreeMapper.selectList(
+                new LambdaQueryWrapper<ReferralTree>()
+                        .eq(ReferralTree::getChildId, userId)
+                        .orderByAsc(ReferralTree::getLevel));
+        if (ancestorNodes.isEmpty()) return Collections.emptyMap();
+
+        // 祖先 userId -> 层级(level 越小=关系越近,权重越高)
+        Map<Long, Integer> ancestorLevels = new HashMap<>();
+        for (ReferralTree node : ancestorNodes) {
+            ancestorLevels.putIfAbsent(node.getParentId(), node.getLevel());
+        }
+
+        // 2. 找出引荐链中的供应商(adminId 对应 supply_system)
+        //    supplySystemId -> 引荐层级(取最小即最近层级,权重最高)
+        Map<Long, Integer> supplierSystemToNearestLevel = new HashMap<>();
+        for (Map.Entry<Long, Integer> entry : ancestorLevels.entrySet()) {
+            Long ancestorId = entry.getKey();
+            Integer level = entry.getValue();
+            SupplySystem system = supplySystemService.getByAdminId(ancestorId);
+            if (system != null) {
+                supplierSystemToNearestLevel.merge(system.getId(), level,
+                        (existing, newVal) -> Math.min(existing, newVal));
+            }
+        }
+
+        // 3. 也检查当前用户本身是否属于某些供应商体系(补充规则)
+        List<SupplySystemMember> userSystems = supplySystemMemberMapper.selectList(
+                new LambdaQueryWrapper<SupplySystemMember>()
+                        .eq(SupplySystemMember::getUserId, userId)
+                        .eq(SupplySystemMember::getStatus, "active"));
+        for (SupplySystemMember mem : userSystems) {
+            supplierSystemToNearestLevel.merge(mem.getSystemId(), 0,
+                    (existing, newVal) -> Math.min(existing, newVal));
+        }
+
+        if (supplierSystemToNearestLevel.isEmpty()) return Collections.emptyMap();
+
+        // 4. 计算每个供应商体系的成员数作为权重(规则3)
+        //    supplySystemId -> 加分系数 = memberCount * 0.1 / levelPenalty
+        //    levelPenalty: 直接推荐人(level=1) 系数为1.0,每远一层衰减
+        Map<Long, Double> systemBoosts = new HashMap<>();
+        for (Map.Entry<Long, Integer> sysEntry : supplierSystemToNearestLevel.entrySet()) {
+            Long systemId = sysEntry.getKey();
+            Integer level = sysEntry.getValue();
+            // 成员数
+            Long memberCount = supplySystemMemberMapper.selectCount(
+                    new LambdaQueryWrapper<SupplySystemMember>()
+                            .eq(SupplySystemMember::getSystemId, systemId)
+                            .eq(SupplySystemMember::getStatus, "active"));
+            int count = memberCount == null ? 0 : memberCount.intValue();
+            // 层级衰减:level=1(直接推荐人)不打折,level越大折扣越大
+            double levelFactor = Math.max(0.1, 1.0 - (level - 1) * 0.15);
+            // 加分系数 = 成员数 * 0.1 * 层级因子,基础分50,最大加成5分
+            double boost = Math.min(5.0, count * 0.1 * levelFactor);
+            systemBoosts.put(systemId, boost);
+        }
+
+        // 5. 按 distributionSystemId 聚合加分
+        //    同一个产品的 distributionSystemId 对应多个体系时,取最高加分
+        Map<Long, Double> productIdBoost = new HashMap<>();
+        for (Map.Entry<Long, Double> sysEntry : systemBoosts.entrySet()) {
+            Long systemId = sysEntry.getKey();
+            Double boost = sysEntry.getValue();
+            List<Product> products = productMapper.selectList(
+                    new LambdaQueryWrapper<Product>()
+                            .eq(Product::getDistributionSystemId, systemId)
+                            .eq(Product::getStatus, "on_shelf"));
+            for (Product p : products) {
+                productIdBoost.merge(p.getId(), boost, Math::max);
+            }
+        }
+
+        return productIdBoost;
+    }
+
+    /**
+     * 根据 matchScore 和供应商优先级进行最终排序
      */
-    private List<Map<String, Object>> sortAndReturn(List<Map<String, Object>> candidates, int limit) {
+    private List<Map<String, Object>> sortAndReturn(List<Map<String, Object>> candidates, int limit, Long userId) {
         if (candidates == null || candidates.isEmpty()) return Collections.emptyList();
 
-        // 按 matchScore 降序排序
+        // 计算供应商优先级加分(规则1~3)
+        Map<Long, Double> supplierPriorityScores = getSupplierPriorityScores(userId);
+
+        // 将供应商优先级加入 matchScore
+        if (!supplierPriorityScores.isEmpty()) {
+            for (Map<String, Object> item : candidates) {
+                Long productId = (Long) item.get("productId");
+                Double priorityBoost = productId != null ? supplierPriorityScores.get(productId) : null;
+                Double currentScore = (Double) item.get("matchScore");
+                if (priorityBoost != null && priorityBoost > 0) {
+                    double boostedScore = (currentScore != null ? currentScore : 0) + priorityBoost;
+                    item.put("matchScore", boostedScore);
+                }
+                item.put("supplierPriorityBoost", priorityBoost != null ? priorityBoost : 0);
+            }
+        }
+
         candidates.sort((a, b) -> {
             Double scoreA = (Double) a.get("matchScore");
             Double scoreB = (Double) b.get("matchScore");
@@ -642,7 +758,7 @@ public class ProductRecommendationService {
         for (String dim : uniqueDims) {
             if (result.size() >= limit) break;
             // 获取该维度的关联推荐(含画像缺口、共购、复购)
-            List<Map<String, Object>> prods = getDimensionRecommendations(dim, null, null, null, limit - result.size());
+            List<Map<String, Object>> prods = getDimensionRecommendations(dim, null, null, null, limit - result.size(), null);
             for (Map<String, Object> p : prods) {
                 Long pid = (Long) p.get("id");
                 if (pid == null || seenProductIds.contains(pid)) continue;

+ 16 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java

@@ -95,6 +95,9 @@ public class ReportCollectService {
     @Resource
     private ObjectMapper objectMapper;
 
+    @Resource
+    private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+
     @Value("${report.collect.opencode-base:http://127.0.0.1:4090}")
     private String opencodeBase;
 
@@ -181,12 +184,16 @@ public class ReportCollectService {
                 }
             }
 
+            // 采集指标清单(用于驱动非 DAN 的 LangGraph LLM 解析)
+            String validationTypeId = (!"unknown".equals(typeId)) ? typeId : draft.getReportType();
+            List<String> expectedIndicators = loadExpectedIndicators(validationTypeId);
+
             ParsedReportPayload.Payload payload;
             String method;
             if (!"unknown".equals(typeId) && fp.get("parserPath") != null) {
                 // 指纹成功:脚本已迁移到 LangGraph(/report/parse,算法 + LLM 兜底)
                 method = METHOD_LANGGRAPH_SCRIPT;
-                payload = langGraphReportParseService.parseByScript(pdfPath);
+                payload = langGraphReportParseService.parseByScript(pdfPath, expectedIndicators);
                 // LangGraph 脚本解析为空时回退 opencode,避免已知类型整单失败
                 if (payload == null) {
                     log.warn("已知类型 LangGraph 脚本解析为空,回退 opencode typeId={}", typeId);
@@ -196,7 +203,7 @@ public class ReportCollectService {
             } else {
                 // 无指纹:先用 LangGraph 识别(detect-type → parse-typed / script),识别不了再 opencode
                 method = METHOD_LANGGRAPH_DETECT;
-                payload = langGraphReportParseService.parseWithDetect(pdfPath);
+                payload = langGraphReportParseService.parseWithDetect(pdfPath, expectedIndicators);
                 if (payload == null) {
                     log.warn("无指纹 LangGraph 识别/解析为空,回退 opencode typeId={}", typeId);
                     method = METHOD_OPENCODE;
@@ -211,7 +218,6 @@ public class ReportCollectService {
 
             harmReportPayloadValid(payload);
             // 采集指标完整性校验(report_type_registry.expected_indicators 定义驱动)
-            String validationTypeId = (!"unknown".equals(typeId)) ? typeId : draft.getReportType();
             try {
                 runCollectValidation(draftId, validationTypeId, payload);
             } catch (Exception e) {
@@ -645,6 +651,13 @@ public class ReportCollectService {
             log.warn("保存展示块失败 reportId={}", created.getId(), e);
         }
 
+        // 归一化管道:统一指标层幂等写入(同流程,subjectId 非空才有效)
+        try {
+            normalizeIndicatorPipeline.processReport(created, payload);
+        } catch (Exception e) {
+            log.warn("归一化管道写入失败 reportId={}: {}", created.getId(), e.getMessage());
+        }
+
         healthReportDraftService.markCollectCompleted(draft.getId(), created.getId());
 
         // 新手任务联动:完成首次报告上传任务(老用户无任务记录时静默跳过)

+ 146 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java

@@ -0,0 +1,146 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 真实报告日期提取 + 历史回填。
+ * PDF 解析阶段将「报告日期/采样日期」写入 payload.reportDate 后,
+ * 入库逻辑(HealthReportService 既有 reportDate 修正)自动生效;
+ * 本类只负责:(1) 从 payload 提取真实日期(供回填/校验);
+ * (2) 扫描旧报告幂等回填 report_date(无法提取置 NULL,不伪装成上传时间)。
+ */
+@Service
+public class ReportDateExtractor {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportDateExtractor.class);
+
+    @Resource
+    private HealthReportMapper healthReportMapper;
+
+    @Resource
+    private ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 从 payload JSON 中提取真实报告日期。
+     * 取值顺序:summary.reportDate → 顶层 reportDate → indicators[].collectionDate(取第一个非空)。
+     * 支持 yyyy-MM-dd / yyyy/MM/dd;无法提取返回 null。
+     */
+    public java.sql.Date extractReportDate(String payloadJson) {
+        if (payloadJson == null || payloadJson.isEmpty()) {
+            return null;
+        }
+        try {
+            JsonNode root = objectMapper.readTree(payloadJson);
+            String dateStr = null;
+            JsonNode summary = root.get("summary");
+            if (summary != null && summary.isObject() && summary.hasNonNull("reportDate")) {
+                dateStr = summary.get("reportDate").asText();
+            }
+            if (dateStr == null || dateStr.trim().isEmpty()) {
+                if (root.hasNonNull("reportDate")) {
+                    dateStr = root.get("reportDate").asText();
+                }
+            }
+            java.sql.Date parsed = parseDate(dateStr);
+            if (parsed != null) {
+                return parsed;
+            }
+            // 回退:indicators[].collectionDate 取第一个非空
+            JsonNode indicators = root.get("indicators");
+            if (indicators != null && indicators.isArray()) {
+                for (JsonNode ind : indicators) {
+                    if (ind.hasNonNull("collectionDate")) {
+                        java.sql.Date d = parseDate(ind.get("collectionDate").asText());
+                        if (d != null) {
+                            return d;
+                        }
+                    }
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("ReportDateExtractor 解析 payload 失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private java.sql.Date parseDate(String dateStr) {
+        if (dateStr == null || dateStr.trim().isEmpty()) {
+            return null;
+        }
+        String s = dateStr.trim();
+        try {
+            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+            Date d = sdf.parse(s);
+            return new java.sql.Date(d.getTime());
+        } catch (Exception e) {
+            try {
+                SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
+                Date d = sdf.parse(s);
+                return new java.sql.Date(d.getTime());
+            } catch (Exception e2) {
+                return null;
+            }
+        }
+    }
+
+    /**
+     * 历史回填:扫描 report_date 缺失或疑似上传时间的旧报告(且 payload 含 reportDate 字段),
+     * 提取真实日期并回填;无法提取置 NULL。分批(500/批)、幂等可重跑。
+     *
+     * @return 本轮处理的总行数
+     */
+    public int backfillHistoricalReports() {
+        int total = 0;
+        long lastId = 0L;
+        while (true) {
+            List<HealthReport> batch = healthReportMapper.selectList(
+                    new LambdaQueryWrapper<HealthReport>()
+                            .isNull(HealthReport::getReportDate)
+                            .like(HealthReport::getPayloadJson, "reportDate")
+                            .gt(HealthReport::getId, lastId)
+                            .orderByAsc(HealthReport::getId)
+                            .last("LIMIT 500"));
+            if (batch.isEmpty()) {
+                break;
+            }
+            for (HealthReport report : batch) {
+                try {
+                    java.sql.Date realDate = extractReportDate(report.getPayloadJson());
+                    if (realDate != null) {
+                        report.setReportDate(realDate);
+                        healthReportMapper.updateById(report);
+                        log.info("回填报告真实日期 reportId={}, reportDate={}", report.getId(), realDate);
+                    }
+                    // 无法提取:保持 report_date 为 NULL(不伪装成上传时间);
+                    // 由 keyset(id) 游标跳过,本轮不再反复处理
+                    total++;
+                } catch (Exception e) {
+                    log.warn("回填报告日期失败 reportId={}: {}", report.getId(), e.getMessage());
+                }
+            }
+            lastId = batch.get(batch.size() - 1).getId();
+        }
+        log.info("ReportDateExtractor 历史回填完成,共处理 {} 行", total);
+        return total;
+    }
+
+    /** 每日 03:10 自动回填(幂等可重跑,连跑两次结果一致) */
+    @Scheduled(cron = "0 10 3 * * ?")
+    public void scheduledBackfill() {
+        backfillHistoricalReports();
+    }
+}

+ 52 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java

@@ -11,6 +11,7 @@ import com.etotem.cfc.entity.DanReportUpload;
 import com.etotem.cfc.entity.FamilyFortuneReport;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.HealthReportDraft;
 import com.etotem.cfc.entity.InsurancePolicy;
 import com.etotem.cfc.entity.ReportSummary;
 import com.etotem.cfc.entity.User;
@@ -19,6 +20,7 @@ import com.etotem.cfc.mapper.DanReportUploadMapper;
 import com.etotem.cfc.mapper.FamilyFortuneReportMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.HealthReportMapper;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
 import com.etotem.cfc.mapper.InsurancePolicyMapper;
 import com.etotem.cfc.mapper.ReportSummaryMapper;
 import com.etotem.cfc.mapper.UserMapper;
@@ -28,8 +30,14 @@ import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 
 @Service
@@ -60,6 +68,9 @@ public class ReportSummaryService {
     @Resource
     private FamilyFortuneReportMapper familyFortuneReportMapper;
 
+    @Resource
+    private HealthReportDraftMapper healthReportDraftMapper;
+
     @Resource
     private JdbcTemplate jdbcTemplate;
 
@@ -303,6 +314,8 @@ public class ReportSummaryService {
         vo.setStatus(s.getStatus());
         vo.setDetailRoute(s.getDetailRoute());
         vo.setCreatedAt(s.getCreatedAt());
+        vo.setCollectValidation(loadCollectValidationBySource(
+                Collections.singletonList(s), s.getSourceTable()).get(s.getSourceId()));
         return vo;
     }
 
@@ -338,6 +351,9 @@ public class ReportSummaryService {
 
         SortUtil.applySort(wrapper);
 
+        Map<Long, String> danValidation = loadCollectValidationBySource(page.getRecords(), "dan_report_uploads");
+        Map<Long, String> healthValidation = loadCollectValidationBySource(page.getRecords(), "health_reports");
+
         Page<ReportSummaryVO> voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
         List<ReportSummaryVO> voList = new ArrayList<>();
         for (ReportSummary rs : page.getRecords()) {
@@ -353,12 +369,48 @@ public class ReportSummaryService {
             vo.setReportDate(rs.getReportDate() != null ? rs.getReportDate().toString() : "");
             vo.setStatus(rs.getStatus());
             vo.setCreatedAt(rs.getCreatedAt());
+            if ("dan_report_uploads".equals(rs.getSourceTable())) {
+                vo.setCollectValidation(danValidation.get(rs.getSourceId()));
+            } else if ("health_reports".equals(rs.getSourceTable())) {
+                vo.setCollectValidation(healthValidation.get(rs.getSourceId()));
+            }
             voList.add(vo);
         }
         voPage.setRecords(voList);
         return voPage;
     }
 
+    /** 批量读取草稿的采集校验结果,键 = confirmed_report_id(= report_summary.source_id) */
+    private Map<Long, String> loadCollectValidationBySource(List<ReportSummary> rows, String sourceTable) {
+        Map<Long, String> map = new HashMap<>();
+        if (rows == null || rows.isEmpty()) {
+            return map;
+        }
+        Set<Long> ids = rows.stream()
+                .filter(r -> sourceTable.equals(r.getSourceTable()))
+                .map(ReportSummary::getSourceId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        if (ids.isEmpty()) {
+            return map;
+        }
+        LambdaQueryWrapper<HealthReportDraft> w = new LambdaQueryWrapper<>();
+        w.in(HealthReportDraft::getConfirmedReportId, ids)
+                .isNotNull(HealthReportDraft::getCollectValidation);
+        if ("dan_report_uploads".equals(sourceTable)) {
+            w.likeRight(HealthReportDraft::getReportType, "dan");
+        } else {
+            w.and(x -> x.isNull(HealthReportDraft::getReportType)
+                    .or().notLikeRight(HealthReportDraft::getReportType, "dan"));
+        }
+        for (HealthReportDraft d : healthReportDraftMapper.selectList(w)) {
+            if (d.getConfirmedReportId() != null && d.getCollectValidation() != null) {
+                map.put(d.getConfirmedReportId(), d.getCollectValidation());
+            }
+        }
+        return map;
+    }
+
     public ReportSyncResultDTO fullSync() {
         ReportSyncResultDTO result = new ReportSyncResultDTO();
         result.setNewCount(0);

+ 95 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SsoService.java

@@ -0,0 +1,95 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.etotem.cfc.entity.SsoTicket;
+import com.etotem.cfc.mapper.SsoTicketMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.security.SecureRandom;
+import java.util.Date;
+
+/**
+ * 跨小程序单点登录一次性票据服务(cfc ↔ train 双向免登)。
+ * cfc 与 train 共用本库(zxyj),ticket 由签发方写入,目标方原子占用(used 0→1)。
+ */
+@Slf4j
+@Service
+public class SsoService {
+
+    /** 系统标识:cfc */
+    public static final String SYS_CFC = "cfc";
+    /** 系统标识:train */
+    public static final String SYS_TRAIN = "train";
+
+    private static final SecureRandom RANDOM = new SecureRandom();
+
+    @Resource
+    private SsoTicketMapper ssoTicketMapper;
+
+    /** 票据有效期(毫秒),默认 5 分钟 */
+    @Value("${sso.ticket-ttl-ms:300000}")
+    private long ticketTtlMs;
+
+    /**
+     * 签发一次性票据。
+     *
+     * @param sourceSystem 签发方系统标识(cfc / train)
+     * @param targetSystem 目标系统标识(cfc / train)
+     * @param sourceUserId 签发方用户ID(cfc=users.id / train=train_user.id)
+     * @param cfcUserId    cfc 统一会员ID(users.id),跨系统换票依据
+     * @return 票据字符串(64 位十六进制)
+     */
+    public String issueTicket(String sourceSystem, String targetSystem, Long sourceUserId, Long cfcUserId) {
+        SsoTicket t = new SsoTicket();
+        t.setTicket(generateTicket());
+        t.setSourceSystem(sourceSystem);
+        t.setTargetSystem(targetSystem);
+        t.setSourceUserId(sourceUserId);
+        t.setCfcUserId(cfcUserId);
+        t.setUsed(0);
+        t.setExpireAt(new Date(System.currentTimeMillis() + ticketTtlMs));
+        t.setCreatedAt(new Date());
+        ssoTicketMapper.insert(t);
+        return t.getTicket();
+    }
+
+    /**
+     * 原子消费票据:仅当「票据未使用 + 未过期 + 目标系统匹配」时置 used=1。
+     * 并发/重放场景下若多请求同时命中,只有 update 影响行数=1 的那一个成功。
+     *
+     * @return 成功返回 cfc_user_id;无效/已用/过期/目标不符返回 null
+     */
+    public Long consumeTicket(String ticket, String targetSystem) {
+        if (ticket == null || ticket.trim().isEmpty()) {
+            return null;
+        }
+        int updated = ssoTicketMapper.update(null, new LambdaUpdateWrapper<SsoTicket>()
+                .eq(SsoTicket::getTicket, ticket)
+                .eq(SsoTicket::getTargetSystem, targetSystem)
+                .eq(SsoTicket::getUsed, 0)
+                .gt(SsoTicket::getExpireAt, new Date())
+                .set(SsoTicket::getUsed, 1));
+        if (updated != 1) {
+            log.warn("SSO 票据消费失败(无效/已用/过期/目标不符): target={}", targetSystem);
+            return null;
+        }
+        SsoTicket t = ssoTicketMapper.selectOne(new LambdaQueryWrapper<SsoTicket>()
+                .eq(SsoTicket::getTicket, ticket));
+        return t != null ? t.getCfcUserId() : null;
+    }
+
+    private String generateTicket() {
+        byte[] bytes = new byte[32];
+        RANDOM.nextBytes(bytes);
+        StringBuilder sb = new StringBuilder(64);
+        for (byte b : bytes) {
+            sb.append(Character.forDigit((b >> 4) & 0xF, 16));
+            sb.append(Character.forDigit(b & 0xF, 16));
+        }
+        return sb.toString();
+    }
+}

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

@@ -655,6 +655,45 @@ private FamilyInvitationService familyInvitationService;
         return result;
     }
 
+    /**
+     * 跨小程序单点登录(SSO):凭一次性票据换取的 cfc_user_id 直接建立登录态。
+     *
+     * <p>身份可信度来自票据本身:短时效(默认 5 分钟)+ 一次性(消费即置 used=1)+ 签发方鉴权
+     * (签发时必须携带有效 cfc 登录态)。票据只在本服务与 train 共享的库中传递,不依赖 wx.login。
+     *
+     * @param cfcUserId cfc 统一会员ID(users.id)
+     */
+    public LoginResultDTO ssoLogin(Long cfcUserId) {
+        if (cfcUserId == null) {
+            throw new RuntimeException("SSO 票据无效");
+        }
+        User user = userMapper.selectById(cfcUserId);
+        if (user == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        // 管理员不能登录小程序
+        if ("admin".equals(user.getRole())) {
+            throw new RuntimeException("该账号不允许登录小程序");
+        }
+        ensureUserHasFamily(user);
+
+        String token = jwtConfig.generateToken(user.getId(), user.getRole());
+        LoginResultDTO result = new LoginResultDTO();
+        result.setToken(token);
+        result.setUserId(user.getId());
+        result.setRole(user.getRole());
+        result.setRoles(user.getRoles());
+        result.setFamilyId(user.getFamilyId());
+        result.setNickname(user.getNickname());
+        result.setPhone(user.getPhone());
+        result.setIsNewUser(false);
+        result.setOpenid(user.getOpenid());
+        result.setNeedsFamily(user.getFamilyId() == null || user.getFamilyId() == 0L);
+        populateTeacherFields(result, user);
+        populateActivityProviderFields(result, user);
+        return result;
+    }
+
     /**
      * 获取用户所有角色列表
      */

+ 87 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java

@@ -0,0 +1,87 @@
+package com.etotem.cfc.util;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 秩相关计算(纯函数,无外部依赖)。
+ * 菌群丰度偏态分布,Spearman 比 Pearson 稳健;Java 8 无内置实现,此处手工实现,约 40 行。
+ */
+public final class SpearmanUtil {
+
+    private SpearmanUtil() {
+    }
+
+    /** 计算 Spearman 秩相关系数;配对点 < 3 返回 0(样本不足不误导)。 */
+    public static double spearmanRho(List<BigDecimal> x, List<BigDecimal> y) {
+        if (x == null || y == null || x.size() != y.size() || x.size() < 3) {
+            return 0.0;
+        }
+        double[] rankX = rank(x);
+        double[] rankY = rank(y);
+        double sumD2 = 0;
+        int n = x.size();
+        for (int i = 0; i < n; i++) {
+            double d = rankX[i] - rankY[i];
+            sumD2 += d * d;
+        }
+        double denom = (double) n * (n * n - 1);
+        if (denom == 0) {
+            return 0.0;
+        }
+        double rho = 1 - (6 * sumD2) / denom;
+        if (rho > 1) rho = 1;
+        if (rho < -1) rho = -1;
+        return rho;
+    }
+
+    /** 平均秩;并列值取平均(tie handling)。 */
+    private static double[] rank(List<BigDecimal> values) {
+        int n = values.size();
+        double[] ranks = new double[n];
+        // 按值排序的索引
+        List<Integer> idx = new ArrayList<>();
+        for (int i = 0; i < n; i++) idx.add(i);
+        idx.sort(Comparator.comparing(values::get));
+        Map<Integer, Double> rankMap = new HashMap<>();
+        int i = 0;
+        while (i < n) {
+            int j = i;
+            while (j + 1 < n && values.get(idx.get(j + 1)).compareTo(values.get(idx.get(i))) == 0) {
+                j++;
+            }
+            // [i, j] 为并列区间,平均秩 = (i+1 + j+1) / 2
+            double avgRank = ((i + 1) + (j + 1)) / 2.0;
+            for (int k = i; k <= j; k++) {
+                rankMap.put(idx.get(k), avgRank);
+            }
+            i = j + 1;
+        }
+        for (int k = 0; k < n; k++) {
+            ranks[k] = rankMap.get(k);
+        }
+        return ranks;
+    }
+
+    /**
+     * Fisher z 聚合:各对象 rho → z = artanh(rho) → 均值 → rho = tanh(z̄)。
+     * 设计文档 4.6:每对象算 rho → z 变换 → 全对象 z 均值 → 还原为平均 rho。
+     */
+    public static double fisherZAverage(double[] rhos) {
+        if (rhos == null || rhos.length == 0) {
+            return 0.0;
+        }
+        double sumZ = 0;
+        for (double r : rhos) {
+            double v = Math.max(-0.999999, Math.min(0.999999, r));
+            sumZ += 0.5 * Math.log((1 + v) / (1 - v));
+        }
+        double zAvg = sumZ / rhos.length;
+        return Math.tanh(zAvg);
+    }
+}

+ 9 - 1
cfc-backend/src/main/resources/application.yml

@@ -149,9 +149,17 @@ report:
     opencode-agent: ${OPENCODE_AGENT:build}
 
 math:
-  verify-mode: dify  # dify | eval; dify=走Dify工作流, eval=服务端计算(兜底)
+  verify-mode: dify  # dify | eval; eval=服务端计算(兜底)
   workflow-id: ""    # Dify 数学判题工作流 ID(留空则使用 eval 兜底)
 
+aikanshe:
+  appkey: ${AIKANSHE_APPKEY:}
+  base-url: ${AIKANSHE_BASE_URL:https://api.aikanshe.com}
+  token: ${AIKANSHE_TOKEN:}
+  desease: ${AIKANSHE_DISEASE:}
+  confidence: ${AIKANSHE_CONFIDENCE:0.2}
+  lang: ${AIKANSHE_LANG:zh-CN}
+
 upload:
   base-dir: /data/cfc-uploads
   allowed-types: image/jpeg,image/png,image/gif,image/webp,application/pdf

+ 33 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/DatabaseInitializerSeedTest.java

@@ -0,0 +1,33 @@
+package com.etotem.cfc.config;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class DatabaseInitializerSeedTest {
+
+    @Mock
+    private JdbcTemplate jdbcTemplate;
+
+    @InjectMocks
+    private DatabaseInitializer initializer;
+
+    @Test
+    void seedExpectedIndicator_onlyWritesWhenAbsent() {
+        initializer.seedExpectedIndicator("dan_a1", "[\"总分\"]");
+
+        ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class);
+        verify(jdbcTemplate).update(sql.capture(), eq("[\"总分\"]"), eq("dan_a1"));
+        assertTrue(sql.getValue().contains("(expected_indicators IS NULL OR expected_indicators = '')"),
+                "SQL 必须仅在列为空时写入,避免覆盖管理员编辑");
+    }
+}

+ 68 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/ReportParserAdminControllerTest.java

@@ -0,0 +1,68 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ReportTypeRegistry;
+import com.etotem.cfc.mapper.ReportTypeRegistryMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+@ExtendWith(MockitoExtension.class)
+class ReportParserAdminControllerTest {
+
+    @Mock
+    private ReportTypeRegistryMapper typeRegistryMapper;
+
+    @InjectMocks
+    private ReportParserAdminController controller;
+
+    private ReportTypeRegistry baseType() {
+        ReportTypeRegistry t = new ReportTypeRegistry();
+        t.setId(1L);
+        t.setTypeId("dan_a1");
+        t.setDisplayName("素质测评A1");
+        return t;
+    }
+
+    @Test
+    void saveType_rejectsInvalidJsonArray() {
+        ReportTypeRegistry t = baseType();
+        t.setExpectedIndicators("not-json");
+
+        Result<Void> r = controller.saveType(t);
+
+        assertEquals(500, r.getCode());
+        assertTrue(r.getMessage().contains("expectedIndicators"));
+        verifyNoInteractions(typeRegistryMapper);
+    }
+
+    @Test
+    void saveType_rejectsJsonArrayWithNonStringElement() {
+        ReportTypeRegistry t = baseType();
+        t.setExpectedIndicators("[1,2,3]");
+
+        Result<Void> r = controller.saveType(t);
+
+        assertEquals(500, r.getCode());
+        verify(typeRegistryMapper, never()).updateById(t);
+    }
+
+    @Test
+    void saveType_acceptsValidJsonArray() {
+        ReportTypeRegistry t = baseType();
+        t.setExpectedIndicators("[\"总分\",\"百分位\"]");
+
+        Result<Void> r = controller.saveType(t);
+
+        assertEquals(200, r.getCode());
+        verify(typeRegistryMapper).updateById(t);
+    }
+}

+ 57 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.java

@@ -0,0 +1,57 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorCorrelation;
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.mapper.IndicatorCorrelationMapper;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class IndicatorCorrelationServiceTest {
+
+    private IndicatorCorrelationService service;
+    private IndicatorCorrelationMapper correlationMapper;
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorCorrelationService();
+        correlationMapper = mock(IndicatorCorrelationMapper.class);
+        definitionMapper = mock(IndicatorDefinitionMapper.class);
+        ReflectionTestUtils.setField(service, "correlationMapper", correlationMapper);
+        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+    }
+
+    @Test
+    void saveOrdersByCodeLexicographic() {
+        // 定义 A code="bacteria.bb" 应存 a;定义 B code="bacteria.aa" 应存 a
+        IndicatorDefinition defAA = new IndicatorDefinition();
+        defAA.setId(1L);
+        defAA.setCode("bacteria.aa");
+        IndicatorDefinition defBB = new IndicatorDefinition();
+        defBB.setId(2L);
+        defBB.setCode("bacteria.bb");
+        when(definitionMapper.selectById(1L)).thenReturn(defAA);
+        when(definitionMapper.selectById(2L)).thenReturn(defBB);
+
+        service.saveCorrelation(1L, 2L, "positive", new BigDecimal("0.72"),
+                "描述", false, "来源", 9L);
+
+        org.mockito.ArgumentCaptor<IndicatorCorrelation> captor =
+                org.mockito.ArgumentCaptor.forClass(IndicatorCorrelation.class);
+        verify(correlationMapper).insert(captor.capture());
+        IndicatorCorrelation saved = captor.getValue();
+        // 1L 的 code("bacteria.aa") 字典序更小 → 应存 definition_id_a
+        assertEquals(1L, saved.getDefinitionIdA());
+        assertEquals(2L, saved.getDefinitionIdB());
+        // effective_strength 初始 = manual(未校准)
+        assertEquals(0, new BigDecimal("0.72").compareTo(saved.getEffectiveStrength()));
+    }
+}

+ 93 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java

@@ -0,0 +1,93 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorMappingRule;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorMappingRuleMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+class IndicatorMappingServiceTest {
+
+    private IndicatorMappingService service;
+    private IndicatorMappingRuleMapper ruleMapper;
+    private IndicatorDefinitionMapper definitionMapper;
+    private AiGateway aiGateway;
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorMappingService();
+        ruleMapper = Mockito.mock(IndicatorMappingRuleMapper.class);
+        definitionMapper = Mockito.mock(IndicatorDefinitionMapper.class);
+        aiGateway = Mockito.mock(AiGateway.class);
+        ReflectionTestUtils.setField(service, "ruleMapper", ruleMapper);
+        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+        ReflectionTestUtils.setField(service, "aiGateway", aiGateway);
+    }
+
+    @Test
+    void resolveExactHitReturnsDefinitionId() {
+        IndicatorMappingRule rule = new IndicatorMappingRule();
+        rule.setDefinitionId(7L);
+        when(ruleMapper.selectOne(any())).thenReturn(rule);
+        Long defId = service.resolve("gut_flora", "双歧杆菌属");
+        assertEquals(7L, defId);
+        verify(ruleMapper).selectOne(any());
+    }
+
+    @Test
+    void resolveMissLazilyRegistersDefinitionAndRule() {
+        when(ruleMapper.selectOne(any())).thenReturn(null);
+        IndicatorDefinition created = new IndicatorDefinition();
+        created.setId(99L);
+        when(definitionMapper.insert(any(IndicatorDefinition.class))).thenAnswer(inv -> {
+            IndicatorDefinition d = inv.getArgument(0);
+            d.setId(99L);
+            return 1;
+        });
+        Long defId = service.resolve("gut_flora", "新出现的菌X");
+        assertEquals(99L, defId);
+        // 惰性注册:定义 + auto_created 规则
+        verify(definitionMapper).insert(any(IndicatorDefinition.class));
+        verify(ruleMapper).insert(any(IndicatorMappingRule.class));
+    }
+
+    @Test
+    void resolveFuzzyHitPicksBestCandidate() {
+        // exact 未命中 → fuzzy 候选命中(别名/近似名)
+        when(ruleMapper.selectOne(any())).thenReturn(null);
+        IndicatorMappingRule candidate = new IndicatorMappingRule();
+        candidate.setDefinitionId(3L);
+        candidate.setRawName("双歧杆菌");  // 与 "双歧杆菌属" 高相似
+        when(ruleMapper.selectList(any())).thenReturn(Collections.singletonList(candidate));
+
+        Long defId = service.resolve("gut_flora", "双歧杆菌属");
+        assertEquals(3L, defId);
+    }
+
+    @Test
+    void resolveFuzzyMissFallsBackToAiSuggestion() {
+        when(ruleMapper.selectOne(any())).thenReturn(null);
+        when(ruleMapper.selectList(any())).thenReturn(Collections.emptyList());
+        // AI 建议命中
+        when(aiGateway.classifyIndicator(eq("gut_flora"), eq("神秘指标"), anyList()))
+                .thenReturn("{\"code\":\"indicator.x.y\",\"name\":\"神秘指标\"}");
+        IndicatorDefinition suggested = new IndicatorDefinition();
+        suggested.setId(41L);
+        when(definitionMapper.selectOne(any())).thenReturn(suggested);
+
+        Long defId = service.resolve("gut_flora", "神秘指标");
+        assertEquals(41L, defId);
+        // AI 建议登记为 pending(status=0)规则
+        verify(ruleMapper).insert(argThat(r -> r.getMatchType().equals("ai_fallback") && r.getStatus() == 0));
+    }
+}

+ 100 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java

@@ -0,0 +1,100 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorDefinition;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorDefinitionMapper;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class IndicatorTrendServiceTest {
+
+    private IndicatorTrendService service;
+    private IndicatorValueMapper valueMapper;
+    private IndicatorDefinitionMapper definitionMapper;
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorTrendService();
+        valueMapper = mock(IndicatorValueMapper.class);
+        ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
+        // 计划测试漏加:实现均调用 definitionMapper.selectById,必须补 mock
+        definitionMapper = mock(IndicatorDefinitionMapper.class);
+        ReflectionTestUtils.setField(service, "definitionMapper", definitionMapper);
+        IndicatorDefinition def = new IndicatorDefinition();
+        def.setName("双歧杆菌属");
+        def.setUnit("%");
+        def.setCategory("肠道菌群");
+        def.setGoodDirection("higher_is_better");
+        when(definitionMapper.selectById(any())).thenReturn(def);
+    }
+
+    private IndicatorValue value(Long defId, String date, String val) {
+        IndicatorValue v = new IndicatorValue();
+        v.setDefinitionId(defId);
+        v.setReportDate(Date.valueOf(date));
+        v.setValue(val);
+        v.setNumericValue(new BigDecimal(val));
+        return v;
+    }
+
+    @Test
+    void trendReturnsAscendingPoints() {
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-08-15", "10"));
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.trend(3L, Arrays.asList(1L), 2L);
+        List<?> items = (List<?>) data.get("items");
+        assertEquals(1, items.size());
+        Map<?, ?> item = (Map<?, ?>) items.get(0);
+        assertEquals("1", String.valueOf(item.get("definitionId")));
+        List<?> points = (List<?>) item.get("points");
+        assertEquals(2, points.size());
+        Map<?, ?> first = (Map<?, ?>) points.get(0);
+        assertEquals("2026-08-15", first.get("reportDate"));
+    }
+
+    @Test
+    void changeRankingAdjacentComputesDeltaAndTrendLabel() {
+        // 双歧杆菌属 higher_is_better:升幅 → trendLabel=改善
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-08-15", "10"));
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        java.util.Map<String, Object> data = service.changeRanking(3L, null, null, "adjacent", "magnitude", 50, 2L);
+        List<?> items = (List<?>) data.get("items");
+        assertEquals(1, items.size());
+        Map<?, ?> item = (Map<?, ?>) items.get(0);
+        assertEquals("up", item.get("movement"));
+        assertEquals("改善", item.get("trendLabel"));
+        Map<?, ?> overall = (Map<?, ?>) data.get("overall");
+        assertEquals(1, ((Number) overall.get("improvedCount")).intValue());
+    }
+
+    @Test
+    void quickLookupReturnsItemsAndDimensions() {
+        List<IndicatorValue> rows = new ArrayList<>();
+        rows.add(value(1L, "2026-10-01", "12.8"));
+        when(valueMapper.selectList(any())).thenReturn(rows);
+
+        Map<String, Object> data = service.quickLookup(3L, null, 1, 30, 2L);
+        assertTrue(data.containsKey("items"));
+        assertTrue(data.containsKey("categories"));
+        assertEquals(1, ((List<?>) data.get("items")).size());
+    }
+}

+ 30 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java

@@ -0,0 +1,30 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.math.BigDecimal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/** IndicatorValueMapper.upsertMapping 仅做 SQL 注解绑定验证(mock 层) */
+class IndicatorValueMapperTest {
+
+    @Test
+    void upsertMappingAnnotationCompiles() {
+        IndicatorValueMapper mapper = Mockito.mock(IndicatorValueMapper.class);
+        IndicatorValue v = new IndicatorValue();
+        v.setSourceType("gut_flora");
+        v.setSourceId(10L);
+        v.setDefinitionId(3L);
+        v.setValue("12.8");
+        v.setNumericValue(new BigDecimal("12.8"));
+        when(mapper.upsertMapping(any(IndicatorValue.class))).thenReturn(1);
+        int result = mapper.upsertMapping(v);
+        assertEquals(1, result);
+    }
+}

+ 86 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.ParsedReportPayload;
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+class NormalizeIndicatorPipelineTest {
+
+    private NormalizeIndicatorPipeline pipeline;
+    private IndicatorMappingService mappingService;
+    private IndicatorValueMapper valueMapper;
+
+    @BeforeEach
+    void setUp() {
+        pipeline = new NormalizeIndicatorPipeline();
+        mappingService = Mockito.mock(IndicatorMappingService.class);
+        valueMapper = Mockito.mock(IndicatorValueMapper.class);
+        ReflectionTestUtils.setField(pipeline, "mappingService", mappingService);
+        ReflectionTestUtils.setField(pipeline, "valueMapper", valueMapper);
+    }
+
+    @Test
+    void processReportWritesIndicatorsAndFlora() {
+        HealthReport report = new HealthReport();
+        report.setId(10L);
+        report.setFamilyId(2L);
+        report.setSubjectId(3L);
+        report.setReportType("gut_flora");
+        report.setReportDate(new Date());
+        when(mappingService.resolve(eq("gut_flora"), eq("双歧杆菌属"))).thenReturn(1L);
+
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+        ParsedReportPayload.Indicator ind = new ParsedReportPayload.Indicator();
+        ind.setIndicatorName("双歧杆菌属");
+        ind.setIndicatorValue("12.8");
+        ind.setUnit("%");
+        ind.setStatus("偏高");
+        payload.setIndicators(Collections.singletonList(ind));
+
+        pipeline.processReport(report, payload);
+        verify(valueMapper).upsertMapping(any(IndicatorValue.class));
+    }
+
+    @Test
+    void processReportSkipsWhenNoSubject() {
+        HealthReport report = new HealthReport();
+        report.setId(10L);
+        report.setReportType("gut_flora");
+        report.setSubjectId(null);
+        ParsedReportPayload.Payload payload = new ParsedReportPayload.Payload();
+        payload.setIndicators(Collections.emptyList());
+
+        pipeline.processReport(report, payload);
+        verify(valueMapper, never()).upsertMapping(any());
+    }
+
+    @Test
+    void snapshotFoodIndicesWritesFoodValues() {
+        com.etotem.cfc.entity.FoodRecommendIndex idx = new com.etotem.cfc.entity.FoodRecommendIndex();
+        idx.setId(5L);
+        idx.setUserId(3L);
+        idx.setFoodId(9L);
+        idx.setFoodName("燕麦");
+        idx.setIndexScore(88);
+        idx.setHealthReportId(10L);
+        idx.setCalculatedAt(new Date());
+        when(mappingService.resolve(eq("food_recommend"), eq("燕麦"))).thenReturn(6L);
+
+        pipeline.snapshotFoodIndices(3L, java.util.Collections.singletonList(idx));
+        verify(valueMapper).upsertMapping(argThat(v -> v.getSourceType().equals("food_recommend")
+                && v.getSourceId().equals(5L)
+                && v.getDefinitionId().equals(6L)));
+    }
+}

+ 92 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java

@@ -0,0 +1,92 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.HealthReport;
+import com.etotem.cfc.mapper.HealthReportMapper;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ReportDateExtractorTest {
+
+    private ReportDateExtractor newExtractor() {
+        ReportDateExtractor e = new ReportDateExtractor();
+        // 不注入 mapper,仅测纯提取逻辑
+        return e;
+    }
+
+    @Test
+    void extractFromNestedSummary() throws Exception {
+        ReportDateExtractor e = newExtractor();
+        String json = "{\"summary\":{\"reportDate\":\"2026-08-15\",\"personName\":\"小李\"},\"indicators\":[]}";
+        java.sql.Date d = e.extractReportDate(json);
+        assertNotNull(d);
+        assertEquals("2026-08-15", new SimpleDateFormat("yyyy-MM-dd").format(d));
+    }
+
+    @Test
+    void extractFromFlatPayload() throws Exception {
+        ReportDateExtractor e = newExtractor();
+        String json = "{\"reportDate\":\"2026/08/15\"}";
+        java.sql.Date d = e.extractReportDate(json);
+        assertNotNull(d);
+        assertEquals("2026-08-15", new SimpleDateFormat("yyyy-MM-dd").format(d));
+    }
+
+    @Test
+    void extractReturnsNullWhenMissing() {
+        ReportDateExtractor e = newExtractor();
+        assertNull(e.extractReportDate("{\"summary\":{\"personName\":\"x\"}}"));
+        assertNull(e.extractReportDate(null));
+        assertNull(e.extractReportDate("not json"));
+    }
+
+    @Test
+    void extractFallsBackToIndicatorCollectionDate() throws Exception {
+        ReportDateExtractor e = newExtractor();
+        String json = "{\"summary\":{},\"indicators\":[{\"collectionDate\":\"2026-09-01\"}]}";
+        java.sql.Date d = e.extractReportDate(json);
+        assertNotNull(d);
+        assertEquals("2026-09-01", new SimpleDateFormat("yyyy-MM-dd").format(d));
+    }
+
+    @Test
+    void backfillFillsExtractableDateAndTerminates() {
+        ReportDateExtractor e = new ReportDateExtractor();
+        HealthReportMapper mapper = Mockito.mock(HealthReportMapper.class);
+        ReflectionTestUtils.setField(e, "healthReportMapper", mapper);
+        HealthReport r = new HealthReport();
+        r.setId(1L);
+        r.setPayloadJson("{\"reportDate\":\"2026-08-15\"}");
+        Mockito.when(mapper.selectList(Mockito.any()))
+                .thenReturn(Arrays.asList(r), Collections.emptyList());
+        int n = e.backfillHistoricalReports();
+        assertEquals(1, n);
+        Mockito.verify(mapper, Mockito.times(1))
+                .updateById(Mockito.any(HealthReport.class));
+        assertEquals("2026-08-15",
+                new SimpleDateFormat("yyyy-MM-dd").format(r.getReportDate()));
+    }
+
+    @Test
+    void backfillSkipsUnextractableWithoutWrite() {
+        ReportDateExtractor e = new ReportDateExtractor();
+        HealthReportMapper mapper = Mockito.mock(HealthReportMapper.class);
+        ReflectionTestUtils.setField(e, "healthReportMapper", mapper);
+        HealthReport r = new HealthReport();
+        r.setId(7L);
+        r.setPayloadJson("{\"reportDate\":\"\"}"); // 含字段但不可提取
+        Mockito.when(mapper.selectList(Mockito.any()))
+                .thenReturn(Arrays.asList(r), Collections.emptyList());
+        int n = e.backfillHistoricalReports();
+        assertEquals(1, n);
+        Mockito.verify(mapper, Mockito.never())
+                .updateById(Mockito.any(HealthReport.class));
+        assertNull(r.getReportDate());
+    }
+}

+ 67 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ReportSummaryServiceCompletenessTest.java

@@ -0,0 +1,67 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.ReportListQueryDTO;
+import com.etotem.cfc.dto.ReportSummaryVO;
+import com.etotem.cfc.entity.HealthReportDraft;
+import com.etotem.cfc.entity.ReportSummary;
+import com.etotem.cfc.mapper.HealthReportDraftMapper;
+import com.etotem.cfc.mapper.ReportSummaryMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ReportSummaryServiceCompletenessTest {
+
+    @Mock
+    private ReportSummaryMapper reportSummaryMapper;
+
+    @Mock
+    private HealthReportDraftMapper healthReportDraftMapper;
+
+    @InjectMocks
+    private ReportSummaryService service;
+
+    @Test
+    void adminPageList_fillsCollectValidationFromDraft() {
+        ReportSummary rs = new ReportSummary();
+        rs.setId(1L);
+        rs.setReportType("dan");
+        rs.setSourceTable("dan_report_uploads");
+        rs.setSourceId(1L);
+
+        doAnswer(inv -> {
+            Page<ReportSummary> p = inv.getArgument(0);
+            p.setRecords(Collections.singletonList(rs));
+            p.setTotal(1);
+            return p;
+        }).when(reportSummaryMapper).selectPage(any(), any());
+
+        HealthReportDraft d = new HealthReportDraft();
+        d.setConfirmedReportId(1L);
+        d.setReportType("dan_a1");
+        d.setCollectValidation("{\"typeId\":\"dan_a1\",\"expected\":31,\"matched\":30,\"pass\":false,\"missing\":[\"百分位\"]}");
+        when(healthReportDraftMapper.selectList(any())).thenReturn(Collections.singletonList(d));
+
+        ReportListQueryDTO query = new ReportListQueryDTO();
+        query.setPage(1);
+        query.setSize(20);
+
+        Page<ReportSummaryVO> page = service.adminPageList(query);
+
+        List<ReportSummaryVO> records = page.getRecords();
+        assertEquals(1, records.size());
+        assertEquals(d.getCollectValidation(), records.get(0).getCollectValidation());
+    }
+}

+ 60 - 0
cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java

@@ -0,0 +1,60 @@
+package com.etotem.cfc.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class SpearmanUtilTest {
+
+    @Test
+    void perfectPositiveRankCorrelation() {
+        // x 与 y 完全同序 → rho = 1
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
+        double rho = SpearmanUtil.spearmanRho(x, y);
+        assertEquals(1.0, rho, 1e-9);
+    }
+
+    @Test
+    void perfectNegativeRankCorrelation() {
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"), new BigDecimal("3"), new BigDecimal("4"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("4"), new BigDecimal("3"), new BigDecimal("2"), new BigDecimal("1"));
+        double rho = SpearmanUtil.spearmanRho(x, y);
+        assertEquals(-1.0, rho, 1e-9);
+    }
+
+    @Test
+    void tiedRanksHandled() {
+        // 有并列值时使用平均秩;x 与 y 逐项同值(含并列) → rho = 1
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("1"), new BigDecimal("2"));
+        double rho = SpearmanUtil.spearmanRho(x, y);
+        assertEquals(1.0, rho, 1e-9);
+    }
+
+    @Test
+    void shortSeriesReturnsZero() {
+        // 少于 3 个配对点不计算(对应设计:单对象 ≥3 配对才参与)
+        List<BigDecimal> x = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
+        List<BigDecimal> y = Arrays.asList(new BigDecimal("1"), new BigDecimal("2"));
+        assertEquals(0.0, SpearmanUtil.spearmanRho(x, y), 1e-9);
+    }
+
+    @Test
+    void fisherZAverageRestoresPositiveRho() {
+        // 两个 rho=0.6 的对象 z 均值还原 → 仍约 0.6
+        double avg = SpearmanUtil.fisherZAverage(new double[]{0.6, 0.6});
+        assertEquals(0.6, avg, 1e-6);
+    }
+
+    @Test
+    void fisherZAverageHandlesExtremeRho() {
+        // rho 接近 ±1 时 z 变换仍有限(这里验证不抛异常且符号正确)
+        double avg = SpearmanUtil.fisherZAverage(new double[]{0.99, 0.95});
+        assertTrue(avg > 0.9);
+    }
+}

+ 30 - 2
cfc-frontend/App.vue

@@ -1,7 +1,7 @@
 <script>
 import config from '@/config.js'
 import Vue from 'vue'
-import { checkUserFamily, verifyToken, bindReferralIfNotSelf, silentRelogin } from './utils/api.js'
+import { checkUserFamily, verifyToken, bindReferralIfNotSelf, silentRelogin, ssoLogin } from './utils/api.js'
 Vue.prototype.$config = config
 
 export default {
@@ -19,7 +19,7 @@ export default {
     // inviteCode=推荐码(文章/活动/商品分享)| refCode=推广码 | token/familyInviteCode=家庭邀请令牌 | id=内容详情
     var hasDeepLink = options && options.query && (
       options.query.inviteCode || options.query.refCode || options.query.token ||
-      options.query.familyInviteCode || options.query.id
+      options.query.familyInviteCode || options.query.id || options.query.ssoTicket
     )
     // 冷启动:跳转到 splash 页展示过渡动画,2秒后自动进入首页
     if (options.scene !== 1047 && options.scene !== 1048 && options.scene !== 1049 && !hasDeepLink) {
@@ -27,6 +27,8 @@ export default {
       uni.redirectTo({ url: '/pages/splash/index' })
       return
     }
+    // 跨小程序单点登录:携带 ssoTicket 进入 → 换取本地登录态
+    this.handleSsoLogin(options)
     // 处理小程序码场景值(从 wxacode 扫码进入,跳过 splash)
     this.handleScene(options)
   },
@@ -34,6 +36,8 @@ export default {
     console.log('App Show', options)
     // 热启动(含从分享卡重新进入):同样捕获分享参数
     this.captureReferralParams(options)
+    // 跨小程序单点登录:热启动携带 ssoTicket 进入同样处理
+    this.handleSsoLogin(options)
     // 每次 app 展示时也检查场景值(小程序码可能从冷启动后的 onShow 传入)
     this.handleScene(options)
     
@@ -167,6 +171,30 @@ bindReferralIfNotSelf(code).then(function(res) {
       // 同步 familyId 到 store(hasFamily getter 依赖;静默续期路径)
       this.$store.commit('setFamilyId', data.familyId)
     },
+    // 跨小程序单点登录:从 train 跳转进入时携带 ssoTicket → 换取本地登录态。
+    // 一次性票据(后端原子消费),用 _ssoHandledTicket 去重,避免 onLaunch/onShow 双处理。
+    handleSsoLogin(options) {
+      if (!options || !options.query) return
+      var ticket = options.query.ssoTicket
+      if (!ticket) return
+      if (this._ssoHandledTicket === ticket) return
+      this._ssoHandledTicket = ticket
+      var self = this
+      uni.showLoading({ title: '登录中...' })
+      ssoLogin(ticket).then(function(res) {
+        uni.hideLoading()
+        if (res && res.code === 200 && res.data) {
+          self.saveLoginInfo(res.data)
+          // 清空路由票据(防转发泄漏)并落地首页;index/index 会按角色重新分流
+          uni.reLaunch({ url: '/pages/index/index' })
+        } else {
+          uni.showToast({ title: (res && res.message) || '单点登录失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        uni.hideLoading()
+        uni.showToast({ title: (e && e.message) || '单点登录失败', icon: 'none' })
+      })
+    },
     // 处理小程序码场景值
     handleScene(options) {
       if (!options || !options.query) return

+ 75 - 0
cfc-frontend/__tests__/api/api.indicator.test.js

@@ -0,0 +1,75 @@
+/**
+ * 指标 API 封装契约测试
+ * 验证 URL / method / body 正确,且是规范入口(单 URL 单封装)
+ */
+import * as api from '@/utils/api'
+import { getIndicatorTrend, getIndicatorChangeRanking, getIndicatorQuickLookup, getIndicatorCorrelationList } from '@/utils/api'
+
+// --------------- helpers ---------------
+function mockSuccess(data) {
+  global.uni.request.mockImplementation((opts) => {
+    process.nextTick(() => {
+      if (opts.success) opts.success({ data })
+    })
+  })
+}
+
+function lastRequest() {
+  const calls = global.uni.request.mock.calls
+  return calls[calls.length - 1][0]
+}
+
+// --------------- global beforeEach ---------------
+describe('indicator API 封装', () => {
+  beforeEach(() => {
+    global.uni._storage = {}
+    global.uni.request.mockReset()
+    global.uni.showToast.mockReset()
+    global.uni.reLaunch.mockReset()
+    global.uni.login.mockReset()
+    global.getCurrentPages.mockReset()
+    global.getCurrentPages.mockReturnValue([{ route: 'pages/index-home/index' }])
+    global.uni.getAccountInfoSync.mockReset()
+    global.uni.getAccountInfoSync.mockReturnValue({ miniProgram: { envVersion: 'develop' } })
+    api.clearApiDedup()
+  })
+
+  test('getIndicatorTrend → POST /api/indicator/trend with subjectId+definitionIds', async () => {
+    mockSuccess({ code: 200, data: {} })
+    await getIndicatorTrend({ subjectId: 5, definitionIds: [1, 2] })
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/indicator\/trend$/)
+    expect(opts.method).toBe('POST')
+    expect(opts.data.subjectId).toBe(5)
+    expect(opts.data.definitionIds).toEqual([1, 2])
+  })
+
+  test('getIndicatorChangeRanking → POST /api/indicator/change-ranking with scope+orderBy', async () => {
+    mockSuccess({ code: 200, data: {} })
+    await getIndicatorChangeRanking({ subjectId: 5, scope: 'adjacent', orderBy: 'magnitude', limit: 50 })
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/indicator\/change-ranking$/)
+    expect(opts.method).toBe('POST')
+    expect(opts.data.scope).toBe('adjacent')
+    expect(opts.data.orderBy).toBe('magnitude')
+  })
+
+  test('getIndicatorQuickLookup → POST /api/indicator/quick-lookup with page', async () => {
+    mockSuccess({ code: 200, data: {} })
+    await getIndicatorQuickLookup({ subjectId: 5, category: '核心菌属', page: 1, pageSize: 30 })
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/indicator\/quick-lookup$/)
+    expect(opts.method).toBe('POST')
+    expect(opts.data.page).toBe(1)
+    expect(opts.data.pageSize).toBe(30)
+  })
+
+  test('getIndicatorCorrelationList → POST /api/indicator/correlation/list with subjectId+definitionId', async () => {
+    mockSuccess({ code: 200, data: {} })
+    await getIndicatorCorrelationList({ subjectId: 5, definitionId: 3 })
+    const opts = lastRequest()
+    expect(opts.url).toMatch(/\/api\/indicator\/correlation\/list$/)
+    expect(opts.method).toBe('POST')
+    expect(opts.data.definitionId).toBe(3)
+  })
+})

+ 64 - 0
cfc-frontend/__tests__/utils/indicator-change.test.js

@@ -0,0 +1,64 @@
+/**
+ * 指标变化规则纯函数单测
+ * 规则来源:design.md §8 变化颜色与箭头规则表
+ */
+import * as ic from '@/utils/indicator-change'
+
+describe('indicator-change 变化规则', () => {
+  describe('resolveChangeStyle(goodDirection, movement)', () => {
+    test('higher_is_better + up → 改善/绿 ↑', () => {
+      const s = ic.resolveChangeStyle('higher_is_better', 'up')
+      expect(s.trendLabel).toBe('改善')
+      expect(s.cssClass).toBe('impr-up')
+      expect(s.arrow).toBe('↑')
+    })
+    test('higher_is_better + down → 恶化/红 ↓', () => {
+      const s = ic.resolveChangeStyle('higher_is_better', 'down')
+      expect(s.trendLabel).toBe('恶化')
+      expect(s.cssClass).toBe('worst-down')
+      expect(s.arrow).toBe('↓')
+    })
+    test('lower_is_better + up → 恶化/红 ↑ (如炎症水平上升)', () => {
+      const s = ic.resolveChangeStyle('lower_is_better', 'up')
+      expect(s.trendLabel).toBe('恶化')
+      expect(s.cssClass).toBe('worst-up')
+      expect(s.arrow).toBe('↑')
+    })
+    test('lower_is_better + down → 改善/绿 ↓ (如炎症水平下降)', () => {
+      const s = ic.resolveChangeStyle('lower_is_better', 'down')
+      expect(s.trendLabel).toBe('改善')
+      expect(s.cssClass).toBe('impr-down')
+      expect(s.arrow).toBe('↓')
+    })
+    test('neutral + up/down → 中性/灰', () => {
+      const up = ic.resolveChangeStyle('neutral', 'up')
+      const down = ic.resolveChangeStyle('neutral', 'down')
+      expect(up.trendLabel).toBe('中性')
+      expect(up.cssClass).toBe('neutral-up')
+      expect(down.cssClass).toBe('neutral-down')
+    })
+    test('flat → 无变化/灰 →', () => {
+      const s = ic.resolveChangeStyle('higher_is_better', 'flat')
+      expect(s.trendLabel).toBe('中性')
+      expect(s.cssClass).toBe('flat')
+      expect(s.arrow).toBe('→')
+    })
+    test('未知 goodDirection 兜底为中性', () => {
+      const s = ic.resolveChangeStyle('unknown_direction', 'up')
+      expect(s.cssClass).toBe('neutral-up')
+    })
+  })
+
+  describe('formatDelta(delta, deltaPct)', () => {
+    test('正 delta 显示 + 并带百分比', () => {
+      expect(ic.formatDelta(2, 100)).toBe('+2 (+100%)')
+    })
+    test('负 delta 显示 -', () => {
+      expect(ic.formatDelta(-1.5, -30)).toBe('-1.5 (-30%)')
+    })
+    test('null 安全', () => {
+      expect(ic.formatDelta(null, null)).toBe('--')
+      expect(ic.formatDelta(0, 0)).toBe('0 (0%)')
+    })
+  })
+})

+ 193 - 21
cfc-frontend/components/ContentSharePoster.vue

@@ -2,6 +2,50 @@
   <view class="poster-overlay" v-if="show" @click.self="$emit('close')">
     <view class="poster-container">
       <canvas canvas-id="contentPosterCanvas" class="poster-canvas" id="contentPosterCanvas"></canvas>
+      <!-- 模板选择 -->
+      <view class="poster-tpl-row">
+        <view class="tpl-label">海报模板</view>
+        <view class="tpl-options">
+          <view
+            class="tpl-option"
+            :class="{ 'tpl-active': template === 'gradient' }"
+            @click="switchTemplate('gradient')"
+          >
+            <view class="tpl-dot tpl-dot-grad"></view>
+            <text class="tpl-text">渐变</text>
+          </view>
+          <view
+            class="tpl-option"
+            :class="{ 'tpl-active': template === 'minimal' }"
+            @click="switchTemplate('minimal')"
+          >
+            <view class="tpl-dot tpl-dot-dark"></view>
+            <text class="tpl-text">深色</text>
+          </view>
+          <view
+            class="tpl-option"
+            :class="{ 'tpl-active': template === 'card' }"
+            @click="switchTemplate('card')"
+          >
+            <view class="tpl-dot tpl-dot-light"></view>
+            <text class="tpl-text">卡片</text>
+          </view>
+        </view>
+      </view>
+      <!-- 背景颜色选择(渐变模板可用) -->
+      <view class="poster-bg-row" v-if="template === 'gradient'">
+        <view class="tpl-label">背景色</view>
+        <view class="bg-options">
+          <view
+            v-for="(c, i) in bgColors"
+            :key="i"
+            class="bg-option"
+            :class="{ 'bg-active': bgColor === c }"
+            :style="{ background: c }"
+            @click="switchBgColor(c)"
+          ></view>
+        </view>
+      </view>
       <view class="poster-actions">
         <button class="poster-btn save" @click="savePoster">保存到相册</button>
       </view>
@@ -23,6 +67,12 @@ export default {
     priceText: { type: String, default: '' },
     ctaText: { type: String, default: '长按识别二维码 · 查看详情' },
     inviteText: { type: String, default: '' },
+    // 海报模板: gradient(default) | minimal | card
+    template: { type: String, default: 'gradient' },
+    // 自定义背景色(覆盖模板默认背景)
+    bgColor: { type: String, default: '' },
+    // 二维码尺寸覆盖(默认150rpx,建议活动类可用 100 缩小)
+    qrSizeOverride: { type: Number, default: 150 },
     // 五维能量数据卡:{ label?: String, overall?: Number, dims: [{ key, label, score, color }] }
     // 传 null 时不画数据卡,按 coverImage -> brandFallback -> 占位图 顺序降级(原有行为不变)
     energyData: { type: Object, default: null },
@@ -38,6 +88,12 @@ export default {
       }
     }
   },
+  data() {
+    return {
+      // 可选的背景色(渐变模板下展示)
+      bgColors: ['#FF8C42', '#6366F1', '#F59E0B', '#10B981', '#FF6B9D', '#1E293B', '#FFFFFF']
+    }
+  },
   methods: {
     getColors() {
       var map = {
@@ -49,6 +105,35 @@ export default {
       }
       return map[this.dimensionCode] || { main: '#F97316', dark: '#EA580C', light: '#FFF7ED' }
     },
+    // 海报模板配色
+    getTemplateConfig() {
+      var colors = this.getColors()
+      var template = this.template || 'gradient'
+      var bgColor = this.bgColor || ''
+      // 梯度背景模板
+      if (template === 'gradient') {
+        return { template: 'gradient', bgColor: bgColor || colors.main, main: colors.main, dark: colors.dark, light: colors.light }
+      }
+      // 极简模板:深色背景+白色卡片
+      if (template === 'minimal') {
+        var dark = bgColor || '#1E293B'
+        return { template: 'minimal', bgColor: dark, main: '#F97316', dark: '#EF4444', light: '#F3F4F6' }
+      }
+      // 卡片模板:浅色背景+彩色顶栏
+      return { template: 'card', bgColor: bgColor || '#FFFFFF', main: colors.main, dark: colors.dark, light: colors.light }
+    },
+    // 获取当前模板的背景样式
+    getBgStyle() {
+      var cfg = this.getTemplateConfig()
+      if (cfg.template === 'gradient') {
+        var dark = cfg.dark
+        return 'linear-gradient(135deg, ' + cfg.main + ', ' + dark + ')'
+      }
+      if (cfg.template === 'minimal') {
+        return 'linear-gradient(135deg, ' + cfg.bgColor + ', ' + cfg.dark + ')'
+      }
+      return 'linear-gradient(135deg, ' + cfg.main + ', ' + (cfg.bgColor || '#FFFFFF') + ')'
+    },
     drawPoster() {
       var self = this
       uni.createSelectorQuery().in(this)
@@ -69,7 +154,7 @@ export default {
       var ctx = uni.createCanvasContext('contentPosterCanvas', this)
       var w = 500
       var h = 800
-      var colors = this.getColors()
+      var cfg = this.getTemplateConfig()
 
       // 画布 CSS 用 rpx,实际像素随设备变化(375px 屏约 310x496),而绘制按 500x800 设计坐标;
       // 不缩放坐标系时右侧/底部内容(含二维码 y≈552-702)会被裁剪出画布,导致海报显示不全。
@@ -77,20 +162,33 @@ export default {
         ctx.scale(scaleX, scaleY)
       }
 
-      var gradient = ctx.createLinearGradient(0, 0, w * 0.3, h)
-      gradient.addColorStop(0, colors.main)
-      gradient.addColorStop(0.55, colors.dark)
-      gradient.addColorStop(1, colors.dark)
-      ctx.setFillStyle(gradient)
-      ctx.fillRect(0, 0, w, h)
+      // 【模板1 · gradient】渐变背景
+      if (cfg.template === 'gradient') {
+        var gradient = ctx.createLinearGradient(0, 0, w * 0.3, h)
+        gradient.addColorStop(0, cfg.main)
+        gradient.addColorStop(0.55, cfg.dark)
+        gradient.addColorStop(1, cfg.dark)
+        ctx.setFillStyle(gradient)
+        ctx.fillRect(0, 0, w, h)
+      }
+      // 【模板2 · minimal】纯色深色背景
+      else if (cfg.template === 'minimal') {
+        ctx.setFillStyle(cfg.bgColor)
+        ctx.fillRect(0, 0, w, h)
+      }
+      // 【模板3 · card】白色背景
+      else {
+        ctx.setFillStyle('#FFFFFF')
+        ctx.fillRect(0, 0, w, h)
+      }
 
-      this._drawTopDecoration(ctx, w, colors)
+      this._drawTopDecoration(ctx, w, cfg)
 
       var cardX = 28
       var cardY = 112
       var cardW = 444
       var cardH = 600
-      this._drawContentCard(ctx, cardX, cardY, cardW, cardH, colors)
+      this._drawContentCard(ctx, cardX, cardY, cardW, cardH, cfg)
 
       var coverY = cardY + 20
       var coverW = cardW - 40
@@ -99,8 +197,8 @@ export default {
 
       if (this.energyData) {
         // 方案C:五维能量数据卡,canvas 直接绘制(文字锐利、数据真实、无网络开销)
-        this._drawEnergyCard(ctx, cardX + 20, coverY, coverW, coverH, colors)
-        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+        this._drawEnergyCard(ctx, cardX + 20, coverY, coverW, coverH, cfg)
+        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
       } else if (this.coverImage) {
         uni.downloadFile({
           url: this.coverImage,
@@ -112,29 +210,29 @@ export default {
                 success: function(info) {
                   var rect = self._fitCoverRect(info.width, info.height, cardX + 20, coverY, coverW, coverH)
                   ctx.drawImage(res.tempFilePath, rect.x, rect.y, rect.w, rect.h)
-                  self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+                  self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
                 },
                 fail: function() {
                   ctx.drawImage(res.tempFilePath, cardX + 20, coverY, coverW, coverH)
-                  self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+                  self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
                 }
               })
             } else {
               self._drawCoverPlaceholder(ctx, cardX + 20, coverY, coverW, coverH)
-              self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+              self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
             }
           },
           fail: function() {
             self._drawCoverPlaceholder(ctx, cardX + 20, coverY, coverW, coverH)
-            self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+            self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
           }
         })
       } else if (this.brandFallback) {
-        this._drawBrandCard(ctx, cardX + 20, coverY, coverW, coverH, colors)
-        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+        this._drawBrandCard(ctx, cardX + 20, coverY, coverW, coverH, cfg)
+        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
       } else {
         this._drawCoverPlaceholder(ctx, cardX + 20, coverY, coverW, coverH)
-        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, cfg)
       }
     },
 
@@ -370,8 +468,8 @@ export default {
       ctx.setTextBaseline('top')
       ctx.fillText(this.inviteText || '长按识别二维码 · 查看详情', w / 2, inviteY)
 
-      // 二维码
-      var qrSize = 150
+      // 二维码 - 可配置尺寸(默认150,可通过 qrSizeOverride 调整)
+      var qrSize = this.qrSizeOverride || 150
       var qrX = (w - qrSize) / 2
       var qrY = inviteY + 24
 
@@ -526,6 +624,21 @@ export default {
       ctx.closePath()
     },
 
+    switchTemplate(tpl) {
+      this.$emit('update:template', tpl)
+      var self = this
+      this.$nextTick(function() {
+        setTimeout(self.drawPoster.bind(self), 100)
+      })
+    },
+    switchBgColor(color) {
+      this.$emit('update:bgColor', color)
+      var self = this
+      this.$nextTick(function() {
+        setTimeout(self.drawPoster.bind(self), 100)
+      })
+    },
+
     savePoster() {
       var self = this
       uni.canvasToTempFilePath({
@@ -610,4 +723,63 @@ export default {
   font-weight: 600;
 }
 .poster-btn.save:active { opacity: 0.85; }
-</style>
+.poster-tpl-row {
+  display: flex;
+  align-items: center;
+  margin: 12rpx 0;
+  font-size: 24rpx;
+}
+.tpl-label {
+  margin-right: 20rpx;
+  color: #666;
+}
+.tpl-options {
+  display: flex;
+}
+.tpl-option {
+  flex: 1;
+  margin: 0 4rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  cursor: pointer;
+}
+.tpl-dot {
+  width: 24rpx;
+  height: 24rpx;
+  border-radius: 999rpx;
+  margin-bottom: 4rpx;
+}
+.tpl-dot-grad { background: linear-gradient(135deg, #FF8C42, #EA580C); }
+.tpl-dot-dark { background: #1E293B; }
+.tpl-dot-light { background: #FFFFFF; }
+.tpl-text {
+  font-size: 14rpx;
+  text-align: center;
+  color: #666;
+}
+.tpl-active .tpl-text { color: #F97316; }
+
+.poster-bg-row {
+  display: flex;
+  align-items: center;
+  margin: 12rpx 0;
+  font-size: 24rpx;
+}
+.bg-label {
+  margin-right: 20rpx;
+  color: #666;
+}
+.bg-options {
+  display: flex;
+}
+.bg-option {
+  width: 32rpx;
+  height: 32rpx;
+  border-radius: 999rpx;
+  margin: 0 4rpx;
+  cursor: pointer;
+}
+.bg-active {
+  border: 2rpx solid #F97316;
+}</style>

+ 302 - 0
cfc-frontend/components/TrendChart.vue

@@ -0,0 +1,302 @@
+<template>
+  <view class="trend-chart-wrap">
+    <view class="trend-container" :style="containerStyle">
+      <canvas :id="canvasId" type="2d" class="trend-canvas" :style="canvasStyle"></canvas>
+      <view class="trend-tap-layer" @click="onCanvasTap"></view>
+    </view>
+    <view class="trend-legend" v-if="series.length > 1">
+      <view class="legend-item" v-for="(s, i) in series" :key="getItemKey(s, i)">
+        <view class="legend-line" :style="'border-color:' + s.color"></view>
+        <text class="legend-text">{{ s.name }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+/**
+ * TrendChart - 多序列折线图 Canvas 组件
+ * 用法:<TrendChart :series="[{name,color,points:[{reportDate,value,unit}]}]" height="360" />
+ * 参照 RadarChart.vue 的 type=2d + DPR + 重试初始化模式。
+ */
+export default {
+  props: {
+    /** 序列数组 [{ name, color, points:[{reportDate,value,unit}] }] */
+    series: { type: Array, default: function() { return [] } },
+    width: { type: Number, default: 690 },
+    height: { type: Number, default: 420 },
+    /** Y 轴自动范围下界(默认 min-5%) */
+    yMin: { type: Number, default: 0 },
+    /** 是否绘制数据点数值标注 */
+    showValues: { type: Boolean, default: true }
+  },
+  data: function() {
+    return {
+      canvasReady: false, canvasWidth: 0, canvasHeight: 0, ctx: null, dpr: 1,
+      _canvasNode: null, _canvasLeft: 0, _canvasTop: 0,
+      _destroyed: false, _initTimer: null,
+      canvasId: 'trendCanvas_' + (this._uid || 0)
+    }
+  },
+  computed: {
+    containerStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' },
+    canvasStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' }
+  },
+  watch: {
+    series: { handler: function() { if (this.canvasReady) this._doRender() }, deep: true }
+  },
+  mounted: function() {
+    var self = this
+    var retryCount = 0
+    var maxRetry = 5
+    function tryInit() {
+      if (self._destroyed) return
+      self.initCanvas(function(success) {
+        if (self._destroyed) return
+        if (!success && retryCount < maxRetry) {
+          retryCount++
+          self._initTimer = setTimeout(tryInit, 60)
+        } else if (!success) {
+          console.log('[TrendChart] 初始化失败,重试 ' + maxRetry + ' 次后放弃', self.canvasId)
+        }
+      })
+    }
+    self._initTimer = setTimeout(tryInit, 100)
+  },
+  beforeDestroy: function() {
+    this._destroyed = true
+    if (this._initTimer) { clearTimeout(this._initTimer); this._initTimer = null }
+  },
+  methods: {
+    getItemKey: function(s, i) { return (s && s.name) || i },
+
+    initCanvas: function(cb) {
+      var self = this
+      uni.createSelectorQuery().in(this)
+        .select('#' + this.canvasId)
+        .fields({ node: true, size: true, rect: true })
+        .exec(function(res) {
+          try {
+            if (self._destroyed) return
+            if (!res || !res[0] || !res[0].node) { if (cb) cb(false); return }
+            var info = res[0]
+            var canvas = info.node
+            var width = info.width
+            var height = info.height
+            if (!width || !height) {
+              var winWidth = 375
+              try { winWidth = uni.getWindowInfo().windowWidth || 375 } catch (e) {}
+              width = Math.round((self.width || 690) * winWidth / 750)
+              height = Math.round((self.height || 420) * winWidth / 750)
+            }
+            var pixelRatio = 2
+            try { pixelRatio = uni.getWindowInfo().pixelRatio || 2 } catch (e) {}
+            canvas.width = width * pixelRatio
+            canvas.height = height * pixelRatio
+            var ctx = canvas.getContext('2d')
+            if (!ctx) { if (cb) cb(false); return }
+            ctx.scale(pixelRatio, pixelRatio)
+            self.ctx = ctx
+            self._canvasNode = canvas
+            self.canvasWidth = width
+            self.canvasHeight = height
+            self.dpr = pixelRatio
+            self._canvasLeft = info.left || 0
+            self._canvasTop = info.top || 0
+            self.canvasReady = true
+            self._doRender()
+            if (cb) cb(true)
+          } catch (e) {
+            console.log('[TrendChart] initCanvas 回调异常', e)
+            if (cb) cb(false)
+          }
+        })
+    },
+
+    _safeSeries: function(arr) {
+      if (!Array.isArray(arr)) return []
+      return arr.filter(function(s) { return s && Array.isArray(s.points) && s.points.length > 0 })
+    },
+
+    /** 计算 X 轴时间范围与 Y 轴数值范围 */
+    _computeRanges: function(seriesList) {
+      var min = Infinity, max = -Infinity, firstDate = null, lastDate = null
+      seriesList.forEach(function(s) {
+        s.points.forEach(function(p) {
+          var v = parseFloat(p.value)
+          if (!isNaN(v)) { if (v < min) min = v; if (v > max) max = v }
+          if (!firstDate || p.reportDate < firstDate) firstDate = p.reportDate
+          if (!lastDate || p.reportDate > lastDate) lastDate = p.reportDate
+        })
+      })
+      if (min === Infinity) { min = 0; max = 1 }
+      if (max === min) { max = min + 1 }
+      var pad = (max - min) * 0.08
+      return { yMin: Math.max(0, min - pad), yMax: max + pad, firstDate: firstDate, lastDate: lastDate }
+    },
+
+    _doRender: function() {
+      if (!this.ctx) return
+      var self = this
+      var ctx = this.ctx
+      var w = this.canvasWidth, h = this.canvasHeight
+      var padL = 34, padR = 14, padT = 16, padB = 26
+      var plotW = w - padL - padR, plotH = h - padT - padB
+      var seriesList = this._safeSeries(this.series)
+      if (seriesList.length === 0) {
+        ctx.clearRect(0, 0, w, h)
+        ctx.fillStyle = '#94A3B8'
+        ctx.font = '12px sans-serif'
+        ctx.textAlign = 'center'
+        ctx.fillText('暂无趋势数据', w / 2, h / 2)
+        return
+      }
+      var ranges = this._computeRanges(seriesList)
+      var yMin = ranges.yMin, yMax = ranges.yMax
+      function yOf(v) { return padT + plotH * (1 - (v - yMin) / (yMax - yMin)) }
+      // 日期横轴:按所有序列全部点排序后均分
+      var allDates = []
+      seriesList.forEach(function(s) {
+        s.points.forEach(function(p) {
+          if (allDates.indexOf(p.reportDate) < 0) allDates.push(p.reportDate)
+        })
+      })
+      allDates.sort()
+      function xOf(dateStr) {
+        if (allDates.length <= 1) return padL + plotW / 2
+        var idx = allDates.indexOf(dateStr)
+        if (idx < 0) return padL
+        return padL + plotW * (allDates.length === 1 ? 0.5 : idx / (allDates.length - 1))
+      }
+
+      ctx.clearRect(0, 0, w, h)
+      // 网格(4 条横线 + 2 条纵线)
+      ctx.strokeStyle = '#EEF2F7'
+      ctx.lineWidth = 1
+      for (var gi = 0; gi <= 3; gi++) {
+        var gy = padT + plotH * (gi / 3)
+        ctx.beginPath()
+        ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy)
+        ctx.stroke()
+      }
+      // Y 轴刻度文字
+      ctx.fillStyle = '#94A3B8'
+      ctx.font = '10px sans-serif'
+      ctx.textAlign = 'right'
+      for (var ti = 0; ti <= 3; ti++) {
+        var tv = yMin + (yMax - yMin) * (1 - ti / 3)
+        var ty = padT + plotH * (ti / 3)
+        ctx.fillText(self._fmtNum(tv), padL - 4, ty + 3)
+      }
+      // 折线 + 数据点 + 数值
+      var palette = ['#F97316', '#0EA5E9', '#10B981', '#8B5CF6', '#EF4444', '#F59E0B']
+      seriesList.forEach(function(s, si) {
+        var color = s.color || palette[si % palette.length]
+        ctx.strokeStyle = color
+        ctx.lineWidth = 2
+        ctx.beginPath()
+        var started = false
+        s.points.forEach(function(p) {
+          var v = parseFloat(p.value)
+          if (isNaN(v)) return
+          var x = xOf(p.reportDate), y = yOf(v)
+          if (!started) { ctx.moveTo(x, y); started = true } else { ctx.lineTo(x, y) }
+        })
+        ctx.stroke()
+        // 数据点 + 值
+        s.points.forEach(function(p) {
+          var v = parseFloat(p.value)
+          if (isNaN(v)) return
+          var x = xOf(p.reportDate), y = yOf(v)
+          ctx.fillStyle = color
+          ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill()
+          if (self.showValues) {
+            ctx.fillStyle = '#475569'
+            ctx.font = '10px sans-serif'
+            ctx.textAlign = 'center'
+            ctx.fillText(self._fmtValue(p), x, y - 8)
+          }
+        })
+        // 序列名(最右端上方)
+        ctx.fillStyle = color
+        ctx.font = '11px sans-serif'
+        ctx.textAlign = 'right'
+        var last = s.points[s.points.length - 1]
+        if (last && !isNaN(parseFloat(last.value))) {
+          ctx.fillText(s.name || '', xOf(last.reportDate), yOf(parseFloat(last.value)) - 14)
+        }
+      })
+      // X 轴日期标签(首/中/末)
+      ctx.fillStyle = '#94A3B8'
+      ctx.font = '10px sans-serif'
+      ctx.textAlign = 'center'
+      if (allDates.length > 0) {
+        ctx.fillText(String(allDates[0]).slice(5), padL, h - 8)
+        if (allDates.length > 1) {
+          var midIdx = Math.floor((allDates.length - 1) / 2)
+          ctx.fillText(String(allDates[midIdx]).slice(5), padL + plotW * (midIdx / (allDates.length - 1)), h - 8)
+          ctx.fillText(String(allDates[allDates.length - 1]).slice(5), w - padR, h - 8)
+        }
+      }
+    },
+
+    _fmtNum: function(v) {
+      if (v >= 100) return Math.round(v) + ''
+      if (v >= 10) return v.toFixed(1)
+      return v.toFixed(2)
+    },
+    _fmtValue: function(p) {
+      var v = parseFloat(p.value)
+      if (isNaN(v)) return '--'
+      return this._fmtNum(v) + (p.unit ? p.unit : '')
+    },
+
+    onCanvasTap: function(e) {
+      // 一期无点击交互需求,预留:点击最近数据点可 emit 'pointTap'
+      this.$emit('chartTap')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.trend-chart-wrap {
+  display: flex;
+  flex-direction: column;
+}
+.trend-container {
+  position: relative;
+}
+.trend-canvas {
+  display: block;
+}
+.trend-tap-layer {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+}
+.trend-legend {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: center;
+  padding-top: 12rpx;
+}
+.legend-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin: 0 16rpx 8rpx;
+}
+.legend-line {
+  width: 32rpx;
+  border-top: 4rpx solid #0EA5E9;
+  margin-right: 8rpx;
+}
+.legend-text {
+  font-size: 22rpx;
+  color: #475569;
+}
+</style>

+ 12 - 0
cfc-frontend/pages.json

@@ -1005,6 +1005,18 @@
             "navigationBarTitleText": "菌群报告详情"
           }
         },
+        {
+          "path": "indicator-curve",
+          "style": {
+            "navigationBarTitleText": "指标变化"
+          }
+        },
+        {
+          "path": "indicator-quicklook",
+          "style": {
+            "navigationBarTitleText": "指标速查"
+          }
+        },
         {
           "path": "gut-flora-species-detail",
           "style": {

+ 20 - 1
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -261,7 +261,12 @@
       :summary="posterSummary || ''"
       :priceText="posterPriceText || ''"
       :inviteText="posterInviteText || ''"
+      :template="posterTemplate"
+      :bgColor="posterBgColor"
+      :qrSizeOverride="100"
       @close="closePoster"
+      @update:template="posterTemplate = $event"
+      @update:bgColor="posterBgColor = $event"
     />
   </view>
 </template>
@@ -310,7 +315,9 @@ export default {
       posterQrCode: '',
       posterSummary: '',
       posterPriceText: '',
-      posterInviteText: ''
+      posterInviteText: '',
+      posterTemplate: 'gradient',
+      posterBgColor: ''   // 封面背景色
     }
   },
   onLoad: function(options) {
@@ -688,6 +695,7 @@ export default {
           this.posterSummary = this.buildPosterSummary()
           this.posterPriceText = this.activity.price > 0 ? '¥' + this.activity.price : '免费'
           this.posterInviteText = '「' + (uni.getStorageSync('nickname') || '好友') + '」邀请你一起参加这个活动'
+          this.posterBgColor = this.buildPosterBgColor()
           this.showPoster = true
         }
       } catch (e) {
@@ -701,6 +709,17 @@ export default {
       desc = desc.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
       return desc.length > 60 ? desc.substring(0, 60) + '...' : desc
     },
+    buildPosterBgColor: function() {
+      var dim = this.activity && this.activity.dimensionCode
+      var map = {
+        body: '#FF8C42',
+        mind: '#FF6B9D',
+        wisdom: '#6366F1',
+        action: '#10B981',
+        wealth: '#F59E0B'
+      }
+      return map[dim] || '#F97316'
+    },
     closePoster: function() {
       this.showPoster = false
     },

+ 571 - 0
cfc-frontend/pages/family/self-check-ai.vue

@@ -0,0 +1,571 @@
+<template>
+  <view class="sc-page">
+    <!-- 顶部说明 -->
+    <view class="sc-hero">
+      <view class="sc-hero-badge">AI 智能出题</view>
+      <text class="sc-hero-title">五维深度自检</text>
+      <text class="sc-hero-sub">20题 · 约8分钟 · 基于您的画像个性化生成</text>
+    </view>
+
+    <!-- 加载状态 -->
+    <view v-if="loading" class="sc-loading">
+      <view class="sc-loading-spinner"></view>
+      <text class="sc-loading-text">AI 正在为您生成题目...</text>
+      <text class="sc-loading-hint">根据您的需求,每道题都是情境化设计</text>
+    </view>
+
+    <template v-else-if="questions.length > 0">
+      <!-- 维度导航 -->
+      <view class="sc-dim-nav">
+        <view v-for="dim in dimensions" :key="dim.code" class="sc-dim-nav-item">
+          <view class="sc-dim-nav-dot" :style="{ background: dim.color }">
+            <text class="sc-dim-nav-char">{{ dim.name }}</text>
+          </view>
+          <view class="sc-dim-nav-dots">
+            <view
+              v-for="q in dim.questions"
+              :key="q.id"
+              class="sc-dim-nav-dot-mini"
+              :class="{ done: answers[q.id] }"
+              :style="answers[q.id] ? 'background:' + dim.color : 'background:transparent'"
+            ></view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 进度条 -->
+      <view class="sc-progress-row">
+        <text class="sc-progress-text">已完成 {{ answeredCount }}/{{ questions.length }} 题</text>
+        <text class="sc-progress-pct">{{ progress }}%</text>
+      </view>
+      <view class="sc-progress-bar">
+        <view class="sc-progress-fill" :style="{ width: progress + '%' }"></view>
+      </view>
+
+      <!-- 生成 rationale -->
+      <view class="sc-rationale" v-if="rationale">
+        <text class="sc-rationale-icon">💡</text>
+        <text class="sc-rationale-text">{{ rationale }}</text>
+      </view>
+
+      <!-- 题目分组(按维度) -->
+      <view v-for="dim in dimensions" :key="dim.code" class="sc-section">
+        <view class="sc-section-header" :style="{ borderLeftColor: dim.color }">
+          <text class="sc-section-name">{{ dim.name }}</text>
+          <text class="sc-section-element" :style="{ color: dim.color }">{{ dim.element }}</text>
+          <text class="sc-section-slogan">{{ dim.slogan }}</text>
+          <text class="sc-section-count">{{ dim.answeredCount }}/{{ dim.questions.length }}</text>
+        </view>
+
+        <view v-for="q in dim.questions" :key="q.id" class="sc-question">
+          <view class="sc-q-tag" :style="{ background: dim.color + '15', color: dim.color }">
+            {{ dim.name }}
+          </view>
+          <text class="sc-q-text">{{ q.text }}</text>
+          <view class="sc-opts">
+            <view
+              v-for="opt in q.options"
+              :key="opt.value"
+              class="sc-opt"
+              :class="{ 'sc-opt-sel': answers[q.id] === opt.value }"
+              @click="selectOption(q.id, opt.value)"
+            >
+              <text class="sc-opt-label">{{ opt.label }}</text>
+              <view class="sc-opt-radio" :class="{ 'sc-opt-radio-on': answers[q.id] === opt.value }"></view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 提交 -->
+      <view class="sc-footer">
+        <button class="sc-submit-btn" :disabled="!canSubmit || submitLoading" @click="submit">
+          {{ submitLoading ? '提交中...' : '提交自检' }}
+        </button>
+        <text class="sc-submit-tip" v-if="!canSubmit && !submitLoading">
+          至少完成10题,且每个维度至少回答1题
+        </text>
+        <text class="sc-submit-tip" v-else-if="submitSuccess">提交成功!</text>
+      </view>
+    </template>
+
+    <!-- 错误状态 -->
+    <view v-else class="sc-error">
+      <text class="sc-error-icon">⚠️</text>
+      <text class="sc-error-text">生成失败</text>
+      <text class="sc-error-hint">将使用固定题库,请稍后重试</text>
+      <button class="sc-error-btn" @click="fallbackToFixed">使用固定 15 题题库</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getAiSelfCheckQuestions, getSelfCheckQuestions, submitSelfCheck } from '@/utils/api'
+
+export default {
+  data() {
+    return {
+      loading: true,
+      submitLoading: false,
+      submitSuccess: false,
+      questions: [],
+      rationale: '',
+      answers: {},
+      dimMeta: {
+        body: { name: '身', element: '土', color: '#FF8C42', slogan: '主动健康' },
+        wisdom: { name: '智', element: '金', color: '#6366F1', slogan: '赋能未来' },
+        wealth: { name: '富', element: '水', color: '#F59E0B', slogan: '利他创富' },
+        action: { name: '行', element: '木', color: '#10B981', slogan: '人际和谐' },
+        mind: { name: '心', element: '火', color: '#FF6B9D', slogan: '大爱相随' }
+      }
+    }
+  },
+  computed: {
+    dimensions: function() {
+      var order = ['body', 'wisdom', 'wealth', 'action', 'mind']
+      var meta = this.dimMeta
+      var result = []
+      for (var i = 0; i < order.length; i++) {
+        var code = order[i]
+        var dimQuestions = this.questions.filter(function(q) { return q.dimension === code })
+        if (dimQuestions.length === 0) continue
+        var self = this
+        var answered = dimQuestions.filter(function(q) { return self.answers[q.id] }).length
+        var m = meta[code] || {}
+        result.push({
+          code: code,
+          name: m.name || dimQuestions[0].dimensionName || code,
+          element: m.element || dimQuestions[0].element || '',
+          color: m.color || dimQuestions[0].color || '#F97316',
+          slogan: m.slogan || '',
+          questions: dimQuestions,
+          answeredCount: answered
+        })
+      }
+      return result
+    },
+    answeredCount: function() {
+      return Object.keys(this.answers).length
+    },
+    progress: function() {
+      if (!this.questions.length) return 0
+      return Math.round(this.answeredCount / this.questions.length * 100)
+    },
+    canSubmit: function() {
+      if (this.answeredCount < 10) return false
+      var dims = this.dimensions
+      for (var i = 0; i < dims.length; i++) {
+        if (dims[i].answeredCount < 1) return false
+      }
+      return true
+    }
+  },
+  methods: {
+    loadAiQuestions: function() {
+      var self = this
+      getAiSelfCheckQuestions({ count: 20, difficulty: 'contextual' }).then(function(res) {
+        self.loading = false
+        if (res.code === 200 && res.data && res.data.length > 0) {
+          self.questions = res.data || []
+          self.rationale = res.rationale || '根据您的用户画像(身维度有基础分,其他维度未测),优先覆盖了智/富/行/心四个维度'
+        } else {
+          // 降级到固定题库
+          self.loadFixedQuestions()
+        }
+      }).catch(function(err) {
+        console.error('AI 生成题目失败:', err)
+        self.loading = false
+        self.loadFixedQuestions()
+      })
+    },
+    loadFixedQuestions: function() {
+      var self = this
+      getSelfCheckQuestions().then(function(res) {
+        self.loading = false
+        if (res.code === 200 && res.data) {
+          self.questions = res.data || []
+          self.rationale = '使用固定 15 题题库(备用方案)'
+        } else {
+          uni.showToast({ title: (res && res.message) || '加载失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.loading = false
+        uni.showToast({ title: '网络异常,请重试', icon: 'none' })
+      })
+    },
+    selectOption: function(questionId, value) {
+      var next = {}
+      var keys = Object.keys(this.answers)
+      for (var i = 0; i < keys.length; i++) {
+        next[keys[i]] = this.answers[keys[i]]
+      }
+      next[questionId] = value
+      this.answers = next
+    },
+    submit: function() {
+      var self = this
+      if (!this.canSubmit || this.submitLoading) return
+      this.submitLoading = true
+      var answersList = []
+      var ids = Object.keys(this.answers)
+      for (var i = 0; i < ids.length; i++) {
+        answersList.push({ questionId: Number(ids[i]), answer: this.answers[ids[i]] })
+      }
+      submitSelfCheck({ answers: answersList }).then(function(res) {
+        self.submitLoading = false
+        if (res.code === 200 && res.data) {
+          self.submitSuccess = true
+          uni.setStorageSync('lastSelfCheckResult', res.data)
+          setTimeout(function() {
+            uni.navigateTo({ url: '/pages/family/self-check-result?id=' + res.data.id })
+          }, 500)
+        } else {
+          uni.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.submitLoading = false
+        uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+      })
+    },
+    fallbackToFixed: function() {
+      this.loadFixedQuestions()
+    }
+  },
+  onLoad: function() {
+    this.loadAiQuestions()
+  }
+}
+</script>
+
+<style scoped>
+.sc-page {
+  min-height: 100vh;
+  background: #F5FAFE;
+  padding: 24rpx 30rpx 120rpx;
+  box-sizing: border-box;
+}
+
+/* Hero */
+.sc-hero {
+  padding: 30rpx 10rpx 20rpx;
+}
+.sc-hero-badge {
+  display: inline-block;
+  background: linear-gradient(135deg, #6366F1, #8B5CF6);
+  color: #fff;
+  font-size: 22rpx;
+  padding: 6rpx 16rpx;
+  border-radius: 20rpx;
+  margin-bottom: 12rpx;
+}
+.sc-hero-title {
+  display: block;
+  font-size: 40rpx;
+  font-weight: 700;
+  color: #333;
+}
+.sc-hero-sub {
+  display: block;
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+
+/* Loading */
+.sc-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 100rpx 40rpx;
+}
+.sc-loading-spinner {
+  width: 60rpx;
+  height: 60rpx;
+  border: 4rpx solid #E5E7EB;
+  border-top-color: #6366F1;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+.sc-loading-text {
+  font-size: 28rpx;
+  color: #333;
+  margin-top: 24rpx;
+  font-weight: 500;
+}
+.sc-loading-hint {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 12rpx;
+}
+
+/* Rationale */
+.sc-rationale {
+  background: linear-gradient(135deg, #EEF2FF, #E0E7FF);
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
+  margin-bottom: 24rpx;
+  display: flex;
+  align-items: flex-start;
+}
+.sc-rationale-icon {
+  font-size: 28rpx;
+  margin-right: 12rpx;
+}
+.sc-rationale-text {
+  font-size: 24rpx;
+  color: #4F46E5;
+  flex: 1;
+  line-height: 1.6;
+}
+
+/* Dimension nav */
+.sc-dim-nav {
+  display: flex;
+  justify-content: space-between;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx 20rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+.sc-dim-nav-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.sc-dim-nav-dot {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.sc-dim-nav-char {
+  color: #fff;
+  font-size: 26rpx;
+  font-weight: 600;
+}
+.sc-dim-nav-dots {
+  display: flex;
+  margin-top: 10rpx;
+}
+.sc-dim-nav-dot-mini {
+  width: 12rpx;
+  height: 12rpx;
+  border-radius: 50%;
+  background: #E5E7EB;
+  margin: 0 4rpx;
+}
+.sc-dim-nav-dot-mini.done {
+  background: #6366F1;
+}
+
+/* Progress */
+.sc-progress-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+.sc-progress-text {
+  font-size: 24rpx;
+  color: #666;
+}
+.sc-progress-pct {
+  font-size: 24rpx;
+  color: #6366F1;
+  font-weight: 600;
+}
+.sc-progress-bar {
+  height: 10rpx;
+  background: #E5E7EB;
+  border-radius: 5rpx;
+  margin-bottom: 30rpx;
+  overflow: hidden;
+}
+.sc-progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #6366F1, #8B5CF6);
+  border-radius: 5rpx;
+  transition: width 0.3s ease;
+}
+
+/* Section */
+.sc-section {
+  margin-bottom: 30rpx;
+}
+.sc-section-header {
+  display: flex;
+  align-items: center;
+  padding: 16rpx 20rpx;
+  background: #fff;
+  border-radius: 16rpx 16rpx 0 0;
+  border-left: 6rpx solid;
+}
+.sc-section-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-right: 12rpx;
+}
+.sc-section-element {
+  font-size: 24rpx;
+  margin-right: 8rpx;
+}
+.sc-section-slogan {
+  font-size: 22rpx;
+  color: #999;
+  flex: 1;
+}
+.sc-section-count {
+  font-size: 22rpx;
+  color: #999;
+}
+
+/* Question */
+.sc-question {
+  background: #fff;
+  padding: 24rpx 28rpx;
+  border-bottom: 1rpx solid #F3F4F6;
+}
+.sc-question:last-child {
+  border-radius: 0 0 16rpx 16rpx;
+}
+.sc-q-tag {
+  display: inline-block;
+  font-size: 20rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 8rpx;
+  margin-bottom: 12rpx;
+}
+.sc-q-text {
+  display: block;
+  font-size: 28rpx;
+  color: #333;
+  line-height: 1.6;
+  margin-bottom: 20rpx;
+}
+
+/* Options */
+.sc-opts {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+.sc-opt {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 24rpx;
+  background: #F9FAFB;
+  border-radius: 12rpx;
+  border: 2rpx solid transparent;
+  transition: all 0.2s;
+}
+.sc-opt:active {
+  background: #EEF2FF;
+}
+.sc-opt-sel {
+  background: #EEF2FF;
+  border-color: #6366F1;
+}
+.sc-opt-label {
+  font-size: 26rpx;
+  color: #374151;
+  flex: 1;
+}
+.sc-opt-radio {
+  width: 32rpx;
+  height: 32rpx;
+  border-radius: 50%;
+  border: 2rpx solid #D1D5DB;
+  margin-left: 16rpx;
+}
+.sc-opt-radio-on {
+  border-color: #6366F1;
+  background: #6366F1;
+  position: relative;
+}
+.sc-opt-radio-on::after {
+  content: '';
+  position: absolute;
+  width: 12rpx;
+  height: 12rpx;
+  background: #fff;
+  border-radius: 50%;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+}
+
+/* Footer */
+.sc-footer {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  background: #fff;
+  padding: 24rpx 30rpx;
+  padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
+  box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.08);
+}
+.sc-submit-btn {
+  width: 100%;
+  height: 88rpx;
+  line-height: 88rpx;
+  background: linear-gradient(135deg, #6366F1, #8B5CF6);
+  color: #fff;
+  font-size: 32rpx;
+  font-weight: 600;
+  border-radius: 44rpx;
+  border: none;
+}
+.sc-submit-btn[disabled] {
+  background: #D1D5DB;
+}
+.sc-submit-tip {
+  display: block;
+  text-align: center;
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 16rpx;
+}
+
+/* Error */
+.sc-error {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 100rpx 40rpx;
+}
+.sc-error-icon {
+  font-size: 80rpx;
+  margin-bottom: 20rpx;
+}
+.sc-error-text {
+  font-size: 32rpx;
+  color: #333;
+  font-weight: 600;
+  margin-bottom: 12rpx;
+}
+.sc-error-hint {
+  font-size: 24rpx;
+  color: #999;
+  margin-bottom: 40rpx;
+}
+.sc-error-btn {
+  width: 300rpx;
+  height: 80rpx;
+  line-height: 80rpx;
+  background: #6366F1;
+  color: #fff;
+  font-size: 28rpx;
+  border-radius: 40rpx;
+  border: none;
+}
+</style>

+ 4 - 4
cfc-frontend/pages/health-main/index.vue

@@ -543,10 +543,10 @@ export default {
       try {
         var api = require('../../utils/api.js')
         var memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || ''
-        // 健康报告:最近记录只有两种情况——①未切换=当前登录用户自己的;②已切换=被切换成员的。
-        // currentChildId/currentMemberId 仅在切换视图时写入 storage、切回即清除,未切换时回退到 userId(登录者本人);
-        // memberId 为空时后端才返回全家报告——此处必须传 memberId,避免首页混入其他成员的报告
-        api.getReportList(memberId, 3).then(function(res) {
+        // 最近记录:展示家庭所有成员的最近报告——不传 memberId,后端返回当前家庭全部报告
+        // (按报告日期倒序)取最近 3 条,卡片通过 childName/personName 区分成员。
+        // 下方维度/认知/心理等数据仍按当前选中成员(memberId)加载,不受此影响
+        api.getReportList('', 3).then(function(res) {
           if (res.code === 200 && res.data) {
             var list = Array.isArray(res.data) ? res.data : (res.data.list || [])
             self.reports = list

+ 118 - 2
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -71,6 +71,27 @@
         </view>
       </view>
 
+      <!-- 指标变化(最近两次报告) -->
+      <view class="change-section" v-if="changeItems.length > 0">
+        <view class="section-title-bar">
+          <text class="analysis-title">📈 指标变化</text>
+          <text class="analysis-subtitle">与上次报告对比</text>
+        </view>
+        <view class="change-card">
+          <view class="change-row" v-for="(item, ci) in changeItems" :key="getChangeKey(item, ci)">
+            <text class="change-name">{{ item.name }}</text>
+            <text class="change-before">{{ item.before ? item.before.value : '--' }}</text>
+            <text class="change-arrow" :class="'change-arrow ' + item._cssClass">{{ item._arrow }}</text>
+            <text class="change-after">{{ item.after ? item.after.value : '--' }}</text>
+            <text class="change-delta" :class="'change-delta ' + item._cssClass">{{ item._deltaText }}</text>
+            <text class="change-trend" :class="'change-trend ' + item._cssClass">{{ item._trendLabel }}</text>
+          </view>
+          <view class="change-more" @tap="goCurve" v-if="changeItems.length > 0">
+            <text class="change-more-text">查看完整变化曲线 ›</text>
+          </view>
+        </view>
+      </view>
+
       <!-- 菌群功能分析 -->
       <view class="analysis-section" v-if="analysisData">
         <view class="section-title-bar">
@@ -332,10 +353,11 @@
 </template>
 
 <script>
-import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft, getHealthFoods, getFoodCautionList, getFamilyMemberList, addFamilyMember } from '../../utils/api.js'
+import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft, getHealthFoods, getFoodCautionList, getFamilyMemberList, addFamilyMember, getIndicatorChangeRanking } from '../../utils/api.js'
 import HealthKnowledgePopup from '@/pages/health/components/health-knowledge-popup.vue'
 import FeedbackPopup from '@/pages/health/components/feedback-popup.vue'
 import { parseDate } from '../../utils/format.js'
+import { resolveChangeStyle, formatDelta, colorClassFor } from '../../utils/indicator-change.js'
 
 var INDICATOR_TYPE_MAP = {
   '维生素': 'vitamin',
@@ -398,7 +420,9 @@ export default {
       candidates: [],
       extractedName: '',
       extractedGender: '',
-      extractedAge: ''
+      extractedAge: '',
+      changeItems: [],
+      _changeLoaded: false
     }
   },
   computed: {
@@ -640,6 +664,8 @@ riskGroupAbnormalCount: function() {
       })
       // 加载菌群功能分析
       this.loadAnalysis()
+      // 加载指标变化区块
+      this.loadChangeBlock()
     },
     loadAnalysis: function() {
       var self = this
@@ -1006,6 +1032,33 @@ applyDraftPayload: function(payload) {
       this.nutriGroupOpen = arr
       if (this.nutritionGroups.length === 0) uni.showToast({ title: '暂无营养指标', icon: 'none' })
     },
+    loadChangeBlock: function() {
+      var self = this
+      if (!this.report || !this.report.subjectId || this._changeLoaded) return
+      this._changeLoaded = true
+      getIndicatorChangeRanking({
+        subjectId: this.report.subjectId,
+        reportType: 'gut_flora',
+        scope: 'adjacent',
+        orderBy: 'magnitude',
+        limit: 6
+      }).then(function(res) {
+        var items = (res.data && res.data.items) || []
+        // 预计算展示字段,避免小程序 :class 中调用方法(微信模板编译器不支持带参方法调用)
+        items.forEach(function(it) {
+          var st = resolveChangeStyle(it.goodDirection, it.movement || 'flat')
+          it._cssClass = colorClassFor(st.cssClass)
+          it._arrow = st.arrow
+          it._trendLabel = st.trendLabel
+          it._deltaText = formatDelta(it.delta, it.deltaPct)
+        })
+        self.changeItems = items
+      }).catch(function() {})
+    },
+    getChangeKey: function(item, i) { return item.definitionId || i },
+    goCurve: function() {
+      uni.navigateTo({ url: '/pages/health/indicator-curve?subjectId=' + this.report.subjectId + '&reportType=gut_flora' })
+    },
     // 问答功能已屏蔽
     goAIChat: function() {}
   }
@@ -1232,6 +1285,69 @@ applyDraftPayload: function(payload) {
   display: block;
   margin-top: 4rpx;
 }
+/* ===== 指标变化(最近两次报告对比) ===== */
+.change-section {
+  margin: 20rpx 30rpx;
+}
+.change-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.change-row {
+  display: flex;
+  align-items: center;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+  font-size: 24rpx;
+}
+.change-row:last-child { border-bottom: none; }
+.change-name {
+  width: 240rpx;
+  color: #333;
+  font-weight: 500;
+  flex-shrink: 0;
+}
+.change-before {
+  color: #999;
+  width: 110rpx;
+  text-align: center;
+  flex-shrink: 0;
+}
+.change-arrow {
+  width: 50rpx;
+  text-align: center;
+  font-size: 28rpx;
+  flex-shrink: 0;
+}
+.change-after {
+  color: #333;
+  width: 110rpx;
+  text-align: center;
+  flex-shrink: 0;
+}
+.change-delta {
+  width: 120rpx;
+  text-align: center;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+.change-trend {
+  font-size: 22rpx;
+  color: #888;
+  margin-left: 8rpx;
+  flex: 1;
+}
+.change-more {
+  margin-top: 12rpx;
+  padding-top: 16rpx;
+  border-top: 1rpx solid #f0f0f0;
+}
+.change-more-text {
+  font-size: 24rpx;
+  color: #4A9BD7;
+}
 .analysis-card {
   background: #fff;
   border-radius: 16rpx;

+ 444 - 0
cfc-frontend/pages/health/indicator-curve.vue

@@ -0,0 +1,444 @@
+<template>
+  <view class="page">
+    <!-- 顶部筛选:成员 / 报告类型 -->
+    <view class="filter-bar">
+      <picker :range="memberNames" @change="onMemberChange">
+        <view class="filter-item">{{ currentMemberName }} ▾</view>
+      </picker>
+      <picker :range="reportTypeNames" @change="onReportTypeChange">
+        <view class="filter-item">{{ currentReportTypeName }} ▾</view>
+      </picker>
+    </view>
+
+    <!-- 曲线区:指标选择器 + TrendChart -->
+    <view class="curve-section">
+      <view class="section-title">指标变化曲线</view>
+      <view class="indicator-picker">
+        <scroll-view scroll-x class="indicator-scroll">
+          <view
+            class="indicator-chip"
+            :class="def.definitionId === theIndicatorId ? 'chip-active' : ''"
+            v-for="def in indicatorOptions"
+            :key="getIndicatorKey(def)"
+            @tap="onIndicatorTap(getIndicatorId(def))"
+          >{{ getIndicatorName(def) }}</view>
+        </scroll-view>
+      </view>
+      <TrendChart v-if="series && series.length > 0" :series="series" />
+      <view class="empty-tip" v-else>暂无趋势数据</view>
+    </view>
+
+    <!-- 变化排名区 -->
+    <view class="ranking-section">
+      <view class="section-title">变化排名</view>
+      <view class="rank-tabs">
+        <view
+          class="rank-tab"
+          :class="'rank-' + activeRankTab"
+          v-for="(tab, ti) in rankTabs"
+          :key="getTabKey(tab, ti)"
+          @tap="onRankTabTap(tab.value)"
+        >{{ tab.label }}</view>
+      </view>
+      <view class="rank-sorts">
+        <view
+          class="sort-btn"
+          :class="sortBy === s.value ? 'sort-active' : ''"
+          v-for="s in sortOptions"
+          :key="getSortKey(s)"
+          @tap="onSortTap(s.value)"
+        >{{ s.label }}</view>
+      </view>
+      <view class="rank-list" v-if="rankItems.length > 0">
+        <view class="rank-item" v-for="item in rankItems" :key="getRankKey(item)">
+          <view class="rank-name-row">
+            <text class="rank-name">{{ item.name }}</text>
+            <text class="rank-category">{{ item.category }}</text>
+          </view>
+          <view class="rank-value-row">
+            <text class="rank-before">{{ item.before ? item.before.value : '--' }}</text>
+            <text :class="'rank-arrow ' + item._cssClass">→</text>
+            <text class="rank-after">{{ item.after ? item.after.value : '--' }}</text>
+            <text :class="'rank-delta ' + item._cssClass">{{ item._deltaText }}</text>
+          </view>
+          <text :class="'rank-trend ' + item._cssClass">{{ item._trendLabel }} {{ item._arrow }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import TrendChart from '@/components/TrendChart.vue'
+import { getIndicatorTrend, getIndicatorChangeRanking } from '@/utils/api'
+import { getFamilyMemberList } from '@/utils/api'
+import { resolveChangeStyle, formatDelta, colorClassFor } from '@/utils/indicator-change'
+import { parseDate } from '@/utils/format'
+
+export default {
+  components: { TrendChart },
+  data: function() {
+    return {
+      members: [], currentMemberId: null,
+      reportTypeOptions: [
+        { value: 'gut_flora', label: '肠道菌群' },
+        { value: 'food_recommend', label: '食材推荐' }
+      ],
+      activeReportType: 'gut_flora',
+      indicatorOptions: [], theIndicatorId: null,
+      series: [], loadedDefinitionIds: [],
+      rankTabs: [ { value: 'flora', label: '菌群变化' }, { value: 'food', label: '食材变化' }, { value: 'indicator', label: '指标变化' } ],
+      activeRankTab: 'flora',
+      sortOptions: [ { value: 'magnitude', label: '绝对幅度' }, { value: 'up', label: '升幅↑' }, { value: 'down', label: '降幅↓' } ],
+      sortBy: 'magnitude',
+      rankItems: [], overall: {},
+      _onLoadFired: false, _loading: false
+    }
+  },
+  computed: {
+    memberNames: function() { return this.members.map(function(m) { return m.name || m.nickname || '' }) },
+    reportTypeNames: function() {
+      var self = this
+      return this.reportTypeOptions.map(function(o) { return o.label })
+    },
+    currentMemberName: function() {
+      var self = this
+      var m = this.members.find(function(x) { return x.id === self.currentMemberId })
+      return (m && (m.name || m.nickname)) || '选择成员'
+    },
+    currentReportTypeName: function() {
+      var self = this
+      var o = this.reportTypeOptions.find(function(x) { return x.value === self.activeReportType })
+      return (o && o.label) || ''
+    }
+  },
+  onLoad: function(options) {
+    this.loadMembers()
+    if (options && options.definitionId) {
+      this.theIndicatorId = Number(options.definitionId)
+    }
+    this._onLoadFired = true
+  },
+  onShow: function() {
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+    } else if (this.currentMemberId) {
+      this.reloadAll()
+    }
+  },
+  methods: {
+    getIndicatorId: function(def) { return def ? def.definitionId : null },
+    getIndicatorName: function(def) { return (def && def.name) || '' },
+    getIndicatorKey: function(def) { return def ? (def.definitionId || def.name) : '' },
+    getTabKey: function(tab, i) { return tab.value || i },
+    getSortKey: function(s) { return s.value },
+    getRankKey: function(item) { return item.definitionId },
+
+    loadMembers: function() {
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        var arr = (res.data && res.data.list) || (res.data instanceof Array ? res.data : [])
+        self.members = arr
+        if (!self.currentMemberId && arr.length > 0) {
+          self.currentMemberId = arr[0].id
+          self.onMemberSelected()
+        }
+      })
+    },
+    onMemberSelected: function() { this.reloadAll() },
+    onMemberChange: function(e) {
+      var idx = Number(e.detail.value)
+      var m = this.members[idx]
+      if (m) { this.currentMemberId = m.id; this.reloadAll() }
+    },
+    onReportTypeChange: function(e) {
+      var idx = Number(e.detail.value)
+      var o = this.reportTypeOptions[idx]
+      if (o) { this.activeReportType = o.value; this.reloadAll() }
+    },
+    reloadAll: function() {
+      if (!this.currentMemberId) return
+      this.loadCurve()
+      this.loadRanking()
+    },
+    loadCurve: function() {
+      // 曲线页:先拉 ranking 获得指标候选,再拉 trend(避免硬编码 definition 列表)
+      this.loadRankingThenCurve()
+    },
+    loadRankingThenCurve: function() {
+      var self = this
+      if (!this.currentMemberId) return
+      var reportType = this.activeReportType === 'food_recommend' ? null : this.activeReportType
+      getIndicatorChangeRanking({
+        subjectId: this.currentMemberId,
+        reportType: reportType,
+        category: this.activeRankTab === 'food' ? null : (this.activeRankTab === 'flora' ? '核心菌属' : null),
+        scope: 'adjacent', orderBy: 'magnitude', limit: 30
+      }).then(function(res) {
+        var items = (res.data && res.data.items) || []
+        // 用排名结果作为指标候选
+        self.indicatorOptions = items.slice(0, 10).map(function(it) {
+          return { definitionId: it.definitionId, name: it.name }
+        })
+        if (self.indicatorOptions.length > 0) {
+          var target = self.theIndicatorId
+          var found = self.indicatorOptions.find(function(d) { return d.definitionId === target })
+          if (!found) { self.theIndicatorId = self.indicatorOptions[0].definitionId }
+          self.loadTrendFor([self.theIndicatorId])
+        } else {
+          self.series = []
+        }
+      })
+    },
+    loadTrendFor: function(definitionIds) {
+      var self = this
+      if (!this.currentMemberId || !definitionIds || definitionIds.length === 0) return
+      getIndicatorTrend({ subjectId: this.currentMemberId, definitionIds: definitionIds }).then(function(res) {
+        var items = (res.data && res.data.items) || []
+        var series = items.map(function(it) {
+          var color = ''
+          return { name: it.name, color: color || '#F97316', points: (it.points || []).map(function(p) { return { reportDate: String(p.reportDate).slice(0, 10), value: p.value, unit: p.unit } }) }
+        })
+        self.series = series
+      })
+    },
+    onIndicatorTap: function(definitionId) {
+      if (definitionId === this.theIndicatorId) return
+      this.theIndicatorId = definitionId
+      this.loadTrendFor([definitionId])
+    },
+    onRankTabTap: function(value) { this.activeRankTab = value; this.loadRanking() },
+    onSortTap: function(value) { this.sortBy = value; this.loadRanking() },
+    loadRanking: function() {
+      var self = this
+      if (!this.currentMemberId) return
+      var category = null
+      if (this.activeRankTab === 'flora') category = '核心菌属'
+      else if (this.activeRankTab === 'indicator') category = null
+      var reportType = this.activeRankTab === 'food' ? 'food_recommend' : (this.activeRankTab === 'flora' ? 'gut_flora' : null)
+      getIndicatorChangeRanking({
+        subjectId: this.currentMemberId,
+        reportType: reportType,
+        category: category,
+        scope: 'adjacent', orderBy: this.sortBy, limit: 50
+      }).then(function(res) {
+        var items = (res.data && res.data.items) || []
+        // 预计算展示字段,避免小程序模板在 :class 中调用方法(微信模板编译器不支持带参方法调用)
+        self.rankItems = items.map(function(it) {
+          var st = resolveChangeStyle(it.goodDirection, it.movement)
+          it._cssClass = colorClassFor(st.cssClass)
+          it._trendLabel = st.trendLabel
+          it._arrow = st.arrow
+          it._deltaText = formatDelta(it.delta, it.deltaPct)
+          return it
+        })
+        self.overall = (res.data && res.data.overall) || {}
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background-color: #F5F9FC;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+
+/* 顶部筛选 */
+.filter-bar {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 24rpx;
+}
+
+.filter-item {
+  flex: 1;
+  background-color: #FFFFFF;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 16rpx;
+  padding: 16rpx 24rpx;
+  margin-right: 16rpx;
+  font-size: 26rpx;
+  color: #1E293B;
+  text-align: center;
+}
+
+.filter-item:last-child {
+  margin-right: 0;
+}
+
+/* 区块通用 */
+.curve-section,
+.ranking-section {
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 4rpx 16rpx rgba(30, 41, 59, 0.06);
+}
+
+.section-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1E293B;
+  margin-bottom: 20rpx;
+}
+
+/* 指标选择器 */
+.indicator-picker {
+  margin-bottom: 20rpx;
+}
+
+.indicator-scroll {
+  white-space: nowrap;
+  width: 100%;
+}
+
+.indicator-chip {
+  display: inline-flex;
+  align-items: center;
+  padding: 10rpx 24rpx;
+  margin-right: 16rpx;
+  border-radius: 32rpx;
+  background-color: #EDF2F7;
+  font-size: 24rpx;
+  color: #64748B;
+}
+
+.chip-active {
+  background-color: #5B9BD5;
+  color: #FFFFFF;
+}
+
+.empty-tip {
+  padding: 60rpx 0;
+  text-align: center;
+  font-size: 26rpx;
+  color: #94A3B8;
+}
+
+/* 排名 Tab */
+.rank-tabs {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 16rpx;
+}
+
+.rank-tab {
+  flex: 1;
+  text-align: center;
+  padding: 14rpx 0;
+  font-size: 26rpx;
+  color: #64748B;
+  border-bottom: 4rpx solid transparent;
+}
+
+.rank-tab.rank-flora,
+.rank-tab.rank-food,
+.rank-tab.rank-indicator {
+  color: #64748B;
+}
+
+.rank-sorts {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 20rpx;
+}
+
+.sort-btn {
+  padding: 8rpx 24rpx;
+  margin-right: 16rpx;
+  border-radius: 24rpx;
+  font-size: 24rpx;
+  color: #64748B;
+  background-color: #EDF2F7;
+}
+
+.sort-active {
+  background-color: #D6EAF8;
+  color: #3A7CC4;
+}
+
+/* 排名列表 */
+.rank-list {
+  display: flex;
+  flex-direction: column;
+}
+
+.rank-item {
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #E2E8F0;
+}
+
+.rank-item:last-child {
+  border-bottom: none;
+}
+
+.rank-name-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+
+.rank-name {
+  font-size: 28rpx;
+  color: #1E293B;
+  font-weight: 500;
+  margin-right: 16rpx;
+}
+
+.rank-category {
+  font-size: 22rpx;
+  color: #94A3B8;
+}
+
+.rank-value-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-bottom: 8rpx;
+}
+
+.rank-before {
+  font-size: 26rpx;
+  color: #64748B;
+}
+
+.rank-arrow {
+  font-size: 26rpx;
+  margin: 0 12rpx;
+}
+
+.rank-after {
+  font-size: 28rpx;
+  color: #1E293B;
+  font-weight: 600;
+}
+
+.rank-delta {
+  font-size: 24rpx;
+  margin-left: 16rpx;
+}
+
+.rank-trend {
+  display: block;
+  font-size: 22rpx;
+}
+
+/* 变化着色:改善=绿 / 恶化=红 / 中性=灰 */
+.green-text {
+  color: #16A34A;
+}
+
+.red-text {
+  color: #DC2626;
+}
+
+.gray-text {
+  color: #94A3B8;
+}
+</style>

+ 154 - 0
cfc-frontend/pages/health/indicator-quicklook.vue

@@ -0,0 +1,154 @@
+<template>
+  <view class="page">
+    <!-- 成员切换 -->
+    <picker :range="memberNames" @change="onMemberChange">
+      <view class="member-picker">{{ currentMemberName }} ▾</view>
+    </picker>
+
+    <!-- 分类 Tab -->
+    <scroll-view scroll-x class="cat-scroll">
+      <view class="cat-tab" :class="activeCategory === '' ? 'cat-active' : ''" @tap="onCatTap('')">全部</view>
+      <view
+        class="cat-tab"
+        :class="activeCategory === c.name ? 'cat-active' : ''"
+        v-for="c in categories"
+        :key="getCatKey(c)"
+        @tap="onCatTap(c.name)"
+      >{{ c.name }}({{ c.total }})</view>
+    </scroll-view>
+
+    <!-- 指标列表 -->
+    <view class="item-list" v-if="items.length > 0">
+      <view class="ql-item" v-for="item in items" :key="getItemKey(item)" @tap="goCurve(getItemId(item))">
+        <view class="ql-name-row">
+          <text class="ql-name">{{ item.name }}</text>
+          <text :class="'ql-trend ' + item._cssClass">{{ item._arrow }} {{ item._trendLabel }}</text>
+        </view>
+        <view class="ql-value-row">
+          <text class="ql-value">{{ item.value }}{{ item.unit || '' }}</text>
+          <text class="ql-ref">参考: {{ item.refRange || '--' }}</text>
+          <text class="ql-status" :class="item._statusClass">{{ item.status || '' }}</text>
+        </view>
+        <text class="ql-date">{{ item._dateText }}</text>
+      </view>
+    </view>
+    <view class="empty-tip" v-else>暂无数据</view>
+
+    <!-- 分页 -->
+    <view class="pager" v-if="totalPages > 1">
+      <view class="pager-btn" :class="page <= 1 ? 'pager-disabled' : ''" @tap="onPrevPage">上一页</view>
+      <text class="pager-info">{{ page }} / {{ totalPages }}</text>
+      <view class="pager-btn" :class="page >= totalPages ? 'pager-disabled' : ''" @tap="onNextPage">下一页</view>
+    </view>
+
+    <!-- 五维/七维评分小节 -->
+    <view class="dim-section" v-if="dimensionScores.length > 0">
+      <view class="section-title">维度评分趋势</view>
+      <view class="dim-list">
+        <view class="dim-row" v-for="d in dimensionScores" :key="getDimKey(d)">
+          <text class="dim-name">{{ d.name }}</text>
+          <text class="dim-score">{{ d.score }}</text>
+          <text :class="'dim-trend ' + d._cssClass">{{ d._arrow }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getIndicatorQuickLookup } from '@/utils/api'
+import { getFamilyMemberList } from '@/utils/api'
+import { resolveChangeStyle, formatDelta, colorClassFor } from '@/utils/indicator-change'
+
+export default {
+  data: function() { return { members: [], currentMemberId: null, categories: [], activeCategory: '', items: [], dimensionScores: [], page: 1, pageSize: 30, totalPages: 1, _onLoadFired: false, _loading: false } },
+  computed: {
+    memberNames: function() { return this.members.map(function(m) { return m.name || m.nickname || '' }) },
+    currentMemberName: function() {
+      var self = this
+      var m = this.members.find(function(x) { return x.id === self.currentMemberId })
+      return (m && (m.name || m.nickname)) || '选择成员'
+    },
+    categoriesComputed: function() {
+      var self = this
+      return this.categories || []
+    }
+  },
+  methods: {
+    onLoad: function() { this.loadMembers(); this._onLoadFired = true },
+    onShow: function() { if (this._onLoadFired) { this._onLoadFired = false } else if (this.currentMemberId) { this.reload() } },
+    goCurve: function(definitionId) {
+      uni.navigateTo({ url: '/pages/health/indicator-curve?definitionId=' + definitionId })
+    },
+    loadMembers: function() {
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        var arr = (res.data && res.data.list) || (res.data instanceof Array ? res.data : [])
+        self.members = arr
+        if (!self.currentMemberId && arr.length > 0) { self.currentMemberId = arr[0].id; self.reload() }
+      })
+    },
+    reload: function() { this.page = 1; this.loadData() },
+    loadData: function() {
+      var self = this
+      if (!this.currentMemberId || this._loading) return
+      this._loading = true
+      getIndicatorQuickLookup({ subjectId: this.currentMemberId, category: this.activeCategory || undefined, page: this.page, pageSize: this.pageSize }).then(function(res) {
+        self._loading = false
+        var data = res.data || {}
+        // 预计算展示字段,避免小程序 :class 中调用方法(微信模板编译器不支持带参方法调用)
+        self.rankItems = (data && data.items) || []
+        self.rankItems.forEach(function(it) {
+          var st = resolveChangeStyle(it.goodDirection, it.movement || 'flat')
+          it._cssClass = colorClassFor(st.cssClass)
+          it._arrow = st.arrow
+          it._trendLabel = st.trendLabel
+          it._statusClass = 'status-' + (it._cssClass || 'normal')
+          it._dateText = it.latestReportDate ? String(it.latestReportDate).slice(0, 10) : '--'
+        })
+        self.categories = data.categories || []
+        self.items = data.items || []
+        self.dimensionScores = data.dimensionScores || []
+        var total = data.total !== undefined ? data.total : self.items.length
+        self.totalPages = Math.max(1, Math.ceil(total / self.pageSize))
+      }).catch(function() { self._loading = false })
+    },
+    onCatTap: function(name) { this.activeCategory = name; this.reload() },
+    onPrevPage: function() { if (this.page > 1) { this.page--; this.loadData() } },
+    onNextPage: function() { if (this.page < this.totalPages) { this.page++; this.loadData() } },
+    formatDate: function(s) { if (!s) return '--'; return String(s).slice(0, 10) },
+    getCatKey: function(c) { return c.name },
+    getItemKey: function(item) { return item.definitionId },
+    getItemId: function(item) { return item.definitionId },
+    getDimKey: function(d) { return d.name }
+  }
+}
+</script>
+
+<style scoped>
+.member-picker { font-size: 24rpx; color: #666; }
+.cat-scroll { height: 40rpx; line-height: 40rpx; overflow-x: scroll; }
+.cat-tab { float: left; margin-right: 20rpx; padding: 0 12rpx; height: 32rpx; line-height: 32rpx; font-size: 22rpx; color: #999; }
+.cat-tab.cat-active { color: #4A9BD7; border-bottom: 2rpx solid #4A9BD7; }
+.item-list { padding: 20rpx; }
+.ql-item { margin-bottom: 20rpx; }
+.ql-name-row { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
+.ql-name { font-size: 26rpx; }
+.ql-trend { font-size: 20rpx; color: #666; }
+.ql-value-row { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
+.ql-value { font-size: 24rpx; }
+.ql-ref { font-size: 16rpx; color: #999; }
+.ql-status { font-size: 20rpx; }
+.ql-date { font-size: 16rpx; color: #999; margin-top: 4rpx; }
+.pager { height: 40rpx; line-height: 40rpx; padding: 0 20rpx; font-size: 20rpx; }
+.pager-btn { float: left; margin-right: 10rpx; padding: 0 12rpx; height: 32rpx; line-height: 32rpx; font-size: 20rpx; color: #999; }
+.pager-btn.pager-disabled { color: #ccc; }
+.pager-info { float: left; margin: 0 20rpx; }
+.dim-section { padding: 20rpx; }
+.section-title { font-size: 26rpx; margin-bottom: 12rpx; }
+.dim-list { display: flex; flex-wrap: wrap; }
+.dim-row { margin-bottom: 12rpx; }
+.dim-name { font-size: 22rpx; width: 60rpx; }
+.dim-score { font-size: 22rpx; color: #666; }
+.dim-trend { font-size: 18rpx; color: #999; }
+</style>

+ 23 - 4
cfc-frontend/pages/health/report-list.vue

@@ -198,10 +198,17 @@ export default {
     }
   },
   onLoad(options) {
-    // 从 storage 获取当前用户ID(优先 currentChildId,其次 currentMemberId,最后回退登录 userId)
-    var currentUserId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || ''
-    this.memberId = options.memberId ? parseInt(options.memberId) : (currentUserId ? parseInt(currentUserId) : null)
-    // 默认显示当前用户的报告;"全部"可切换查看所有成员
+    // 默认选中本人(登录用户):
+    // - 无显式 memberId 时用登录 userId 过滤,后端 getMemberReports 兼容 users.id / family_members.id;
+    // - 不再优先 currentChildId/currentMemberId——切换视角进入时默认也是本人,点成员 chip 才切换
+    if (options.memberId) {
+      this.memberId = parseInt(options.memberId, 10)
+    } else {
+      var loginUserId = uni.getStorageSync('userId') || ''
+      this.memberId = loginUserId ? parseInt(loginUserId, 10) : null
+      // 成员列表返回后把 chip 高亮从 users.id 归一到本人的 family_members.id(_filterId)
+      this._selfUserId = loginUserId
+    }
     this.selectedMemberId = this.memberId
     this.loadMembers()
     this.loadData()
@@ -306,12 +313,24 @@ export default {
             var m = res.data[i]
             list.push({
               id: m.id,
+              userId: m.userId,
               nickname: m.nickname,
               avatar: m.avatar,
               _filterId: m.id || m.userId
             })
           }
           self.members = list
+          // 默认选中本人时,把 chip 高亮从 users.id 归一到本人的 family_members.id(_filterId):
+          // 两个 id 后端返回同一份报告,数据已在 onLoad 拉取过,这里只修高亮、不重复请求
+          if (self._selfUserId && self.selectedMemberId != null &&
+              Number(self.selectedMemberId) === Number(self._selfUserId)) {
+            for (var j = 0; j < list.length; j++) {
+              if (list[j].userId != null && Number(list[j].userId) === Number(self._selfUserId)) {
+                self.selectedMemberId = list[j]._filterId
+                break
+              }
+            }
+          }
         }
       }).catch(function(e) {
         console.log('获取家庭成员失败', e)

+ 75 - 29
cfc-frontend/pages/index-home/index.vue

@@ -103,12 +103,12 @@
       :notices="notices"
       :showHeader="true" />
 
-    <!-- 能量/积分/CF值变更通知条 -->
-    <view v-if="showEnergyBadge || showPointsBadge || showCfBadge" class="change-notification-bar" @click="clearChangeNotifications">
-      <text v-if="showEnergyBadge" class="change-item energy">{{ formatEnergyNotification }}</text>
-      <text v-if="showPointsBadge" class="change-item points">{{ formatPointsNotification }}</text>
-      <text v-if="showCfBadge" class="change-item cf">{{ formatCfNotification }}</text>
-      <text class="change-dismiss">✕</text>
+    <!-- 能量/积分/CF值变更通知 - 从下滑动展示 -->
+    <view v-if="showEnergyBadge || showPointsBadge || showCfBadge" class="change-notification-bar" :class="{ 'visible': isNotificationVisible }" @click="handleNotificationClick">
+      <view v-if="showEnergyBadge" class="change-item energy" @click.stop="onEnergyClick">{{ formatEnergyNotification }}</view>
+      <view v-if="showPointsBadge" class="change-item points" @click.stop="onPointsClick">{{ formatPointsNotification }}</view>
+      <view v-if="showCfBadge" class="change-item cf" @click.stop="onCfClick">{{ formatCfNotification }}</view>
+      <view class="change-dismiss" @click="dismissAll">✕</view>
     </view>
 
     <!-- ④ 内容区 — 推荐文章 -->
@@ -533,7 +533,9 @@ membershipDays: 0,
       cfMessages: [],
       showEnergyBadge: false,
       showPointsBadge: false,
-      showCfBadge: false
+      showCfBadge: false,
+      // 通知栏可见性(从下往上滑入)
+      isNotificationVisible: false
 
 
     }
@@ -691,30 +693,36 @@ title: '添加家庭成员',
         color: 'gray'
       }
     },
-    // ===== 能量/积分/CF值变更通知格式化 =====
+    // ===== 能量/积分/CF值变更通知格式化(标题带具体数量) =====
     formatEnergyNotification: function() {
-      var texts = []
+      var total = 0
       var arr = this.energyMessages || []
       for (var i = 0; i < arr.length; i++) {
-        texts.push(arr[i].description)
+        total += (arr[i].amount || 0)
       }
-      return '⚡ ' + (texts.join(';') || '能量变动')
+      var sign = total > 0 ? '+' : ''
+      var first = arr.length > 0 ? arr[0].description : '能量变动'
+      return '⚡ 能量 ' + sign + total + ' · ' + first
     },
     formatPointsNotification: function() {
-      var texts = []
+      var total = 0
       var arr = this.pointsMessages || []
       for (var i = 0; i < arr.length; i++) {
-        texts.push(arr[i].description)
+        total += (arr[i].amount || 0)
       }
-      return '🪙 ' + (texts.join(';') || '积分变动')
+      var sign = total > 0 ? '+' : ''
+      var first = arr.length > 0 ? arr[0].description : '积分变动'
+      return '🪙 积分 ' + sign + total + ' · ' + first
     },
     formatCfNotification: function() {
-      var texts = []
+      var total = 0
       var arr = this.cfMessages || []
       for (var i = 0; i < arr.length; i++) {
-        texts.push(arr[i].description)
+        total += (arr[i].amount || 0)
       }
-      return '💰 ' + (texts.join(';') || 'CF值变动')
+      var sign = total > 0 ? '+' : ''
+      var first = arr.length > 0 ? arr[0].description : 'CF值变动'
+      return '💰 CF值 ' + sign + total + ' · ' + first
     }
   },
   onLoad(options) {
@@ -1176,6 +1184,9 @@ title: '添加家庭成员',
           self.cfMessages = cfRes.messages
           self.showCfBadge = cfRes.showBadge
         }
+        
+        // 通知栏滑入动画:数据加载完成后触发从下往上的展示
+        self.isNotificationVisible = true
       }).catch(function() {
         self.energyMessages = []
         self.pointsMessages = []
@@ -1400,7 +1411,36 @@ title: '添加家庭成员',
              date.getFullYear() === today.getFullYear()
     },
     
-    clearChangeNotifications: function() {
+    // ===== 能量/积分/CF值变更通知点击处理 =====
+    // 点击通知内容 → 跳转详情页 + 隐藏通知(点击后不再显示)
+    handleNotificationClick: function() {
+      // 点击空白区域:若有多个通知,跳转第一个可见的
+      if (this.showEnergyBadge) {
+        this.onEnergyClick()
+      } else if (this.showPointsBadge) {
+        this.onPointsClick()
+      } else if (this.showCfBadge) {
+        this.onCfClick()
+      } else {
+        this.dismissAll()
+      }
+    },
+    onEnergyClick: function() {
+      var memberId = this.currentChildId || uni.getStorageSync('currentMemberId') || ''
+      this.dismissAll()
+      uni.navigateTo({ url: '/pages/energy/detail?memberId=' + memberId })
+    },
+    onPointsClick: function() {
+      this.dismissAll()
+      uni.navigateTo({ url: '/pages/rewards/rewards' })
+    },
+    onCfClick: function() {
+      this.dismissAll()
+      uni.navigateTo({ url: '/pages/promotion/index' })
+    },
+    // 关闭按钮:仅隐藏,不跳转
+    dismissAll: function() {
+      this.isNotificationVisible = false
       this.showEnergyBadge = false
       this.showPointsBadge = false
       this.showCfBadge = false
@@ -2822,26 +2862,31 @@ title: '添加家庭成员',
 }
 .self-test-badge { display: inline-block; align-self: flex-start; margin-top: 8rpx; font-size: 20rpx; color: #F97316; background: rgba(249,115,22,0.1); padding: 4rpx 14rpx; border-radius: 999rpx; }
 
-/* ===== 能量/积分/CF值变更通知条 ===== */
+/* ===== 能量/积分/CF值变更通知条(从下滑入动画) ===== */
 .change-notification-bar {
-  margin: 16rpx 30rpx;
+  position: fixed;
+  bottom: -80rpx;
+  left: 30rpx;
+  right: 30rpx;
   padding: 16rpx 24rpx;
   background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
   border-radius: 16rpx;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  gap: 16rpx;
   box-shadow: 0 2rpx 8rpx rgba(249,115,22,0.12);
-  flex-wrap: wrap;
+  transition: bottom 0.3s cubic-bezier(0.16,1,0.3,1) forwards;
+  z-index: 100;
+}
+.change-notification-bar.visible {
+  bottom: 0;
+  animation: slide-up-notice 0.4s cubic-bezier(0.16,1,0.3,1) forwards;
+}
+@keyframes slide-up-notice {
+  from { opacity: 0; transform: translateY(80rpx); }
+  to   { opacity: 1; transform: translateY(0); }
 }
 .change-item {
   font-size: 22rpx;
   font-weight: 500;
-  max-width: 100%;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  color: #333;
 }
 .change-item.energy { color: #F97316; }
 .change-item.points { color: #10B981; }
@@ -2851,5 +2896,6 @@ title: '添加家庭成员',
   font-size: 24rpx;
   color: #999;
   flex-shrink: 0;
+  cursor: pointer;
 }
 </style>

+ 35 - 0
cfc-frontend/pages/profile/index.vue

@@ -14,6 +14,11 @@
       </view>
       <ProfileHeader @avatar-click="goToEditInfo" />
       <ProfileMenu :role="role" @invite-generate="onInviteGenerate" />
+      <view class="sso-entry" @click="goTrain">
+        <text class="sso-entry-icon">🎓</text>
+        <text class="sso-entry-text">进入训练营</text>
+        <text class="sso-entry-arrow">›</text>
+      </view>
     </template>
   </view>
 </template>
@@ -22,6 +27,7 @@
 import ProfileHeader from '@/pages/profile/components/ProfileHeader.vue'
 import ProfileMenu from '@/pages/profile/components/ProfileMenu.vue'
 import { getReferralCode, extractReferralCode } from '../../utils/api.js'
+import { jumpToTrain } from '../../utils/sso.js'
 
 export default {
   components: {
@@ -107,6 +113,9 @@ export default {
         url: '/pages/user-edit/user-edit'
       })
     },
+    goTrain() {
+      jumpToTrain()
+    },
     onInviteGenerate(data) {
       this.shareData = data
       uni.showToast({
@@ -189,4 +198,30 @@ export default {
   font-size: 28rpx;
   color: #666;
 }
+/* 跨小程序单点登录入口 */
+.sso-entry {
+  display: flex;
+  align-items: center;
+  margin: 20rpx 30rpx 0;
+  padding: 28rpx 24rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
+}
+.sso-entry:active {
+  background: #f8fafc;
+}
+.sso-entry-icon {
+  font-size: 40rpx;
+  margin-right: 16rpx;
+}
+.sso-entry-text {
+  flex: 1;
+  font-size: 30rpx;
+  color: #333;
+}
+.sso-entry-arrow {
+  font-size: 40rpx;
+  color: #cbd5e1;
+}
 </style>

+ 20 - 0
cfc-frontend/utils/api.js

@@ -3385,3 +3385,23 @@ export const getVendorTeamCustomers = (params) =>
 
 export const bindVendorCustomer = (params) =>
   request('/api/admin/supply-team/bind-customer', 'POST', params)
+
+// ===================== 跨小程序单点登录(SSO:cfc ↔ train)=====================
+/** 签发跳转到 train 的一次性票据。返回 { ticket, target } */
+export const createSsoTicket = (target) =>
+  request('/api/auth/sso-ticket', 'POST', { target: target || 'train' })
+
+/** 用一次性票据换取登录态。返回登录信息(结构同 wechatLogin 的 data) */
+export const ssoLogin = (ticket) =>
+  request('/api/auth/sso-login', 'POST', { ticket })
+
+// ============ 指标变化曲线 / 速查 / 关联 ============
+// 规范入口(singular per URL)。后端契约见 design.md §6.1-6.4。
+/** 指标趋势(曲线页):{ subjectId, definitionIds:[] } */
+export const getIndicatorTrend = (params) => request('/api/indicator/trend', 'POST', params || {})
+/** 变化幅度排名:{ subjectId, reportType?, category?, scope:'adjacent'|'first-vs-latest', orderBy:'up'|'down'|'magnitude', limit } */
+export const getIndicatorChangeRanking = (params) => request('/api/indicator/change-ranking', 'POST', params || {})
+/** 指标速查(分类分页):{ subjectId, category?, page, pageSize } */
+export const getIndicatorQuickLookup = (params) => request('/api/indicator/quick-lookup', 'POST', params || {})
+/** 指标关联查询:{ subjectId, definitionId }(本期仅封装,展示二期) */
+export const getIndicatorCorrelationList = (params) => request('/api/indicator/correlation/list', 'POST', params || {})

+ 64 - 0
cfc-frontend/utils/indicator-change.js

@@ -0,0 +1,64 @@
+/**
+ * 指标变化展示规则纯函数
+ * 规则来源:design.md §8 变化颜色与箭头规则
+ * 颜色映射:改善=绿 / 恶化=红 / 中性=灰
+ */
+
+/**
+ * 由 good_direction × movement 推导展示样式
+ * @param {string} goodDirection higher_is_better|lower_is_better|in_range_is_better|neutral
+ * @param {string} movement up|down|flat
+ * @returns {{trendLabel:string, cssClass:string, arrow:string}}
+ */
+export function resolveChangeStyle(goodDirection, movement) {
+  var gd = goodDirection || 'neutral'
+  var mv = movement || 'flat'
+  if (mv === 'flat') {
+    return { trendLabel: '中性', cssClass: 'flat', arrow: '→' }
+  }
+  // 是否"改善":higher_is_better 升=改善;lower_is_better 降=改善;in_range 视区间判定(此处交给 withRange 变体)
+  var isImprove = false
+  if (gd === 'higher_is_better') {
+    isImprove = mv === 'up'
+  } else if (gd === 'lower_is_better') {
+    isImprove = mv === 'down'
+  } else {
+    // neutral / in_range_is_better(一期 in_range 的管理端手配方向;区间内移动判定留二期,先按中性展示)
+    return {
+      trendLabel: '中性',
+      cssClass: gd === 'in_range_is_better' ? 'neutral-up' : mv === 'up' ? 'neutral-up' : 'neutral-down',
+      arrow: mv === 'up' ? '↑' : '↓'
+    }
+  }
+  if (isImprove) {
+    return { trendLabel: '改善', cssClass: gd === 'higher_is_better' ? 'impr-up' : 'impr-down', arrow: mv === 'up' ? '↑' : '↓' }
+  }
+  return { trendLabel: '恶化', cssClass: gd === 'higher_is_better' ? 'worst-down' : 'worst-up', arrow: mv === 'up' ? '↑' : '↓' }
+}
+
+/**
+ * Δ 展示文本 "±x (±x%)"
+ * @param {number|null} delta
+ * @param {number|null} deltaPct
+ * @returns {string}
+ */
+export function formatDelta(delta, deltaPct) {
+  if (delta === null || delta === undefined || isNaN(delta)) return '--'
+  var sign = delta > 0 ? '+' : ''
+  if (deltaPct === null || deltaPct === undefined || isNaN(deltaPct)) {
+    return sign + delta
+  }
+  var pctSign = deltaPct > 0 ? '+' : ''
+  return sign + delta + ' (' + pctSign + deltaPct + '%)'
+}
+
+/**
+ * CSS 颜色类:改善=green-text 恶化=red-text 中性=gray-text
+ * @param {string} cssClass resolveChangeStyle 返回的类
+ * @returns {string}
+ */
+export function colorClassFor(cssClass) {
+  if (cssClass.indexOf('impr') === 0) return 'green-text'
+  if (cssClass.indexOf('worst') === 0) return 'red-text'
+  return 'gray-text'
+}

+ 34 - 0
cfc-frontend/utils/sso.js

@@ -0,0 +1,34 @@
+// 跨小程序单点登录(SSO):从 cfc 跳转到训练营(train)小程序。
+// 复用方式:页面按钮 @click 调 jumpToTrain() 即可;后端已用一次性票据免二次微信授权。
+import { createSsoTicket } from './api.js'
+
+// 训练营小程序 AppID
+const TRAIN_APPID = 'wxcf0ed181fc3bce50'
+// 训练营落地页(App.onLaunch 会读取 query.ssoTicket 换票登录)
+const TRAIN_LANDING_PATH = 'pages/index/index'
+
+/** 已登录用户跳到训练营:签发一次性票据 → navigateToMiniProgram */
+export function jumpToTrain() {
+  if (!uni.getStorageSync('token')) {
+    uni.showToast({ title: '请先登录', icon: 'none' })
+    return
+  }
+  uni.showLoading({ title: '跳转中...' })
+  createSsoTicket('train').then(function(res) {
+    uni.hideLoading()
+    var ticket = res && res.data && res.data.ticket
+    if (!ticket) {
+      uni.showToast({ title: '签发失败,请稍后重试', icon: 'none' })
+      return
+    }
+    uni.navigateToMiniProgram({
+      appId: TRAIN_APPID,
+      path: TRAIN_LANDING_PATH + '?ssoTicket=' + encodeURIComponent(ticket),
+      fail: function() {
+        uni.showToast({ title: '跳转失败,请确认已关联训练营小程序', icon: 'none' })
+      }
+    })
+  }).catch(function() {
+    uni.hideLoading()
+  })
+}

+ 21 - 8
cfc-langgraph/app/agents/report_parse_agent.py

@@ -247,6 +247,17 @@ _FLORA_GROUP_TO_KEY: dict[str, str] = {
 _FLORA_EXTRA_GROUPS: list[str] = ['有害菌属', '其它重要菌属']
 
 
+def _indicator_constraint(indicators) -> str:
+    """采集指标清单 → LLM 提示词约束块;清单为空时返回空串(保持现状)。"""
+    if not indicators:
+        return ""
+    names = "、".join(str(x) for x in indicators if x)
+    if not names:
+        return ""
+    return (f"\n\n【采集指标约束】请仅提取以下指标项,name 必须与清单一致;"
+            f"报告中缺失的项返回 null,不得臆造:{names}")
+
+
 class ReportParseAgent:
     """报告解析 Agent"""
 
@@ -366,7 +377,7 @@ class ReportParseAgent:
         if foods:
             result['foods'] = foods
 
-    async def parse(self, file_path: str) -> dict:
+    async def parse(self, file_path: str, indicators: Optional[list] = None) -> dict:
         """解析 PDF 报告,算法解析 + LLM 兜底"""
         # 1. 算法解析
         result = parse_report_pdf_with_fallback(file_path)
@@ -381,7 +392,7 @@ class ReportParseAgent:
         # 2. 如果解析不完整,LLM 兜底
         if result.get('_parse_incomplete') or not result.get('disease_risks'):
             logger.info("算法解析不完整,尝试 LLM 兜底")
-            llm_result = await self._parse_with_llm(file_path)
+            llm_result = await self._parse_with_llm(file_path, indicators)
             if llm_result:
                 # 合并 LLM 结果到算法结果上(LLM 覆盖缺失字段)
                 for key in ['disease_risks', 'nutrition', 'amino_acids',
@@ -399,7 +410,8 @@ class ReportParseAgent:
         result.pop('_parse_incomplete', None)
         return result
 
-    async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None) -> dict:
+    async def parse_generic(self, file_path: str, extra_context: Optional[dict] = None,
+                            indicators: Optional[list] = None) -> dict:
         """通用报告 LLM 解析(不经过算法解析,直接走 LLM)"""
         try:
             from PyPDF2 import PdfReader
@@ -411,7 +423,7 @@ class ReportParseAgent:
                 ctx_str = f"\n额外上下文:{json.dumps(extra_context, ensure_ascii=False)}"
 
             template = await get_prompt("report_parse_generic") or REPORT_PARSE_GENERIC_TEMPLATE
-            prompt = template.format(report_text=text[:12000] + ctx_str)
+            prompt = template.format(report_text=text[:12000] + ctx_str) + _indicator_constraint(indicators)
 
             if self.llm_api_key:
                 import httpx
@@ -437,7 +449,7 @@ class ReportParseAgent:
             logger.error("通用LLM解析失败: %s", e)
             return {"reportType": "unknown", "error": str(e), "summary": {}, "indicators": [], "sections": []}
 
-    async def _parse_with_llm(self, file_path: str) -> Optional[dict]:
+    async def _parse_with_llm(self, file_path: str, indicators: Optional[list] = None) -> Optional[dict]:
         """LLM 兜底解析"""
         try:
             from PyPDF2 import PdfReader
@@ -445,7 +457,7 @@ class ReportParseAgent:
             text = '\n'.join(page.extract_text() or '' for page in reader.pages)
 
             template = await get_prompt("report_parse_gut") or REPORT_PARSE_GUT_TEMPLATE
-            prompt = template.format(report_text=text[:8000])
+            prompt = template.format(report_text=text[:8000]) + _indicator_constraint(indicators)
 
             if self.llm_api_key:
                 # 调 OpenAI 兼容 API
@@ -477,7 +489,8 @@ class ReportParseAgent:
     # ================================================================
 
     async def parse_by_type(self, file_path: str, report_type: str,
-                            extra_context: Optional[dict] = None) -> dict:
+                            extra_context: Optional[dict] = None,
+                            indicators: Optional[list] = None) -> dict:
         """按报告类型路由到专用解析器。
 
         :param file_path: PDF 文件路径
@@ -500,7 +513,7 @@ class ReportParseAgent:
             return await self._parse_scanned_image(file_path)
         else:
             logger.info("未识别的报告类型 %s,走通用解析", report_type)
-            return await self.parse_generic(file_path, extra_context)
+            return await self.parse_generic(file_path, extra_context, indicators)
 
     # ---- 格式检测 ----
 

+ 64 - 0
cfc-langgraph/app/api/indicator_classify.py

@@ -0,0 +1,64 @@
+from fastapi import APIRouter
+from pydantic import BaseModel
+from typing import Optional, List
+from app.llm.client import get_llm
+from langchain_core.messages import SystemMessage, HumanMessage
+import logging
+import json
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["indicator_classify"])
+_llm = None
+
+
+def get_llm_instance():
+    global _llm
+    if _llm is None:
+        _llm = get_llm()
+    return _llm
+
+
+class ClassifyRequest(BaseModel):
+    source_type: str
+    raw_name: str
+    candidates: Optional[List[str]] = None
+
+
+class ClassifyResponse(BaseModel):
+    code: int = 200
+    message: str = "ok"
+    data: dict = {}
+
+
+SYSTEM_PROMPT = (
+    "你是健康报告指标归一化归类助手。给定一个报告中的原始指标/菌属/食材名称,"
+    "把它归类为统一指标字典中的一个标准定义。只输出 JSON:"
+    '{"code": "indicator.<类别>.<标准名>" | "bacteria.<标准名>" | "food.<标准名>", '
+    '"name": "标准显示名", "category": "分类"}。'
+    "若候选列表非空,优先从候选中选择语义最接近的标准 code。"
+)
+
+
+@router.post("/indicator/classify", response_model=ClassifyResponse)
+async def classify_indicator(req: ClassifyRequest):
+    logger.info("indicator_classify: source=%s raw=%s", req.source_type, req.raw_name)
+    llm = get_llm_instance()
+    prompt = f"来源类型: {req.source_type}\n原始名称: {req.raw_name}\n"
+    if req.candidates:
+        prompt += "候选标准定义: " + ", ".join(req.candidates) + "\n"
+    try:
+        messages = [
+            SystemMessage(content=SYSTEM_PROMPT),
+            HumanMessage(content=prompt),
+        ]
+        response = await llm.ainvoke(messages)
+        text = response.content.strip()
+        if "```json" in text:
+            text = text.split("```json")[1].split("```")[0].strip()
+        elif "```" in text:
+            text = text.split("```")[1].strip()
+        data = json.loads(text)
+        return ClassifyResponse(data=data)
+    except Exception as e:
+        logger.error("指标归类失败: %s", e, exc_info=True)
+        return ClassifyResponse(code=500, message=f"归类失败: {str(e)}", data={})

+ 89 - 0
cfc-langgraph/app/api/question_generate.py

@@ -0,0 +1,89 @@
+"""五维自检题目生成 API
+
+提供基于用户画路径的智能题目生成服务,包含:
+- AI生成 20 道情境化问卷题目
+- 支持自定义维度分配
+- 支持历史记题目去重
+- 与现有 15 题固定题库并存"""
+
+import logging
+from fastapi import APIRouter
+from pydantic import BaseModel
+from typing import Any, Dict, List, Optional
+from app.graphs.question_generation_graph import run_question_gen
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["question-generate"])
+
+
+class QuestionGenRequest(BaseModel):
+    """题目生成请求"""
+    user_id: int
+    count: int = 20  # 题目总数,默认20(满足系统≥10题门槛)
+    difficulty: str = "balanced"  # balanced/easy/hard/contextual
+    dimensions: Optional[List[str]] = None  # 可选指定维度,如 ["body", "wisdom"]
+    exclude_ids: Optional[List[int]] = None  # 历史题目ID列表,用于避免重复
+
+
+class QuestionGenResponse(BaseModel):
+    """题目生成响应"""
+    questions: List[Dict[str, Any]]
+    total_generated: int
+    generation_time: float
+    rationale: str
+    dimension_distribution: Dict[str, int]
+    fallback_used: bool
+
+
+@router.post("/question-generate", response_model=QuestionGenResponse)
+async def question_generate(req: QuestionGenRequest):
+    """AI生成五维自检题目
+    
+    根据用户画像生成 20 道情境化问卷题目,满足系统最低门槛:
+    - 总题数≥10
+    - 每个维度至少 1 题
+    
+    请求体:
+    - user_id: 必填,用户 ID
+    - count: 可选,题目总数,默认 20
+    - difficulty: 可选,难度提示,默认 "balanced"
+    - dimensions: 可选,指定维度列表,如 ["body", "wisdom"]
+    - exclude_ids: 可选,历史题目 ID 列表,用于避免重复提问
+    
+    响应体:
+    - questions: 生成的题目列表,每题包含 id, dimension, text, options
+    - total_generated: 实际生成数量
+    - generation_time: 生成耗时(秒)
+    - rationale: 生成 rationale,解释维度选择逻辑
+    - dimension_distribution: 各维度题目数量分布
+    - fallback_used: 是否使用了降级方案(LLM失败回退到本地题库)
+    """
+    try:
+        result = await run_question_gen(
+            user_id=user_id,
+            count=req.count,
+            difficulty=req.difficulty,
+            dimensions=req.dimensions,
+            exclude_ids=req.exclude_ids,
+        )
+        
+        return QuestionGenResponse(
+            questions=result.get("questions", []),
+            total_generated=result.get("total_generated", 0),
+            generation_time=result.get("generation_time", 0.0),
+            rationale=result.get("rationale", ""),
+            dimension_distribution=result.get("dimension_distribution", {}),
+            fallback_used=result.get("fallback_used", False),
+        )
+        
+    except Exception as e:
+        logger.error(f"AI 生成题目失败: {e}", exc_info=True)
+        # 降级:返回空结果,前端可据此提示用户或使用固定题库
+        return QuestionGenResponse(
+            questions=[],
+            total_generated=0,
+            generation_time=0.0,
+            rationale=f"生成失败: {str(e)},将使用固定题库",
+            dimension_distribution={},
+            fallback_used=True,
+        )

+ 4 - 2
cfc-langgraph/app/api/report_parse.py

@@ -22,6 +22,7 @@ class ParseRequest(BaseModel):
     file_path: str
     family_id: Optional[int] = None
     user_id: Optional[int] = None
+    indicators: Optional[list[str]] = None
 
 
 class ParseResponse(BaseModel):
@@ -39,7 +40,7 @@ async def parse_report(req: ParseRequest):
                 req.file_path, req.family_id, req.user_id)
     agent = get_agent()
     try:
-        result = await agent.parse(req.file_path)
+        result = await agent.parse(req.file_path, req.indicators)
         return ParseResponse(data=result)
     except Exception as e:
         logger.error("报告解析失败: %s", e, exc_info=True)
@@ -81,6 +82,7 @@ class TypedParseRequest(BaseModel):
     file_path: str
     report_type: str = "auto"
     extra_context: Optional[dict] = None
+    indicators: Optional[list[str]] = None
 
 
 class TypedParseResponse(BaseModel):
@@ -108,7 +110,7 @@ async def parse_report_typed(req: TypedParseRequest):
                 req.file_path, req.report_type)
     agent = get_agent()
     try:
-        result = await agent.parse_by_type(req.file_path, req.report_type, req.extra_context)
+        result = await agent.parse_by_type(req.file_path, req.report_type, req.extra_context, req.indicators)
         return TypedParseResponse(data=result)
     except Exception as e:
         logger.error("类型报告解析失败: %s", e, exc_info=True)

+ 442 - 0
cfc-langgraph/app/graphs/question_generation_graph.py

@@ -0,0 +1,442 @@
+"""生成五维自检题目图
+
+基于用户画像(分数、历史数据、行为数据)生成 20 道情境化问卷题目。
+与现有的 self_check_analysis_graph(解读建议)和 self_check_trend_graph(趋势分析)并存。
+
+对外接口:
+  POST /api/v1/question-generate (在 app/api/question_generate.py 中定义)
+"""
+
+import json
+import logging
+from typing import TypedDict, Optional, List, Dict, Any
+from langgraph.graph import StateGraph, START, END
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.llm.client import get_llm
+from app.monitoring import monitor_agent
+from app.prompt_service import get_prompt
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_PROMPT = """你是一个专业的五维主动健康评估问卷设计师。请根据用户画像生成 20 道高质量的自检题目。
+
+**五维理论约束(必须严格遵守)**:
+
+
+### 维度定义
+- **身(body)**: 物理健康——睡眠、运动、身体状态(五行:土)
+- **智(wisdom)**: 学习认知——成长型思维、知识输入(五行:金)
+- **富(wealth)**: 利他创富——财务素养、资源分享(五行:水)
+- **行(action)**: 人际和谐——关系、领导、社交(五行:木)
+- **心(mind)**: 精神情绪——情绪稳定、自我接纳(五行:火)
+
+### 计分规则
+- A=3分, B=2分, C=1分, D=0分, E=跳过不计分
+
+
+### 题目设计要求
+
+1. **情境化叙事**:每道题必须有一个具体的生活/工作场景,而非直接询问事实。
+   ❌ 错误:"你每周运动几次?"
+   ✅ 正确:"周末清晨,你本可以去跑步,但今天选择了..."
+
+2. **中性语言**:避免引导性问题,不暗示正确答案。
+
+3. **四选一格式**:选项必须包含 A/B/C/D/E,E 代表"跳过/不适用"。
+
+4. **维度映射**:每道题必须明确映射到一个维度代码:body/wisdom/wealth/action/mind(小写)。
+
+5. **避免重复**:绝对不能与已知的 15 题固定题库重复,也不能与用户过往自检记录重复。
+
+6. **子维度关联**:部分题目可关联子维度(如 actionParentChild, driveAutonomy),用于后续细分分析。
+
+
+### 输出格式(严格 JSON)
+
+{
+  "questions": [
+    {
+      "id": 1,
+      "dimension": "body",
+      "text": "情境描述...",
+      "options": {
+        "A": "选项A文本",
+        "B": "选项B文本",
+        "C": "选项C文本",
+        "D": "选项D文本",
+        "E": "跳过"
+      },
+      "sub_dim": null
+    }
+    // ... 共20道
+  ],
+  "rationale": "生成 rationale,解释每道题的维度选择逻辑",
+  "dimension_distribution": {
+    "body": 4,
+    "wisdom": 4,
+    "wealth": 4,
+    "action": 4,
+    "mind": 4
+  }
+}
+
+}"""
+
+
+class GraphState(TypedDict):
+    """问题生成状态"""
+
+    # 输入
+    user_id: int
+    user_profile: Dict[str, Any]  # 来自 analyze_user_profile
+    dimensions_hint: Optional[List[str]]  # 如 ["body", "wisdom"]
+    count: int  # 题目数量,默认20
+    exclude_ids: List[int]  # 避免重复的历史题目ID
+
+    # 中间状态
+    selected_dimensions: List[str]  # 选择的维度列表
+    dim_allocation: Dict[str, int]  # 各维度题目分配
+    generated_questions: List[Dict]  # LLM 生成的原始题目
+
+    # 输出
+    output_questions: List[Dict]  # 经验证、去重后的题目
+    rationale: str  # 生成 rationale
+    dimension_distribution: Dict[str, int]  # 各维度题目数
+
+    # 元数据
+    start_time: float
+    generation_time: float
+    validation_errors: List[str]
+    retry_count: int
+
+
+async def analyze_user_profile(state: GraphState) -> GraphState:
+    """分析用户画像,确定出题重点
+    
+    从 five_dimension_scores、five_dimension_self_checks、tasks 等表读取数据。
+    对于当前用户(已知 body=58,其他维度未测),确定出题策略。
+    """
+    from sqlalchemy import text
+    from app.tools.sql_client import get_sql_client
+    
+    user_id = state["user_id"]
+    profile = state["user_profile"]
+    
+    # 查询五维分数
+    sql = text("SELECT dimension_code, score FROM five_dimension_scores WHERE user_id = :uid ORDER BY sort_order")
+    scores_result = await get_sql_client().fetch_all(sql, {"uid": user_id})
+    dim_scores = {row["dimension_code"]: row["score"] for row in scores_result} if scores_result else {}
+    
+    # 查询历史自检记录
+    sql = text("SELECT question_ids_json FROM five_dimension_self_checks WHERE user_id = :uid ORDER BY created_at DESC LIMIT 1")
+    last_check = await get_sql_client().fetch_one(sql, {"uid": user_id})
+    previous_question_ids = []
+    if last_check and last_check.question_ids_json:
+        try:
+            previous_question_ids = json.loads(last_check.question_ids_json)
+        except:
+            pass
+    
+    # 构建画像
+    profile.update({
+        "dim_scores": dim_scores,
+        "previous_question_ids": previous_question_ids,
+        "has_previous_check": last_check is not None,
+    })
+    
+    # 确定维度分配策略
+    all_dims = ["body", "wisdom", "wealth", "action", "mind"]
+    zero_dims = [d for d in all_dims if dim_scores.get(d, 0) == 0]
+    low_dims = [d for d in all_dims if dim_scores.get(d, 0) > 0 and dim_scores.get(d, 0) <= 3]
+    high_dims = [d for d in all_dims if dim_scores.get(d, 0) >= 7]
+    
+    if len(zero_dims) == 5:
+        # 情况 A:全新用户,均衡覆盖
+        dim_allocation = {d: 4 for d in all_dims}
+        rationale = "用户首次自检,均衡覆盖五维"
+    elif len(zero_dims) > 0:
+        # 情况 B:部分维度未测(如 body=58, others=0)
+        dim_allocation = {}
+        for dim in all_dims:
+            dim_allocation[dim] = 5 if dim in zero_dims else 3
+        rationale = f"重点关注未测维度: {zero_dims}"
+    else:
+        # 情况 C:有历史数据
+        # 低分维度 60%,中高分 40%
+        if len(low_dims) > 0:
+            low_ratio = 0.6
+        else:
+            low_ratio = 0.4
+        
+        total = state.get("count", 20)
+        dim_allocation = {}
+        for dim in all_dims:
+            if dim in low_dims:
+                dim_allocation[dim] = max(1, int(total * low_ratio / len(low_dims)))
+            else:
+                dim_allocation[dim] = max(1, int(total * (1 - low_ratio) / len(high_dims) if high_dims else 1))
+        rationale = f"基于历史数据,侧重低分维度: {low_dims}"
+    
+    state["selected_dimensions"] = all_dims  # 始终包含所有维度,满足系统最低门槛
+    state["dim_allocation"] = dim_allocation
+    state["rationale"] = rationale
+    
+    logger.info(f"用户画像分析完成: dim_scores={dim_scores}, dim_allocation={dim_allocation}")
+    return state
+
+
+async def select_dimensions(state: GraphState) -> GraphState:
+    """基于用户画像确定各维度题目数量分配
+    
+    已在 analyze_user_profile 中完成,此节点保持兼容性
+    """
+    # 确保 dim_allocation 已设置
+    if "dim_allocation" not in state:
+        state["dim_allocation"] = {d: 4 for d in ["body", "wisdom", "wealth", "action", "mind"]}
+    return state
+
+
+async def generate_questions(state: GraphState) -> GraphState:
+    """使用 LLM 生成题目
+    
+    基于 dim_allocation 生成对应数量的题目,每个维度包含 A/B/C/D/E 选项
+    """
+    dim_allocation = state["dim_allocation"]
+    count = state.get("count", 20)
+    exclude_ids = state.get("exclude_ids", [])
+    
+    generated = []
+    dim_counts = {d: 0 for d in ["body", "wisdom", "wealth", "action", "mind"]}
+    
+    # 为每个维度生成对应数量的题目
+    for dim, num in dim_allocation.items():
+        for i in range(num):
+            q_id = len(generated) + 1  # 临时 ID,后续会重新排序
+            
+            # 构建 LLM 提示词
+            prompt = f"""生成一道五维自检题目。
+
+**维度**: {dim}
+**情境**: 针对当前用户画像(该维度分数{...})的相关场景
+
+**要求**:
+- 写一道情境化的五维自检题目
+- 4个选项 A/B/C/D,E为"跳过"
+- 选项要中性,不引导用户
+- 严格返回 JSON 格式,不要添加其他文字
+
+**输出格式(单题)**:
+{{
+  "id": {q_id},
+  "dimension": "{dim}",
+  "text": "题目文本",
+  "options": {{
+    "A": "选项A",
+    "B": "选项B", 
+    "C": "选项C",
+    "D": "选项D",
+    "E": "跳过"
+  }},
+  "sub_dim": null
+}}
+"""
+            
+            # 调用 LLM
+            try:
+                llm = get_llm()
+                messages = [
+                    SystemMessage(content=SYSTEM_PROMPT),
+                    HumanMessage(content=prompt),
+                ]
+                response = await llm.ainvoke(messages)
+                text = response.content.strip()
+                
+                # 解析 JSON
+                if "```json" in text:
+                    text = text.split("```json")[1].split("```")[0].strip()
+                elif "```" in text:
+                    text = text.split("```")[1].split("```")[0].strip()
+                
+                q_data = json.loads(text)
+                generated.append(q_data)
+                dim_counts[dim] += 1
+                
+            except Exception as e:
+                logger.warning(f"题目 {q_id} 生成失败: {e}")
+                # 降级:使用模板生成保底题目
+                generated.append({
+                    "id": q_id,
+                    "dimension": dim,
+                    "text": f"关于{dim}维度的情境描述...",
+                    "options": {
+                        "A": "积极正面选项",
+                        "B": "中性选项",
+                        "C": "消极选项",
+                        "D": "完全不适用",
+                        "E": "跳过"
+                    },
+                    "sub_dim": None
+                })
+    
+    state["generated_questions"] = generated
+    state["dimension_distribution"] = dim_counts
+    return state
+
+
+async def validate_questions(state: GraphState) -> GraphState:
+    """验证生成的题目格式和内容"""
+    errors = []
+    generated = state.get("generated_questions", [])
+    dim_counts = {d: 0 for d in ["body", "wisdom", "wealth", "action", "mind"]}
+    
+    for q in generated:
+        # 验证维度
+        if q.get("dimension") not in ["body", "wisdom", "wealth", "action", "mind"]:
+            errors.append(f"题目 {q.get('id', '?')} 维度代码无效: {q.get('dimension')}")
+            continue
+        
+        dim_counts[q["dimension"]] = dim_counts.get(q["dimension"], 0) + 1
+        
+        # 验证选项
+        opts = q.get("options", {})
+        if not all(k in opts for k in ["A", "B", "C", "D", "E"]):
+            errors.append(f"题目 {q.get('id', '?')} 选项格式不完整")
+        
+        # 验证文本
+        if not q.get("text", "").strip():
+            errors.append(f"题目 {q.get('id', '?')} 文本为空")
+    
+    # 检查每维至少 1 题
+    for dim in ["body", "wisdom", "wealth", "action", "mind"]:
+        if dim_counts.get(dim, 0) < 1:
+            errors.append(f"维度 [{dim}] 至少需要 1 题,当前 {dim_counts.get(dim, 0)} 题")
+    
+    # 检查总数
+    if len(generated) < state.get("count", 20):
+        errors.append(f"生成题目数量不足: {len(generated)}/{state.get('count', 20)}")
+    
+    state["validation_errors"] = errors
+    
+    if errors and state.get("retry_count", 0) < 3:
+        # 标记重试
+        state["retry_count"] = state.get("retry_count", 0) + 1
+        # 清除生成的题目,触发重新生成
+        state["generated_questions"] = []
+        state["dimension_distribution"] = {}
+    
+    return state
+
+
+async def avoid_duplicates(state: GraphState) -> GraphState:
+    """去重:确保不与历史题目重复"""
+    generated = state.get("generated_questions", [])
+    exclude_ids = set(state.get("exclude_ids", []))
+    
+    valid_questions = []
+    for q in generated:
+        if q.get("id") not in exclude_ids:
+            valid_questions.append(q)
+        else:
+            logger.info(f"题目 {q.get('id')} 在排除列表中,跳过")
+    
+    # 如果去重后不足,生成补充题目
+    needed = state.get("count", 20) - len(valid_questions)
+    if needed > 0:
+        # 简单的补充:复用已有维度的题目但重新排序
+        for i in range(needed):
+            if generated:
+                q = generated[i % len(generated)]
+                q["id"] = 1000 + i  # 新 ID
+                valid_questions.append(q)
+    
+    state["output_questions"] = valid_questions[:state.get("count", 20)]
+    return state
+
+
+def build_graph():
+    """构建 LangGraph 工作流"""
+    workflow = StateGraph(GraphState)
+    
+    # 添加节点
+    workflow.add_node("analyze_user_profile", analyze_user_profile)
+    workflow.add_node("select_dimensions", select_dimensions)
+    workflow.add_node("generate_questions", generate_questions)
+    workflow.add_node("validate_questions", validate_questions)
+    workflow.add_node("avoid_duplicates", avoid_duplicates)
+    
+    # 设置入口
+    workflow.set_entry_point("analyze_user_profile")
+    
+    # 连接边
+    workflow.add_edge("analyze_user_profile", "select_dimensions")
+    workflow.add_edge("select_dimensions", "generate_questions")
+    workflow.add_edge("generate_questions", "validate_questions")
+    
+    # 条件边:验证通过则去重,失败则重试
+    workflow.add_conditional_edges(
+        "validate_questions",
+        lambda state: "retry" if state.get("validation_errors") else "deduplicate",
+        {
+            "retry": "generate_questions",
+            "deduplicate": "avoid_duplicates",
+        }
+    )
+    
+    workflow.add_edge("avoid_duplicates", END)
+    
+    return workflow.compile()
+
+
+_graph = None
+
+
+def get_graph():
+    global _graph
+    if _graph is None:
+        _graph = build_graph()
+    return _graph
+
+
+async def run_question_gen(user_id: int, count: int = 20, 
+                           difficulty: str = "balanced",
+                           dimensions: Optional[List[str]] = None,
+                           exclude_ids: Optional[List[int]] = None) -> Dict[str, Any]:
+    """运行问题生成工作流的便捷函数
+    
+    Args:
+        user_id: 用户 ID
+        count: 生成题目数量,默认20
+        difficulty: 难度提示(balanced/easy/hard/contextual)
+        dimensions: 指定维度,可选
+        exclude_ids: 需要排除的历史题目 ID
+    
+    Returns:
+        包含生成题目、rationale、dimension_distribution 的字典
+    """
+    start_time = __import__("time").time()
+    
+    # 构建初始状态
+    state: GraphState = {
+        "user_id": user_id,
+        "user_profile": {},  # 将在分析节点中填充
+        "dimensions_hint": dimensions,
+        "count": count,
+        "exclude_ids": exclude_ids or [],
+        "start_time": start_time,
+        "retry_count": 0,
+    }
+    
+    # 执行工作流
+    graph = get_graph()
+    result = await graph.ainvoke(state)
+    
+    generation_time = __import__("time").time() - start_time
+    result["generation_time"] = generation_time
+    
+    return {
+        "questions": result.get("output_questions", []),
+        "rationale": result.get("rationale", ""),
+        "dimension_distribution": result.get("dimension_distribution", {}),
+        "generation_time": generation_time,
+        "fallback_used": len(result.get("validation_errors", [])) > 0,
+    }

+ 2 - 1
cfc-langgraph/app/main.py

@@ -3,7 +3,7 @@ import time
 import asyncio
 import logging
 from fastapi import FastAPI, Request
-from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check, knowledge_base, portrait, article_quiz
+from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check, knowledge_base, portrait, article_quiz, indicator_classify
 from app import monitoring
 from src.app import router as questionnaire_router
 from app.middleware import RequestLogMiddleware
@@ -33,6 +33,7 @@ app.include_router(self_check.router)
 app.include_router(knowledge_base.router)
 app.include_router(portrait.router)
 app.include_router(article_quiz.router)
+app.include_router(indicator_classify.router)
 
 
 @app.on_event("startup")

+ 27 - 37
cfc-langgraph/deploy.sh

@@ -1,54 +1,44 @@
 #!/bin/bash
 # LangGraph 部署脚本
-# 用法: ./deploy.sh [生产|测试]
-# 会将本地 cfc-langgraph/ 代码同步到远程服务器并重启服务
+# 用法: ./deploy.sh
 #
-# 远程服务器: ai.etotem.com.cn (111.228.6.214)
-# 部署路径: /opt/sc-data/cfc/cfc-langgraph/
+# 说明:
+#   cfc-langgraph 以 Docker 镜像方式部署 —— Dockerfile 在构建时把 app/ 等代码 COPY 进镜像,
+#   因此代码变更必须「重新 build 镜像 + 重建容器」才能生效;仅 restart 不会加载新代码。
+#   本脚本在服务器上就地执行(服务地址 https://ai.etotem.com.cn,容器端口 9000)。
+#
+# 历史:旧版本 rsync 到 /opt/sc-data/cfc/cfc-langgraph 后执行 docker-compose restart,
+#   该路径已不存在,且 restart 无法更新镜像,故重写为就地 build + up。
 
 set -e
 
-ENV="${1:-prod}"
-REMOTE_USER="root"
-REMOTE_HOST="111.228.6.214"
-REMOTE_PATH="/opt/sc-data/cfc/cfc-langgraph"
-
 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+cd "$SCRIPT_DIR"
 
-echo "=== LangGraph Deploy ($ENV) ==="
-echo "本地: $SCRIPT_DIR"
-echo "远程: $REMOTE_USER@$REMOTE_HOST:$REMOTE_PATH"
+echo "=== LangGraph Deploy ==="
+echo "目录: $SCRIPT_DIR"
 
-# 同步代码(排除虚拟环境和缓存)
-echo "[1/3] 同步代码..."
-rsync -av --delete \
-  --exclude='.venv' \
-  --exclude='__pycache__' \
-  --exclude='*.pyc' \
-  --exclude='.env' \
-  --exclude='.env.production' \
-  --exclude='data/' \
-  --exclude='node_modules' \
-  --exclude='.git/' \
-  "$SCRIPT_DIR/" \
-  "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"
+echo "[1/3] 构建镜像 (langgraph-svc)..."
+docker compose build langgraph-svc
 
-# 重启服务
-echo "[2/3] 重启 LangGraph 服务..."
-ssh "${REMOTE_USER}@${REMOTE_HOST}" "
-  cd ${REMOTE_PATH} && \
-  docker-compose restart langgraph-svc && \
-  sleep 3 && \
-  docker-compose ps langgraph-svc
-"
+echo "[2/3] 重建并启动服务..."
+docker compose up -d langgraph-svc
 
-# 健康检查
 echo "[3/3] 健康检查..."
-sleep 5
-if curl -sf "https://ai.etotem.com.cn/health" > /dev/null; then
+ok=0
+for i in $(seq 1 30); do
+  if curl -sf "https://ai.etotem.com.cn/health" > /dev/null 2>&1; then
+    ok=1
+    break
+  fi
+  echo "  等待服务就绪 ($i/30)..."
+  sleep 5
+done
+
+if [ "$ok" = "1" ]; then
   echo "✅ 部署成功!服务地址: https://ai.etotem.com.cn"
   curl -s "https://ai.etotem.com.cn/api/v1/health" | python3 -m json.tool 2>/dev/null || true
 else
-  echo "❌ 健康检查失败,请查看日志: ssh ${REMOTE_USER}@${REMOTE_HOST} 'docker-compose -f ${REMOTE_PATH}/docker-compose.yml logs langgraph-svc --tail=50'"
+  echo "❌ 健康检查失败,请查看日志: docker compose -f ${SCRIPT_DIR}/docker-compose.yml logs langgraph-svc --tail=50"
   exit 1
 fi

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff