Pārlūkot izejas kodu

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

Sisyphus 2 dienas atpakaļ
vecāks
revīzija
37f49e5b98
71 mainītis faili ar 11320 papildinājumiem un 96 dzēšanām
  1. 162 5
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 118 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java
  3. 95 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java
  4. 74 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java
  5. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ReportParserAdminController.java
  6. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ParsedReportPayload.java
  7. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ReportSummaryVO.java
  8. 35 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java
  9. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java
  10. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java
  11. 18 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java
  12. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java
  13. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java
  14. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java
  15. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java
  16. 196 1
      cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
  17. 31 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  18. 13 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java
  19. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java
  20. 267 0
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java
  21. 275 0
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java
  22. 370 0
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java
  23. 22 4
      cfc-backend/src/main/java/com/etotem/cfc/service/LangGraphReportParseService.java
  24. 130 0
      cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java
  25. 16 3
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java
  26. 146 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java
  27. 52 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ReportSummaryService.java
  28. 87 0
      cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java
  29. 9 1
      cfc-backend/src/main/resources/application.yml
  30. 57 1
      cfc-backend/src/main/resources/schema.sql
  31. 33 0
      cfc-backend/src/test/java/com/etotem/cfc/config/DatabaseInitializerSeedTest.java
  32. 68 0
      cfc-backend/src/test/java/com/etotem/cfc/controller/admin/ReportParserAdminControllerTest.java
  33. 9 9
      cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ActivityControllerTest.java
  34. 57 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.java
  35. 93 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java
  36. 100 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java
  37. 30 0
      cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java
  38. 86 0
      cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.java
  39. 92 0
      cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java
  40. 67 0
      cfc-backend/src/test/java/com/etotem/cfc/service/ReportSummaryServiceCompletenessTest.java
  41. 60 0
      cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java
  42. 75 0
      cfc-frontend/__tests__/api/api.indicator.test.js
  43. 64 0
      cfc-frontend/__tests__/utils/indicator-change.test.js
  44. 193 21
      cfc-frontend/components/ContentSharePoster.vue
  45. 302 0
      cfc-frontend/components/TrendChart.vue
  46. 12 0
      cfc-frontend/pages.json
  47. 20 1
      cfc-frontend/pages/activity/activity-detail/activity-detail.vue
  48. 444 0
      cfc-frontend/pages/health/indicator-curve.vue
  49. 75 29
      cfc-frontend/pages/index-home/index.vue
  50. 11 0
      cfc-frontend/utils/api.js
  51. 64 0
      cfc-frontend/utils/indicator-change.js
  52. 21 8
      cfc-langgraph/app/agents/report_parse_agent.py
  53. 64 0
      cfc-langgraph/app/api/indicator_classify.py
  54. 4 2
      cfc-langgraph/app/api/report_parse.py
  55. 2 1
      cfc-langgraph/app/main.py
  56. 24 1
      cfc-langgraph/tests/test_report_parse.py
  57. 1 1
      cfc-web/.last_build_commit
  58. 2 2
      cfc-web/package-lock.json
  59. 1 1
      cfc-web/package.json
  60. 688 0
      cfc-web/public/CHANGELOG-v1.0.md
  61. 689 1
      cfc-web/public/CHANGELOG.md
  62. 3 0
      cfc-web/src/router/index.js
  63. 25 0
      cfc-web/src/views/admin/ReportManagement.vue
  64. 50 1
      cfc-web/src/views/admin/ReportTypeManagement.vue
  65. 8 3
      docs/superpowers/PROJECT-OVERVIEW.md
  66. 89 0
      docs/superpowers/api/API_REFERENCE.md
  67. 3174 0
      docs/superpowers/plans/2026-10-09-indicator-change-curve-backend.md
  68. 1238 0
      docs/superpowers/plans/2026-10-09-indicator-change-curve-miniapp.md
  69. 320 0
      docs/superpowers/plans/2026-10-09-indicator-change-curve-webadmin.md
  70. 200 0
      docs/superpowers/specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md
  71. 454 0
      docs/superpowers/specs/2026-10-09-indicator-change-curve-design.md

+ 162 - 5
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2984,11 +2984,20 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         runSystemMigrations();
         registerCollectExpectedIndicators();
 
-        // 减脂专题表创建
+        // 减脂专题表创建(远程 fatloss 功能)
         createFatWeightRecordTable();
         createFatDietRecordTable();
         createFatExerciseRecordTable();
         createFatGoalTable();
+
+        // 指标统一层迁移(356~362)
+        migrateIndicatorDefinitionsColumns();
+        migrateIndicatorValuesColumns();
+        addIndicatorValuesKeys();
+        createIndicatorMappingRulesTable();
+        createIndicatorCorrelationsTable();
+        ensureIndicatorCollectionDate();
+        seedDimensionDefinitions();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -11966,14 +11975,15 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
-    /** 写入类型「应采集指标项」定义(每次启动覆盖,保证与代码一致) */
-    private void seedExpectedIndicator(String typeId, String expectedJson) {
+    /** 写入类型「应采集指标项」定义(仅当为空时写入,保留管理员编辑) */
+    void seedExpectedIndicator(String typeId, String expectedJson) {
         try {
             int updated = jdbcTemplate.update(
-                    "UPDATE report_type_registry SET expected_indicators = ?, updated_at = NOW() WHERE type_id = ?",
+                    "UPDATE report_type_registry SET expected_indicators = ?, updated_at = NOW() " +
+                    "WHERE type_id = ? AND (expected_indicators IS NULL OR expected_indicators = '')",
                     expectedJson, typeId);
             if (updated == 0) {
-                log.warn("迁移354: 类型 {} 不存在,跳过指标项定义", typeId);
+                log.info("迁移354: 类型 {} 已有指标项定义,跳过种子", typeId);
             } else {
                 log.info("迁移354: 已为类型 {} 写入指标项定义", typeId);
             }
@@ -12021,6 +12031,153 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
+    // 迁移356: indicator_definitions 升级为统一指标层(指标变化曲线/速查/关联 需求)
+    private void migrateIndicatorDefinitionsColumns() {
+        ensureColumn("indicator_definitions", "source_kind",
+                "VARCHAR(32) DEFAULT NULL COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score'");
+        ensureColumn("indicator_definitions", "report_type_scope",
+                "VARCHAR(32) DEFAULT NULL COMMENT '适用报告类型(type_id,空=通用)'");
+        ensureColumn("indicator_definitions", "value_type",
+                "VARCHAR(16) DEFAULT NULL COMMENT '语义类型: numeric/categorical/level'");
+        ensureColumn("indicator_definitions", "good_direction",
+                "VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral'");
+        ensureColumn("indicator_definitions", "dimension_code",
+                "VARCHAR(16) DEFAULT NULL COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)'");
+        log.info("迁移356: indicator_definitions 已补齐统一指标层 5 列");
+    }
+
+    // 迁移357: indicator_values 升级为统一观测时序(subject_id/family_id/report_id/report_date/unit/status)
+    private void migrateIndicatorValuesColumns() {
+        ensureColumn("indicator_values", "subject_id", "BIGINT DEFAULT NULL COMMENT '观测对象(family_members.id)'");
+        ensureColumn("indicator_values", "family_id", "BIGINT DEFAULT NULL COMMENT '所属家庭(family_members.family_id)'");
+        ensureColumn("indicator_values", "report_id", "BIGINT DEFAULT NULL COMMENT '来源报告(health_reports.id,食物指数可空)'");
+        ensureColumn("indicator_values", "report_date", "DATE DEFAULT NULL COMMENT '冗余真实报告日期(时序查询键)'");
+        ensureColumn("indicator_values", "unit", "VARCHAR(32) DEFAULT NULL COMMENT '观测单位'");
+        ensureColumn("indicator_values", "status", "VARCHAR(32) DEFAULT NULL COMMENT '观测状态快照(正常/偏高/偏低等)'");
+        log.info("迁移357: indicator_values 已补齐统一观测时序 6 列");
+    }
+
+    // 迁移358: indicator_values 幂等唯一键(source_type+source_id+definition_id)+ 时序查询索引
+    private void addIndicatorValuesKeys() {
+        try {
+            // 先清理极端重复(保留最小 id),避免建唯一键失败;正常数据本无重复
+            jdbcTemplate.execute(
+                    "DELETE v FROM indicator_values v " +
+                    "JOIN (SELECT MIN(id) keep_id, source_type, source_id, definition_id " +
+                    "      FROM indicator_values " +
+                    "      GROUP BY source_type, source_id, definition_id " +
+                    "      HAVING COUNT(*) > 1) d " +
+                    "  ON v.source_type = d.source_type AND v.source_id = d.source_id " +
+                    " AND v.definition_id = d.definition_id AND v.id <> d.keep_id");
+            log.info("迁移358: 已清理 indicator_values 重复行(如有)");
+        } catch (Exception e) {
+            log.warn("迁移358: 清理 indicator_values 重复行失败(表可能为空): {}", e.getMessage());
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE indicator_values ADD UNIQUE KEY uk_source_definition (source_type, source_id, definition_id)");
+            log.info("迁移358: 已添加 uk_source_definition 唯一键");
+        } catch (Exception e) {
+            log.info("迁移358: uk_source_definition 已存在,跳过");
+        }
+        try {
+            jdbcTemplate.execute("ALTER TABLE indicator_values ADD INDEX idx_subject_def_date (subject_id, definition_id, report_date)");
+            log.info("迁移358: 已添加 idx_subject_def_date 索引");
+        } catch (Exception e) {
+            log.info("迁移358: idx_subject_def_date 已存在,跳过");
+        }
+    }
+
+    // 迁移359: 新建 indicator_mapping_rules(指标归一化映射规则)
+    private void createIndicatorMappingRulesTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_mapping_rules (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                    "source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension'," +
+                    "raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)'," +
+                    "alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配'," +
+                    "definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id'," +
+                    "match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created'," +
+                    "priority INT DEFAULT 0 COMMENT '多规则冲突时取最高'," +
+                    "status TINYINT DEFAULT 1," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "UNIQUE KEY uk_source_raw (source_type, raw_name)," +
+                    "INDEX idx_def (definition_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则'");
+            log.info("迁移359: 已创建 indicator_mapping_rules 表");
+        } catch (Exception e) {
+            log.warn("迁移359: 创建 indicator_mapping_rules 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移360: 新建 indicator_correlations(指标关联关系:人工定义 + 数据校准)
+    private void createIndicatorCorrelationsTable() {
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_correlations (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                    "definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标'," +
+                    "definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标'," +
+                    "relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source'," +
+                    "direction VARCHAR(10) NOT NULL COMMENT 'positive|negative'," +
+                    "manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1'," +
+                    "computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)'," +
+                    "method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman'," +
+                    "sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数'," +
+                    "confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)'," +
+                    "effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual'," +
+                    "manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖'," +
+                    "cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population'," +
+                    "status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending'," +
+                    "computed_at DATETIME NULL COMMENT '最后校准时间'," +
+                    "created_by BIGINT NULL COMMENT '创建者'," +
+                    "description VARCHAR(500) NULL COMMENT '给用户看的解释'," +
+                    "source_note VARCHAR(255) NULL COMMENT '依据/出处'," +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                    "UNIQUE KEY uk_pair (definition_id_a, definition_id_b)," +
+                    "INDEX idx_def_a (definition_id_a)," +
+                    "INDEX idx_def_b (definition_id_b)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)'");
+            log.info("迁移360: 已创建 indicator_correlations 表");
+        } catch (Exception e) {
+            log.warn("迁移360: 创建 indicator_correlations 表失败: {}", e.getMessage());
+        }
+    }
+
+    // 迁移361: health_indicators.collection_date 生产库兜底(schema.sql 已有该列,仅确保老库补齐)
+    private void ensureIndicatorCollectionDate() {
+        ensureColumn("health_indicators", "collection_date",
+                "DATETIME COMMENT '指标采集日期(报告检测日期)'");
+        log.info("迁移361: health_indicators.collection_date 已确认存在");
+    }
+
+    // 迁移362: 预置五维展示定义(dim.body 等,仅作展示标签,不写 indicator_values)
+    private void seedDimensionDefinitions() {
+        Object[][] dims = {
+            {"dim.body",     "身", "身体",  "higher_is_better", "higher_is_better"},
+            {"dim.mind",     "心", "心理",  "higher_is_better", "higher_is_better"},
+            {"dim.wisdom",   "智", "智慧",  "higher_is_better", "higher_is_better"},
+            {"dim.action",   "行", "行动",  "higher_is_better", "higher_is_better"},
+            {"dim.wealth",   "富", "财富",  "higher_is_better", "higher_is_better"},
+        };
+        for (Object[] d : dims) {
+            try {
+                Integer exists = jdbcTemplate.queryForObject(
+                        "SELECT COUNT(*) FROM indicator_definitions WHERE code = ?", Integer.class, (String) d[0]);
+                if (exists == null || exists == 0) {
+                    jdbcTemplate.update(
+                            "INSERT INTO indicator_definitions (domain, category, code, name, data_type, unit, " +
+                            "source_kind, value_type, good_direction, dimension_code, sort_order, status) " +
+                            "VALUES ('physical', ?, ?, ?, 'number', '分', 'dimension_score', 'numeric', ?, ?, 0, 1)",
+                            d[1], d[0], d[2], d[4], d[3]);
+                    log.info("迁移362: 已预置维度定义 {}", d[0]);
+                }
+            } catch (Exception e) {
+                log.warn("迁移362: 预置维度定义 {} 失败: {}", d[0], e.getMessage());
+            }
+        }
+    }
+
     // 迁移355: 创建减脂专题体重记录表
     private void createFatWeightRecordTable() {
         try {

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

+ 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");

+ 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 "未知";

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

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

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

+ 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

@@ -415,6 +415,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;
+        }
+    }
+
     /**
      * 识别图片中的食材
      */

+ 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) {

+ 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 "中性";
+        }
+    }
+}

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

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

+ 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

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

@@ -2508,6 +2508,11 @@ CREATE TABLE IF NOT EXISTS indicator_definitions (
     options JSON COMMENT '枚举值列表: ["淡红","红","绛","青紫"] 或 null',
     unit VARCHAR(50) COMMENT '单位',
     ref_range VARCHAR(200) COMMENT '参考范围',
+    source_kind VARCHAR(32) COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score',
+    report_type_scope VARCHAR(32) COMMENT '适用报告类型(type_id,空=通用)',
+    value_type VARCHAR(16) COMMENT '语义类型: numeric/categorical/level',
+    good_direction VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral',
+    dimension_code VARCHAR(16) COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)',
     sort_order INT DEFAULT 0 COMMENT '排序',
     status TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -2523,9 +2528,17 @@ CREATE TABLE IF NOT EXISTS indicator_values (
     value VARCHAR(200) COMMENT '值',
     numeric_value DECIMAL(10,2) COMMENT '数值(number类型时用)',
     remark TEXT COMMENT '备注',
+    subject_id BIGINT COMMENT '观测对象(family_members.id)',
+    family_id BIGINT COMMENT '所属家庭(family_members.family_id)',
+    report_id BIGINT COMMENT '来源报告(health_reports.id)',
+    report_date DATE COMMENT '真实报告日期(时序查询键)',
+    unit VARCHAR(32) COMMENT '观测单位',
+    status VARCHAR(32) COMMENT '观测状态快照(正常/偏高/偏低等)',
     recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_source_definition (source_type, source_id, definition_id),
     INDEX idx_source (source_type, source_id),
-    INDEX idx_definition (definition_id)
+    INDEX idx_definition (definition_id),
+    INDEX idx_subject_def_date (subject_id, definition_id, report_date)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标值';
 
 -- ========== 舌诊主记录表 ==========
@@ -6194,3 +6207,46 @@ CREATE TABLE IF NOT EXISTS sso_ticket (
     UNIQUE KEY uk_ticket (ticket),
     INDEX idx_expire (expire_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨小程序单点登录一次性票据';
+
+-- 迁移359: 指标归一化映射规则(连接各来源原始名与统一 indicator_definitions)
+CREATE TABLE IF NOT EXISTS indicator_mapping_rules (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension',
+    raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)',
+    alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配',
+    definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id',
+    match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created',
+    priority INT DEFAULT 0 COMMENT '多规则冲突时取最高',
+    status TINYINT DEFAULT 1,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_source_raw (source_type, raw_name),
+    INDEX idx_def (definition_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则';
+
+-- 迁移360: 指标关联关系(人工定义 + 数据校准)
+CREATE TABLE IF NOT EXISTS indicator_correlations (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标',
+    definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标',
+    relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source',
+    direction VARCHAR(10) NOT NULL COMMENT 'positive|negative',
+    manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1',
+    computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)',
+    method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman',
+    sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数',
+    confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)',
+    effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual',
+    manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖',
+    cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population',
+    status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending',
+    computed_at DATETIME NULL COMMENT '最后校准时间',
+    created_by BIGINT NULL COMMENT '创建者',
+    description VARCHAR(500) NULL COMMENT '给用户看的解释',
+    source_note VARCHAR(255) NULL COMMENT '依据/出处',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_pair (definition_id_a, definition_id_b),
+    INDEX idx_def_a (definition_id_a),
+    INDEX idx_def_b (definition_id_b)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)';

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

+ 9 - 9
cfc-backend/src/test/java/com/etotem/cfc/integration/controller/ActivityControllerTest.java

@@ -222,7 +222,7 @@ public class ActivityControllerTest {
                 setCode(200);
             }});
 
-        Result<ActivityDTO> result = controller.create(activity, 5L, "admin");
+        Result<ActivityDTO> result = controller.create(activity, 5L, "admin", null);
         assertEquals(200, result.getCode());
         assertEquals(5L, activity.getVendorId()); // vendorId set from userId
         assertNotNull(result.getData().getId());
@@ -240,7 +240,7 @@ public class ActivityControllerTest {
                 setCode(200);
             }});
 
-        Result<ActivityDTO> result = controller.create(activity, null, "admin");
+        Result<ActivityDTO> result = controller.create(activity, null, "admin", null);
         assertEquals(200, result.getCode());
         assertNull(activity.getVendorId());
     }
@@ -257,7 +257,7 @@ public class ActivityControllerTest {
                 setCode(200);
             }});
 
-        Result<ActivityDTO> result = controller.update(activity, null, "admin");
+        Result<ActivityDTO> result = controller.update(activity, null, "admin", null);
         assertEquals(200, result.getCode());
         assertEquals("更新后的活动", result.getData().getTitle());
     }
@@ -275,7 +275,7 @@ public class ActivityControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("id", 1);
 
-        Result<String> result = controller.publish(params, "admin");
+        Result<String> result = controller.publish(params, "admin", null);
         assertEquals(200, result.getCode());
         assertEquals("发布成功", result.getData());
     }
@@ -292,14 +292,14 @@ public class ActivityControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("id", 999);
 
-        Result<String> result = controller.publish(params, "admin");
+        Result<String> result = controller.publish(params, "admin", null);
         assertEquals(500, result.getCode());
     }
 
     @Test
     public void publish_MissingId() {
         Map<String, Object> params = new HashMap<>();
-        Result<String> result = controller.publish(params, "admin");
+        Result<String> result = controller.publish(params, "admin", null);
         assertEquals(500, result.getCode());
         assertTrue(result.getMessage().contains("不能为空"));
     }
@@ -317,7 +317,7 @@ public class ActivityControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("id", 1);
 
-        Result<String> result = controller.end(params, null, "admin");
+        Result<String> result = controller.end(params, null, "admin", null);
         assertEquals(200, result.getCode());
     }
 
@@ -333,14 +333,14 @@ public class ActivityControllerTest {
         Map<String, Object> params = new HashMap<>();
         params.put("id", 999);
 
-        Result<String> result = controller.end(params, null, "admin");
+        Result<String> result = controller.end(params, null, "admin", null);
         assertEquals(500, result.getCode());
     }
 
     @Test
     public void end_MissingId() {
         Map<String, Object> params = new HashMap<>();
-        Result<String> result = controller.end(params, null, "admin");
+        Result<String> result = controller.end(params, null, "admin", null);
         assertEquals(500, result.getCode());
     }
 }

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

+ 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
     },

+ 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>

+ 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>

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

@@ -3394,3 +3394,14 @@ export const createSsoTicket = (target) =>
 /** 用一次性票据换取登录态。返回登录信息(结构同 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'
+}

+ 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={})

+ 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)

+ 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")

+ 24 - 1
cfc-langgraph/tests/test_report_parse.py

@@ -58,4 +58,27 @@ async def test_report_parse_invalid_json_response():
                 assert resp.status_code == 200
                 data = resp.json()
                 assert data["code"] == 500
-                assert "解析失败" in data["message"]
+                assert "解析失败" in data["message"]
+
+
+def test_indicator_constraint_contains_names():
+    from app.agents.report_parse_agent import _indicator_constraint
+    s = _indicator_constraint(["总分", "菌群健康"])
+    assert "总分" in s and "菌群健康" in s
+    assert _indicator_constraint(None) == ""
+    assert _indicator_constraint([]) == ""
+
+
+@pytest.mark.asyncio
+async def test_report_parse_accepts_indicators_field():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/report/parse", json={
+            "file_path": "/nonexistent.pdf",
+            "family_id": 1,
+            "user_id": 1,
+            "indicators": ["总分", "菌群健康"],
+        })
+        # 字段被接受(非 422 校验错误),仅文件不存在
+        assert resp.status_code == 400
+        assert "文件不存在" in resp.json()["detail"]

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-85ffba6aa1976012eb0d42554419fcfc4014102e
+8e11fad3dd9c7a9d2ae565ad0506e0a0bdbe340a

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,694 @@
 
 ---
 
+## v1.0.1556 (2026-10-09)
+
+### 新功能
+- pages.json 注册 indicator-curve / indicator-quicklook
+- 指标变化曲线页(筛选 + TrendChart + 变化排名 Tab/排序/着色)
+- 指标变化展示规则纯函数 resolveChangeStyle/formatDelta
+- TrendChart 通用折线组件(canvas 2d + DPR,参照 RadarChart 模式)
+- 指标 API 封装 getIndicatorTrend/ChangeRanking/QuickLookup/CorrelationList
+- 视图+管理控制器(4+5接口) + 每日校准Job
+- IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准
+- IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)
+- NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 报告管理展示采集完整度列与明细
+- 报告类型管理支持编辑采集指标
+- /report/parse 支持 indicators 采集指标约束注入
+- 非 DAN 解析下传 expected_indicators 驱动 LangGraph LLM
+- 报告管理列表/详情回填采集完整度 collect_validation
+- /types/save 校验 expectedIndicators 为 JSON 字符串数组
+
+### Bug 修复
+- 以 @Lazy 打破 IndicatorMappingService↔AiGateway 循环依赖,修复应用上下文启动失败
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 恢复 PromotionManagement 路由缺失的 path/name(构建阻断回归)
+- 采集指标种子改为仅空时写入,保留管理员编辑
+
+### 文档
+- 同步指标统一层 9 个新接口契约 + PROJECT-OVERVIEW 状态
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+
+## v1.0.1555 (2026-10-09)
+
+### 新功能
+- 指标 API 封装 getIndicatorTrend/ChangeRanking/QuickLookup/CorrelationList
+- 视图+管理控制器(4+5接口) + 每日校准Job
+- IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准
+- IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)
+- NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 接入图片识别食材 + 语音自动填入食物清单
+- 跨小程序单点登录 sso_ticket 换票与双向免登
+
+### Bug 修复
+- 以 @Lazy 打破 IndicatorMappingService↔AiGateway 循环依赖,修复应用上下文启动失败
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 文档
+- 同步指标统一层 9 个新接口契约 + PROJECT-OVERVIEW 状态
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- 
+- 
+
+### 重构
+- 管理端列表页表格改自适应高度布局
+
+
+## v1.0.1554 (2026-10-09)
+
+### 新功能
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+
+### Bug 修复
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+- 采集指标补齐 实施计划(方案A,P1+P2)
+
+
+## v1.0.1553 (2026-10-09)
+
+### Bug 修复
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 修复家庭共餐食材持久化编译错误(selectCount 返回 Long、去除重复迁移方法、迁移354→356)
+- add store/user.js, wire App.vue store init, append fatloss API docs
+- 最近记录展示全家报告,报告列表默认选中本人
+- 基础会员(BASIC) 在会员等级映射表中缺失,导致显示为免费用户
+
+### 新功能
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- add all UI pages (weight/diet/exercise/ai-coach/profile/chart/query)
+- add remaining pages (ai-coach, profile/goal, weight-chart, food-query)
+- add api wrappers and format utils
+- init project scaffold
+- add controllers for weight/diet/exercise/goal
+- add service layer
+- add entities and mappers
+- 家庭共餐食材推荐持久化到数据库
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 新增 DietFamilySelectedIngredient 实体和 Mapper
+- - DietIngredientService 改用数据库持久化替代内存 Map
+- - suggestIngredients 优先从 DB 加载已有选择
+- - refreshIngredients 清空并重新持久化推荐列表
+- - add/removeIngredient 直接操作 DB
+- - confirmIngredients 保留已选食材(不再清空)
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+- 采集指标 spec 改为方案A(沿既有 expected_indicators),补齐管理端/展示/驱动解析
+- mark completed tasks in fatloss-miniprogram-plan
+- add fatloss mini-program implementation plan
+- 报告类型采集指标定义与驱动解析设计(方案2 分层,P1+P2 本期)
+
+
+## v1.0.1552 (2026-10-09)
+
+### 新功能
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 财富中心移除推广入口并统一 CF 值显示
+- 我的团队入口合并至推广团队页
+- add psychological assessment module (PHQ-9, RSES-7) - New pages: pages/psych-assessment/index & quiz - pages.json registers psych-assessment routes - Dimensions: phq9_score, phq4_score, reses_score
+
+### Bug 修复
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 家庭收益成员贡献榜使用真实家庭成员
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - getFamilyId() 通过 userMapper.selectById 查询用户真实 familyId
+- - isFamilyAdmin() 检查 user.isFamilyAdmin 字段
+- - 修复成员贡献榜展示非本家庭用户的问题
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 移除 SharePoster 组件及其相关死代码(import/注册/数据字段/方法)
+- - 移除 fenToYuan/cfToYuan 辅助函数,统一替换为 cfVal 直接展示 CF 值
+- - 更新所有金额标签为 CF 单位(累计推荐CF/可支配CF/今日CF 等)
+- - 分配弹窗提示文字同步更新
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- # Conflicts:
+- #	cfc-frontend/pages/promotion/index.vue
+- 
+- - pages.json 移除 pages/profile/team-tree 页面注册
+- - 删除 pages/profile/team-tree.vue(与推广团队功能重复)
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+
+## v1.0.1551 (2026-10-09)
+
+### Bug 修复
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+
+
+## v1.0.1550 (2026-10-09)
+
+### 新功能
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+
+
+## v1.0.1549 (2026-10-09)
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+
+### 其他
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+
+
+## v1.0.1548 (2026-10-09)
+
+### 文档
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+
+
+## v1.0.1547 (2026-10-09)
+
+### 文档
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+
+## v1.0.1546 (2026-10-08)
+
+### 新功能
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 六个维度页传入dimension-code按域筛选推荐商品
+- DimensionProducts组件新增dimensionCode按维度取推荐
+
+### 其他
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- 购买态订单查询改用paid等真实状态枚举
+- 维度商品推荐修复订单状态匹配与实物库存口径
+- 迁移354补齐product_recommendation_log缺失列并同步schema.sql
+
+
+## v1.0.1545 (2026-10-08)
+
+### 新功能
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+
+### 其他
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+
+
+## v1.0.1544 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- 每类报告定义采集指标项并接入采集完整性校验
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 幂等补注册 dan_a1/b2/b3/b4/b5/b6/c1 与 gut_flora_beijing 类型行(parser_path 空,不干扰指纹路由)
+- - ReportCollectService 采集完成后按定义校验(归一化+包含+CJK二元组重叠匹配),结果写入 health_report_drafts.collect_validation(typeId/expected/matched/pass/missing)
+- - schema.sql 同步两处新列
+- 
+
+
+## v1.0.1543 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- 修复B4报告解析——自我概念按「维度 分数」提取6维、自驱力按维度顺序映射我的得分,去除错误兜底
+
+
+## v1.0.1542 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- add fallback from product.domain when candidates empty
+
+
+## v1.0.1541 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- DAN报告新增按维度重解析接口,修复历史误解析并刷新结果/展示块/汇总
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+
+## v1.0.1540 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- 我的画像页迁移五维画像并支持全屏编辑,后端新增每日自动更新任务
+- 引入企业级设计系统(Ant Design 5 令牌)并应用 flex 撑满布局
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - OrderManage.vue: 表格改用 .adm-table-wrap 弹性撑满(height=100%),移除 JS 计算的 tableHeight
+- 
+
+
+## v1.0.1539 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- dan报告维度映射支持类型编码(a1/b2/b4等),前端dan_*前缀统一跳转DAN报告页
+
+
+## v1.0.1538 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - getScoreClass/getStatusText/buildFamilyOverview 等状态判定全部改为 >= 0 边界
+- - onLoad 从报告入口进入时默认 sortType='asc',setFilter('unsuitable') 时同步重置 sortType
+- - 分类 tab key 从英文 (vegetable/fruit/meat/seafood/grain/dairy) 改为中文 (蔬菜/水果/肉禽/水产/谷物/蛋奶),并新增 getCategoryKeywords() 关键词映射覆盖 PDF/ LangGraph 两套数据源分类命名
+- - 修复分类点击无效问题,确保前端过滤能匹配 DB 中存储的中文分类
+- closes related category filter bug, aligns with backend semantics
+- 
+
+### Bug 修复
+- ingredient query page - 戒备阈值 <50→<0, 默认正序, 分类点击生效
+
+
+## v1.0.1537 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 将 SUM(points) 修正为 SUM(amount),与 points_log 表结构一致
+- - 修复任务完成接口 /api/tasks/{id}/complete 在 calculateLatePenalty 路径抛 BadSqlGrammarException
+- 
+
+### Bug 修复
+- 修复 PointsLogMapper.selectSum SQL 占位符转义及字段名错误
+
+
+## v1.0.1536 (2026-10-07)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- 注册/绑定推荐人不再发放CF值,新增首次报告上传任务(300 CF)
+
+
+## v1.0.1535 (2026-10-07)
+
+### Bug 修复
+- 健康首页最近记录只显示当前用户自己的报告
+- 商品管理列表撑满视口并消除多级滚动条
+
+### 其他
+- getFamilyReports 返回全家全部报告),改回传入当前视图成员 memberId,
+- 符合"只有两种情况"的语义:
+- - 未切换视图 = 当前登录用户自己的报告(currentChildId/currentMemberId
+-   未写入 storage 时回退 userId,后端 getMemberReports 按成员严格过滤)
+- - 已切换成员 = 被切换成员的报告
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 表格改用 height=100% + flex:1,仅保留表格自身滚动条
+- - 移除 JS 计算 tableHeight 及 window.resize 监听
+- 
+
+### 新功能
+- admin supply-manage auto-set activityProviderStatus for activity_provider
+
+
+## v1.0.1534 (2026-10-07)
+
+### 新功能
+- integrate article recommendation into dimension pages
+- include vendorType/activityProviderStatus in login response for frontend permission check
+- allow activity_provider to create activities + scene referral binding
+- 小程序新增任务提醒配置页(渠道/时机/偏移/开关)
+- 任务打卡提醒升级为可配置提醒模块
+- 每日任务打卡超时 webhook 提醒
+- 新增迁移352移除报告上传权益与配额残留并同步 schema.sql
+- 移除报告上传配额,对所有登录用户免费无限
+- implement product recommendation engine with persona + association relations (P3) add recommendation rules DB + service + UI ab-test tracking
+
+### 其他
+- - 知识库:wiki/raw 原始资料、wiki 页面、结构化知识、构建脚本及 memory 等知识资产
+- - 新增 知识库/.gitignore,排除中间产物(_kb_work/.wiki-creator/__pycache__/*.pyc/.obsidian 缓存)
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 避免最新上传/归属到其他成员的报告在首页不显示;切换到具体成员或孩子本人登录时仍按成员过滤。
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 新增 pages/reminder-config/index.vue 提醒设置页(渠道多选含未开通态/时机单选/偏移分钟/启用开关)
+- - pages.json 注册 reminder-config 分包
+- - profile 设置 actionSheet 增加"提醒设置"入口
+- 
+- - 新增 TaskReminderConfigService/Controller(/api/task/reminder-config get/save)
+- - 重写 TaskCheckinWebhookReminderTask: 按配置支持的 before_deadline/after_timeout 双窗口, 多渠道分发
+- - 新增 WebhookNotificationChannel(wechat) 接入微信待办 webhook; Email 通道骨架(未启用)
+- - 站内信(in_app)恒兜底并作幂等标记, 兼容旧 type 判重避免重复推送
+- - 无配置用户走默认: wechat+超时60分钟(与线上行为一致)
+- - 新增 7 个单测覆盖窗口/渠道/判重/停用场景
+- 
+- - 新增 TaskCheckinWebhookReminderTask:每分钟扫描家长执行型待打卡任务,提醒时间(deadline)过 1 小时未完成则推送 webhook
+- - 幂等:user_notifications 标记,同任务同日仅推一次;失败下一分钟自动重试
+- 模板任务数据(9 条健康打卡)一次性直接落库,不写入代码。
+- 
+- 表示该order_id在微信订单库中从未创建成功(并非临时查询失败)。
+- 原逻辑将所有errcode!=0一律return null, 导致超时任务永远跳过不关单,
+- 死单永不清理且每单每分钟刷2条WARN(今日已5200+条)。
+- - queryOrder: 单独识别268490002返回status=-1(本地扩展状态) + INFO日志
+- - VirtualPayTimeoutTask三个handler: status=-1归入明确未支付置cancelled分支
+- - 已用真实paid单(ORD1785990942621001DCC2)实测接口正常, 无误杀风险
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- add: care_family 运营资料 + cfContent 微信贴图话题发布 skill
+
+### Bug 修复
+- 健康首页最近记录默认展示全家报告
+- query_order返回268490002数据不存在视为未支付关单
+- 移除会员对比表与升级弹窗中的报告上传配额文案
+- 微信支付 v3 回调通知加入白名单并修正 notify-url
+- 报告列表默认显示当前登录用户而非全部
+- body-detail navigation to unregistered pages; resolve action-detail merge conflict
+
+### 文档
+- add activity share referral design spec
+
+
 ## v1.0.1533 (2026-10-07)
 
 ### Bug 修复

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1533
+> 当前版本: v1.0.1556
 
 ## 历史版本
 
@@ -8,6 +8,694 @@
 
 ---
 
+## v1.0.1556 (2026-10-09)
+
+### 新功能
+- pages.json 注册 indicator-curve / indicator-quicklook
+- 指标变化曲线页(筛选 + TrendChart + 变化排名 Tab/排序/着色)
+- 指标变化展示规则纯函数 resolveChangeStyle/formatDelta
+- TrendChart 通用折线组件(canvas 2d + DPR,参照 RadarChart 模式)
+- 指标 API 封装 getIndicatorTrend/ChangeRanking/QuickLookup/CorrelationList
+- 视图+管理控制器(4+5接口) + 每日校准Job
+- IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准
+- IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)
+- NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 报告管理展示采集完整度列与明细
+- 报告类型管理支持编辑采集指标
+- /report/parse 支持 indicators 采集指标约束注入
+- 非 DAN 解析下传 expected_indicators 驱动 LangGraph LLM
+- 报告管理列表/详情回填采集完整度 collect_validation
+- /types/save 校验 expectedIndicators 为 JSON 字符串数组
+
+### Bug 修复
+- 以 @Lazy 打破 IndicatorMappingService↔AiGateway 循环依赖,修复应用上下文启动失败
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 恢复 PromotionManagement 路由缺失的 path/name(构建阻断回归)
+- 采集指标种子改为仅空时写入,保留管理员编辑
+
+### 文档
+- 同步指标统一层 9 个新接口契约 + PROJECT-OVERVIEW 状态
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+
+## v1.0.1555 (2026-10-09)
+
+### 新功能
+- 指标 API 封装 getIndicatorTrend/ChangeRanking/QuickLookup/CorrelationList
+- 视图+管理控制器(4+5接口) + 每日校准Job
+- IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准
+- IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)
+- NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 接入图片识别食材 + 语音自动填入食物清单
+- 跨小程序单点登录 sso_ticket 换票与双向免登
+
+### Bug 修复
+- 以 @Lazy 打破 IndicatorMappingService↔AiGateway 循环依赖,修复应用上下文启动失败
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 文档
+- 同步指标统一层 9 个新接口契约 + PROJECT-OVERVIEW 状态
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- 
+- 
+
+### 重构
+- 管理端列表页表格改自适应高度布局
+
+
+## v1.0.1554 (2026-10-09)
+
+### 新功能
+- IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析
+- AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify
+- SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)
+- ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+
+### Bug 修复
+- 修复报告日期回填无限循环(keyset游标+去除no-op写)+ 补回填测试
+- 修复 ActivityControllerTest 参数列表(补 roles 参数,解除全量测试编译阻塞)
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 其他
+- 未同步测试,导致 src/test 全量无法编译。补 null roles 恢复测试编译。
+- 
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+- 采集指标补齐 实施计划(方案A,P1+P2)
+
+
+## v1.0.1553 (2026-10-09)
+
+### Bug 修复
+- IndicatorValueMapper 恢复 @Mapper 注解(与既有 mapper 约定一致;@MapperScan 兜底但保持统一风格)
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 修复家庭共餐食材持久化编译错误(selectCount 返回 Long、去除重复迁移方法、迁移354→356)
+- add store/user.js, wire App.vue store init, append fatloss API docs
+- 最近记录展示全家报告,报告列表默认选中本人
+- 基础会员(BASIC) 在会员等级映射表中缺失,导致显示为免费用户
+
+### 新功能
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- add all UI pages (weight/diet/exercise/ai-coach/profile/chart/query)
+- add remaining pages (ai-coach, profile/goal, weight-chart, food-query)
+- add api wrappers and format utils
+- init project scaffold
+- add controllers for weight/diet/exercise/goal
+- add service layer
+- add entities and mappers
+- 家庭共餐食材推荐持久化到数据库
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 新增 DietFamilySelectedIngredient 实体和 Mapper
+- - DietIngredientService 改用数据库持久化替代内存 Map
+- - suggestIngredients 优先从 DB 加载已有选择
+- - refreshIngredients 清空并重新持久化推荐列表
+- - add/removeIngredient 直接操作 DB
+- - confirmIngredients 保留已选食材(不再清空)
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+- 采集指标 spec 改为方案A(沿既有 expected_indicators),补齐管理端/展示/驱动解析
+- mark completed tasks in fatloss-miniprogram-plan
+- add fatloss mini-program implementation plan
+- 报告类型采集指标定义与驱动解析设计(方案2 分层,P1+P2 本期)
+
+
+## v1.0.1552 (2026-10-09)
+
+### 新功能
+- 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 财富中心移除推广入口并统一 CF 值显示
+- 我的团队入口合并至推广团队页
+- add psychological assessment module (PHQ-9, RSES-7) - New pages: pages/psych-assessment/index & quiz - pages.json registers psych-assessment routes - Dimensions: phq9_score, phq4_score, reses_score
+
+### Bug 修复
+- AIService 舌诊 JSON 序列化捕获受检异常
+- 家庭收益成员贡献榜使用真实家庭成员
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - getFamilyId() 通过 userMapper.selectById 查询用户真实 familyId
+- - isFamilyAdmin() 检查 user.isFamilyAdmin 字段
+- - 修复成员贡献榜展示非本家庭用户的问题
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 移除 SharePoster 组件及其相关死代码(import/注册/数据字段/方法)
+- - 移除 fenToYuan/cfToYuan 辅助函数,统一替换为 cfVal 直接展示 CF 值
+- - 更新所有金额标签为 CF 单位(累计推荐CF/可支配CF/今日CF 等)
+- - 分配弹窗提示文字同步更新
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- # Conflicts:
+- #	cfc-frontend/pages/promotion/index.vue
+- 
+- - pages.json 移除 pages/profile/team-tree 页面注册
+- - 删除 pages/profile/team-tree.vue(与推广团队功能重复)
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+
+## v1.0.1551 (2026-10-09)
+
+### Bug 修复
+- AIService 舌诊 JSON 序列化捕获受检异常
+
+### 其他
+- mvn clean compile 验证门。最小修复:writeValueAsString 包 try/catch,失败返回 null。
+- 
+
+
+## v1.0.1550 (2026-10-09)
+
+### 新功能
+- 迁移356-362 升级统一指标层(列/键/两新表/维度种子)
+
+
+## v1.0.1549 (2026-10-09)
+
+### 文档
+- 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步
+
+### 其他
+- - miniapp: TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4封装 + 单测
+- - webadmin: src/api/indicator.js 6封装 + IndicatorCorrelations/IndicatorMappings 两页 + indicatorSeeds 解析 + 路由注册 + jest 单测 + build 门禁
+- - PROJECT-OVERVIEW v2.16 同步:3 份计划索引 + 状态行更新
+- 
+
+
+## v1.0.1548 (2026-10-09)
+
+### 文档
+- 自审修正 - 复用既有reportDate/collection_date逻辑、迁移编号起点356、API_REFERENCE同步规范
+- 修正 indicator_values.source_type 注释与五维/七维不入统一时序的决策一致
+
+
+## v1.0.1547 (2026-10-09)
+
+### 文档
+- 指标变化曲线·指标速查·指标关联设计规范 v1.0
+
+
+## v1.0.1546 (2026-10-08)
+
+### 新功能
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- enhance activity poster with template selection and smaller QR code
+- 六个维度页传入dimension-code按域筛选推荐商品
+- DimensionProducts组件新增dimensionCode按维度取推荐
+
+### 其他
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- 购买态订单查询改用paid等真实状态枚举
+- 维度商品推荐修复订单状态匹配与实物库存口径
+- 迁移354补齐product_recommendation_log缺失列并同步schema.sql
+
+
+## v1.0.1545 (2026-10-08)
+
+### 新功能
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+- 舌诊 AI 集成 - aikanshe.com 优先,LangGraph 兜底
+
+### 其他
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 验证: mvn spring-boot:run → POST /api/health/report/parse-preview?type=tongue
+- 
+- 2. application.yml:新增 aikanshe 配置块(6参数)
+- 3. API_REFERENCE.md:新增 4.36.2 节「舌诊 AI 集成」
+- 4. specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md:完整设计规格
+- 无需改动 TongueDiagnosisService/HealthReportController,接口契约兼容。
+- 配置: export AIKANSHE_APPKEY="your_appkey_here"
+- 
+
+
+## v1.0.1544 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- 每类报告定义采集指标项并接入采集完整性校验
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 幂等补注册 dan_a1/b2/b3/b4/b5/b6/c1 与 gut_flora_beijing 类型行(parser_path 空,不干扰指纹路由)
+- - ReportCollectService 采集完成后按定义校验(归一化+包含+CJK二元组重叠匹配),结果写入 health_report_drafts.collect_validation(typeId/expected/matched/pass/missing)
+- - schema.sql 同步两处新列
+- 
+
+
+## v1.0.1543 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- 修复B4报告解析——自我概念按「维度 分数」提取6维、自驱力按维度顺序映射我的得分,去除错误兜底
+
+
+## v1.0.1542 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- add fallback from product.domain when candidates empty
+
+
+## v1.0.1541 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- DAN报告新增按维度重解析接口,修复历史误解析并刷新结果/展示块/汇总
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+
+## v1.0.1540 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+- 我的画像页迁移五维画像并支持全屏编辑,后端新增每日自动更新任务
+- 引入企业级设计系统(Ant Design 5 令牌)并应用 flex 撑满布局
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - OrderManage.vue: 表格改用 .adm-table-wrap 弹性撑满(height=100%),移除 JS 计算的 tableHeight
+- 
+
+
+## v1.0.1539 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- dan报告维度映射支持类型编码(a1/b2/b4等),前端dan_*前缀统一跳转DAN报告页
+
+
+## v1.0.1538 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - getScoreClass/getStatusText/buildFamilyOverview 等状态判定全部改为 >= 0 边界
+- - onLoad 从报告入口进入时默认 sortType='asc',setFilter('unsuitable') 时同步重置 sortType
+- - 分类 tab key 从英文 (vegetable/fruit/meat/seafood/grain/dairy) 改为中文 (蔬菜/水果/肉禽/水产/谷物/蛋奶),并新增 getCategoryKeywords() 关键词映射覆盖 PDF/ LangGraph 两套数据源分类命名
+- - 修复分类点击无效问题,确保前端过滤能匹配 DB 中存储的中文分类
+- closes related category filter bug, aligns with backend semantics
+- 
+
+### Bug 修复
+- ingredient query page - 戒备阈值 <50→<0, 默认正序, 分类点击生效
+
+
+## v1.0.1537 (2026-10-08)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+- - 将 SUM(points) 修正为 SUM(amount),与 points_log 表结构一致
+- - 修复任务完成接口 /api/tasks/{id}/complete 在 calculateLatePenalty 路径抛 BadSqlGrammarException
+- 
+
+### Bug 修复
+- 修复 PointsLogMapper.selectSum SQL 占位符转义及字段名错误
+
+
+## v1.0.1536 (2026-10-07)
+
+### 新功能
+- enhance activity poster with template selection and smaller QR code
+
+### 其他
+- - Add background color selector for gradient template
+- - Reduce QR code size from 150 to 100rpx for activities
+- - Auto-detect background color from activity dimension
+- - Pass template/bgColor as writable props via event updates
+- 
+
+### Bug 修复
+- 注册/绑定推荐人不再发放CF值,新增首次报告上传任务(300 CF)
+
+
+## v1.0.1535 (2026-10-07)
+
+### Bug 修复
+- 健康首页最近记录只显示当前用户自己的报告
+- 商品管理列表撑满视口并消除多级滚动条
+
+### 其他
+- getFamilyReports 返回全家全部报告),改回传入当前视图成员 memberId,
+- 符合"只有两种情况"的语义:
+- - 未切换视图 = 当前登录用户自己的报告(currentChildId/currentMemberId
+-   未写入 storage 时回退 userId,后端 getMemberReports 按成员严格过滤)
+- - 已切换成员 = 被切换成员的报告
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 表格改用 height=100% + flex:1,仅保留表格自身滚动条
+- - 移除 JS 计算 tableHeight 及 window.resize 监听
+- 
+
+### 新功能
+- admin supply-manage auto-set activityProviderStatus for activity_provider
+
+
+## v1.0.1534 (2026-10-07)
+
+### 新功能
+- integrate article recommendation into dimension pages
+- include vendorType/activityProviderStatus in login response for frontend permission check
+- allow activity_provider to create activities + scene referral binding
+- 小程序新增任务提醒配置页(渠道/时机/偏移/开关)
+- 任务打卡提醒升级为可配置提醒模块
+- 每日任务打卡超时 webhook 提醒
+- 新增迁移352移除报告上传权益与配额残留并同步 schema.sql
+- 移除报告上传配额,对所有登录用户免费无限
+- implement product recommendation engine with persona + association relations (P3) add recommendation rules DB + service + UI ab-test tracking
+
+### 其他
+- - 知识库:wiki/raw 原始资料、wiki 页面、结构化知识、构建脚本及 memory 等知识资产
+- - 新增 知识库/.gitignore,排除中间产物(_kb_work/.wiki-creator/__pycache__/*.pyc/.obsidian 缓存)
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 避免最新上传/归属到其他成员的报告在首页不显示;切换到具体成员或孩子本人登录时仍按成员过滤。
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 新增 pages/reminder-config/index.vue 提醒设置页(渠道多选含未开通态/时机单选/偏移分钟/启用开关)
+- - pages.json 注册 reminder-config 分包
+- - profile 设置 actionSheet 增加"提醒设置"入口
+- 
+- - 新增 TaskReminderConfigService/Controller(/api/task/reminder-config get/save)
+- - 重写 TaskCheckinWebhookReminderTask: 按配置支持的 before_deadline/after_timeout 双窗口, 多渠道分发
+- - 新增 WebhookNotificationChannel(wechat) 接入微信待办 webhook; Email 通道骨架(未启用)
+- - 站内信(in_app)恒兜底并作幂等标记, 兼容旧 type 判重避免重复推送
+- - 无配置用户走默认: wechat+超时60分钟(与线上行为一致)
+- - 新增 7 个单测覆盖窗口/渠道/判重/停用场景
+- 
+- - 新增 TaskCheckinWebhookReminderTask:每分钟扫描家长执行型待打卡任务,提醒时间(deadline)过 1 小时未完成则推送 webhook
+- - 幂等:user_notifications 标记,同任务同日仅推一次;失败下一分钟自动重试
+- 模板任务数据(9 条健康打卡)一次性直接落库,不写入代码。
+- 
+- 表示该order_id在微信订单库中从未创建成功(并非临时查询失败)。
+- 原逻辑将所有errcode!=0一律return null, 导致超时任务永远跳过不关单,
+- 死单永不清理且每单每分钟刷2条WARN(今日已5200+条)。
+- - queryOrder: 单独识别268490002返回status=-1(本地扩展状态) + INFO日志
+- - VirtualPayTimeoutTask三个handler: status=-1归入明确未支付置cancelled分支
+- - 已用真实paid单(ORD1785990942621001DCC2)实测接口正常, 无误杀风险
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- add: care_family 运营资料 + cfContent 微信贴图话题发布 skill
+
+### Bug 修复
+- 健康首页最近记录默认展示全家报告
+- query_order返回268490002数据不存在视为未支付关单
+- 移除会员对比表与升级弹窗中的报告上传配额文案
+- 微信支付 v3 回调通知加入白名单并修正 notify-url
+- 报告列表默认显示当前登录用户而非全部
+- body-detail navigation to unregistered pages; resolve action-detail merge conflict
+
+### 文档
+- add activity share referral design spec
+
+
 ## v1.0.1533 (2026-10-07)
 
 ### Bug 修复

+ 3 - 0
cfc-web/src/router/index.js

@@ -733,6 +733,9 @@ const routes = [
         component: () => import('@/views/admin/RecommendationRules.vue'),
         meta: { title: '商品推荐规则管理', perm: 'config:recommendation' }
       },
+      {
+        path: 'promotion',
+        name: 'PromotionManagement',
         component: () => import('@/views/admin/PromotionManagement'),
         meta: { title: '推广管理', perm: 'marketing:promotion' }
       },

+ 25 - 0
cfc-web/src/views/admin/ReportManagement.vue

@@ -64,6 +64,16 @@
             <el-tag size="small">{{ scope.row.status }}</el-tag>
           </template>
         </el-table-column>
+        <el-table-column label="采集完整度" width="120">
+          <template slot-scope="scope">
+            <el-tag
+              v-if="scope.row.collectValidation"
+              size="small"
+              :type="collectPass(scope.row) ? 'success' : 'danger'"
+            >{{ collectText(scope.row) }}</el-tag>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="createdAt" label="创建时间" width="160">
           <template slot="header">
             <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">创建时间{{ sortIcon('createdAt') }}</span>
@@ -175,12 +185,18 @@ export default {
       }).then(function(res) {
         if (res.data && res.data.data) {
           var d = res.data.data
+          var cv = null
+          try { cv = d.collectValidation ? JSON.parse(d.collectValidation) : null } catch (e) { cv = null }
+          var cvLine = '<p>采集完整度: ' + (cv
+            ? (cv.matched + '/' + cv.expected + (cv.pass ? '(通过)' : '(缺失: ' + ((cv.missing || []).join('、') || '-') + ')'))
+            : '-') + '</p>'
           self.$alert(
             '<p>标题: ' + d.title + '</p>' +
             '<p>类型: ' + d.reportTypeName + '</p>' +
             '<p>主体: ' + d.subjectName + '</p>' +
             '<p>日期: ' + d.reportDate + '</p>' +
             '<p>评分: ' + (d.overallScore || '-') + '</p>' +
+            cvLine +
             '<p>状态: ' + d.status + '</p>',
             '报告详情',
             { dangerouslyUseHTMLString: true }
@@ -188,6 +204,15 @@ export default {
         }
       })
     },
+    parseCollect(row) {
+      if (!row || !row.collectValidation) return null
+      try { return JSON.parse(row.collectValidation) } catch (e) { return null }
+    },
+    collectPass(row) { const c = this.parseCollect(row); return !!(c && c.pass === true) },
+    collectText(row) {
+      const c = this.parseCollect(row)
+      return c ? (c.matched + '/' + c.expected) : '-'
+    },
     getTypeTag(type) {
       var map = { gut_flora: 'success', physical_exam: 'primary', dan: 'warning', insurance: 'info', fortune: 'danger' }
       return map[type] || 'info'

+ 50 - 1
cfc-web/src/views/admin/ReportTypeManagement.vue

@@ -41,6 +41,11 @@
             <span class="sort-header" @click.stop="onSortClick('minConfidence', $event)">阈值{{ sortIcon('minConfidence') }}</span>
           </template>
         </el-table-column>
+        <el-table-column label="采集指标" width="90">
+          <template slot-scope="{ row }">
+            <el-tag size="mini" type="info">{{ indicatorCount(row.expectedIndicators) }} 项</el-tag>
+          </template>
+        </el-table-column>
         <el-table-column label="状态" width="80">
           <template slot-scope="{ row }">
             <el-tag :type="row.isActive ? 'success' : 'info'" size="small">
@@ -104,6 +109,27 @@
         <el-form-item label="识别阈值">
           <el-input-number v-model="form.minConfidence" :min="1" :max="100" />
         </el-form-item>
+        <el-form-item label="采集指标">
+          <el-tag
+            v-for="(tag, idx) in indicators"
+            :key="idx"
+            closable
+            size="small"
+            style="margin: 0 6px 6px 0;"
+            @close="removeIndicator(idx)"
+          >{{ tag }}</el-tag>
+          <el-input
+            v-if="indicatorInputVisible"
+            ref="indicatorInput"
+            v-model="indicatorInput"
+            size="mini"
+            style="width: 160px;"
+            @keyup.enter.native="confirmIndicator"
+            @blur="confirmIndicator"
+          />
+          <el-button v-else size="mini" @click="showIndicatorInput">+ 添加指标</el-button>
+          <div class="el-form-item-msg">该类型报告应采集的指标项名称;留空表示不校验</div>
+        </el-form-item>
       </el-form>
       <div slot="footer">
         <el-button @click="dialogVisible = false">取消</el-button>
@@ -176,6 +202,7 @@ export default {
       sortableColumns: { id: 'ID', typeId: '类型标识', displayName: '显示名称', family: '家族', parserPath: '解析器路径', minConfidence: '阈值', source: '来源' },
       dialogVisible: false, isEdit: false,
       form: { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10 },
+      indicators: [], indicatorInput: '', indicatorInputVisible: false,
       fpDialogVisible: false, currentType: null,
       fingerprints: [], fpFormVisible: false,
       fpForm: { pattern: '', weight: 10, isExclusion: false, sortOrder: 0 },
@@ -206,20 +233,42 @@ export default {
       } catch (e) { this.$message.error('加载失败') }
       this.loading = false
     },
+    safeParseIndicators(raw) {
+      if (!raw) return []
+      try {
+        const arr = JSON.parse(raw)
+        return Array.isArray(arr) ? arr.filter(x => typeof x === 'string') : []
+      } catch (e) { return [] }
+    },
+    indicatorCount(raw) { return this.safeParseIndicators(raw).length },
+    showIndicatorInput() {
+      this.indicatorInputVisible = true
+      this.$nextTick(() => this.$refs.indicatorInput && this.$refs.indicatorInput.focus())
+    },
+    confirmIndicator() {
+      const v = (this.indicatorInput || '').trim()
+      if (v && !this.indicators.includes(v)) this.indicators.push(v)
+      this.indicatorInput = ''
+      this.indicatorInputVisible = false
+    },
+    removeIndicator(idx) { this.indicators.splice(idx, 1) },
     handleCreate() {
       this.isEdit = false
       this.form = { typeId: '', displayName: '', family: '', description: '', parserPath: '', minConfidence: 10 }
+      this.indicators = []
       this.dialogVisible = true
     },
     handleEdit(row) {
       this.isEdit = true
       this.form = { ...row }
+      this.indicators = this.safeParseIndicators(row.expectedIndicators)
       this.dialogVisible = true
     },
     async handleSubmit() {
       this.submitting = true
       try {
-        await saveReportType(this.form)
+        const payload = { ...this.form, expectedIndicators: JSON.stringify(this.indicators) }
+        await saveReportType(payload)
         this.$message.success('保存成功')
         this.dialogVisible = false
         this.fetchTypes()

+ 8 - 3
docs/superpowers/PROJECT-OVERVIEW.md

@@ -1,8 +1,9 @@
 # 浠艾福 项目全景 — 阶段性需求与设计汇总
 
-**文档版本:** v2.15
-**日期:** 2026-10-05
+**文档版本:** v2.17
+**日期:** 2026-10-09
 **状态:** 已确认(v2.1 Phase 2-4 全栈完成)+ 虚拟支付改造(Tasks 1-12 已完成,退款闭环实施中)+ 新用户注册引导(12 Tasks 全栈完成)+ 家庭成员关系条增强(✅ 已实施)+ TabBar 重构(✅ 4Tab + 中间⭐扇形菜单,设计已对齐实现)+ **LIFETIME 终身会员(✅ 11 Tasks 全栈完成)** + **健康数据中心(✅ 全栈完成)** + **SKU 价格单位统一分 + 规格选择响应式修复(✅ 7 Tasks 全栈完成)** + **AI健康教练人格分化与管家自助选择(✅ 11 Tasks 全栈完成)** + **优惠券全链路改为家庭维度(待实施)** + **关系三图重构(关键人拓展章鱼图/珍珠图/能力图,设计规格已完成)** + **注册零填写+信息按需补充(✅ 全栈完成:登录直接进首页 + 家庭邀请独立页真实姓名/家庭身份必填 + 功能→信息字段映射表 + 添加成员手机号冲突邀请加入)** + **健康报告归属重分配(✅ 后端接口 + 管理端页面 + 小程序入口已实现,见 `specs/2026-10-05-health-report-reassign-design.md`)**
+**指标变化曲线·指标速查·指标关联(✅ 后端已实施:11 任务全栈(迁移+统一指标层+4 视图 API+5 admin 端点+每日校准 Job),24 用例全绿;小程序/管理端实施计划已就绪待执行,见 `specs/2026-10-09-indicator-change-curve-design.md`)**
 **维护:** 所有需求变更需更新本文档
 
 ---
@@ -116,6 +117,7 @@
 | 营养健康部分缺陷修复 | Phase 1 | ✅ 已实施(5项缺陷已修复) | `plans/2026-06-22-营养健康部分缺陷修复计划.md` |
 | 主动健康六阶模型改进(P0-P3) | Phase 5 | ✅ P0/P1-1/P1-2/P2-1/P2-2/P3 后端全完成(P2-2家庭互动+ P3社区扩散Controller/Service/Entity全量存在) | `specs/2026-07-22-active-health-six-stage-design.md` (设计总纲) |
 | 健康数据中心(看见·主动健康闭环) | Phase 5 | ✅ 全栈完成(身维度首页改造:Tab栏+四维雷达图+数据采集区+数据查看+方案汇总;在线测评新增认知/心理/关系3套题库+dim路由;方案汇总页+health_plans表+4后端接口) | `specs/2026-08-08-health-data-center-design.md` + `plans/health-data-center-implementation-plan.md` |
+| 指标变化曲线·指标速查·指标关联 | Phase 5/Q4 | 🟡 设计已确认 v1.0 + 3 份实施计划就绪待执行(backend/miniapp/webadmin:统一指标层升级 indicator_definitions/indicator_values + indicator_mapping_rules/indicator_correlations 两新表 + NormalizeIndicatorPipeline 单一写者 + 4视图API + change-ranking + Spearman 校准 + TrendChart canvas 组件 + 曲线/速查两页面 + 管理端关联/映射两页) | `specs/2026-10-09-indicator-change-curve-design.md` + `plans/2026-10-09-indicator-change-curve-backend.md` + `plans/2026-10-09-indicator-change-curve-miniapp.md` + `plans/2026-10-09-indicator-change-curve-webadmin.md` |
 
 ---
 
@@ -374,7 +376,7 @@
 | `2026-08-31-self-check-ai-integration-design.md` | 🟡 设计稿(P0-1 纯AI替换静态建议 + P0-2 用户点击生成健康计划 + P1-1 历史趋势AI解读 + P1-2 Chat上下文注入,P2延后) | 五维家庭自检 AI 结合设计 |
 | `2026-09-09-team-hierarchy-design.md` | 🟡 设计稿(团队层级树从下往上生长 + 团队消费额分级统计 + 直推成员消费/CF明细 + 我的推荐人展示 + 去掉富页面推荐码) | 团队层级与推荐关系可视化 |
 | `2026-09-10-report-quick-analyze-design.md` | 🟡 设计稿(报告详情页快速分析:免费版每日 1 次 Redis 计数 + 付费版多轮;复用 chat_graph + AiGateway,LangGraph 零改动) | 报告快速分析设计 |
-| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`;2026-10-04 补充统一服务角色申请 6 接口 `/api/service-role/*`(4.44,含 status 语义、角色落地规则及 admin/operator 权限口径)、规划师解绑家庭 `/api/guide/families/unbind`(4.45)、规划师方案审核 `/api/health/plan/pending-review/*`(4.46,含未分配池 `teacher_id IS NULL` 规则);2026-10-05 补充商品批量上下架 `POST /api/admin/product/batch-shelve`(body: `{productIds, shelve}`,批量校验仅审核通过商品可操作)) | 后台接口参考文档 |
+| `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程;2026-09-06 补充家庭成员切换/回收箱接口:`/leave`、`/kick` 回收箱分流、`/recycle`;2026-09-10 补充关系三图接口:`/api/octopus`、`/api/pearl`、`/api/ability`;2026-09-12 补充供应商审核接口:`/api/product/order/review/update`;2026-10-04 补充统一服务角色申请 6 接口 `/api/service-role/*`(4.44,含 status 语义、角色落地规则及 admin/operator 权限口径)、规划师解绑家庭 `/api/guide/families/unbind`(4.45)、规划师方案审核 `/api/health/plan/pending-review/*`(4.46,含未分配池 `teacher_id IS NULL` 规则);2026-10-05 补充商品批量上下架 `POST /api/admin/product/batch-shelve`(body: `{productIds, shelve}`,批量校验仅审核通过商品可操作);2026-10-09 补充指标统一层 9 接口(4.48:视图 4 个 `/api/indicator/*` + admin 5 个 `/api/admin/indicator/*`,含家庭归属校验、关联校准口径、映射导入/review)) | 后台接口参考文档 |
 | `2026-09-10-key-person-expansion-octopus-design.md` | 🟡 设计规格已确认(成效对象=会员客户 + 类型bitmask + 关键人画像 + 四步法 + 高频词≤8方向;3表/10接口/三层画布;替换旧章鱼图) | 关键人拓展章鱼图替换设计(关系三图重构) |
 | `2026-09-19-article-reading-quiz-design.md` | 🟢 已实施(补全现有阅读答题:article_quiz_records 表 + LangGraph article_quiz_graph 出题 + AiGateway 接入 + 提交答题落库 + CF值/五维能量双奖励 + profile_snapshot.quiz_summary 认知雷达) | 文章阅读后答题奖励 + 认知雷达记录设计 |
 | `2026-09-22-guide-popup-config-design.md` | ✅ 已实施(4表/guide_popup+step+trigger+log,9接口,ConfigurableGuide.vue组件+管理端2页;旧 sys_config 方案保留并行) | 弹窗引导页配置化管理设计 |
@@ -383,6 +385,9 @@
 
 | 文件 | 状态 | 说明 |
 |------|:----:|------|
+| `2026-10-09-indicator-change-curve-backend.md` | 🟢 已实施(11 任务全部完成:迁移 356-362 两新表 indicator_mapping_rules/indicator_correlations + indicator_definitions/indicator_values 升级、实体/Mapper、ReportDateExtractor(keyset 游标防挂死)、SpearmanUtil、IndicatorMappingService、NormalizeIndicatorPipeline 单一写者(三钩子)、IndicatorTrendService、IndicatorCorrelationService、4 视图 API + 2 admin Controller 5 端点、每日 03:00 IndicatorCalibrationJob、API_REFERENCE 4.48 同步;7 个新增单测类共 24 用例全绿,compile/test-compile EXIT=0) | 指标变化曲线·速查·关联 — 后端实施计划(设计稿:2026-10-09-indicator-change-curve-design.md) |
+| `2026-10-09-indicator-change-curve-miniapp.md` | 🟡 待执行(M1-M9:TrendChart canvas 组件 + 指标曲线页 + 指标速查页 + 菌群详情变化区块 + pages.json 注册 + utils/api.js 4 封装 + 单测;验证=提取 script + node --check) | 指标变化曲线·速查 — 小程序实施计划(P4,依赖后端 P1-P3) |
+| `2026-10-09-indicator-change-curve-webadmin.md` | 🟡 待执行(W1-W6 TDD:src/api/indicator.js 6 封装 + IndicatorCorrelations.vue + IndicatorMappings.vue + utils/indicatorSeeds.js + 路由注册 + 菜单回退项 + jest axios-mock-adapter 单测 + npx vue-cli-service build 门禁;零后端改动,仅复用 5 个 admin 端点) | 指标关联/映射 — Web 管理端实施计划(P5,依赖后端 P4-P5) |
 | `2026-10-04-plan-review-workbench.md` | 🟢 已实施(15 任务全部完成:0 修 test-compile 基线、1 GuideFamilyAccessGuard、2 confirmBind 重绑复位、3 七个端点接入绑定校验、4 rejectPlan 状态守卫 + getPlanForFamily IDOR 反查、5 approveAndPublish 返回 taskGenerated(新增 PlanApproveResultDTO)、6 GuidePlanReviewController 方案审核 4 端点(含 planId IDOR 反查,6/6 绿)、7 GuideFamilyQueryService/Controller 新增 my-families + members 端点(授权源与 isBound 同源,4/4 绿)、8 后端整体验证(compile/test-compile EXIT 0 + 五个新增单测类共 32 个用例全绿 + 全仓 1688 条映射零重复路由)、9 utils/api.js 新增审核工作台 8 个 API 封装(各导出唯一,语法校验须用 .mjs 副本)、10 review-workbench.vue 审核台首页、11 review-family.vue 单家庭双 Tab(Task 用 deadline 非 dueDate;HealthPlan 无 title)、12 plan-detail.vue 方案详情(含 taskGenerated=false 部分成功提示;updatePlanContent 用 `if (planJson != null)` 守卫,3 参调用不会清空 plan_json)、13 pages.json 分包注册 3 页面(裸 path)+ families.vue 加入口、14 文档同步 API_REFERENCE 4.47;计划本身核实并修正 26 处初版事实错误) | 规划师审核工作台实现计划(拆 2 层编排:后端并行 + 前端并行;验收门为 `mvn test-compile` + 纯 Mockito 单测,不依赖真实数据库。⚠️ `pom.xml:28` 有 `<skipTests>true</skipTests>`,跑单测必须显式 `-DskipTests=false`,否则只输出 `Tests are skipped` 的假通过。⚠️ 路由查重必须按「类级 @RequestMapping 前缀 + 方法级字面量」归一化比较,只比方法字面量会产生假阳性) |
 | `2026-09-10-report-quick-analyze.md` | 🟡 待执行(5 任务:Redis 依赖/配置 + AIChatController 抽 buildChatInputs 新增 report/analyze 端点 + api.js 封装 + report-detail 浮层 + 验证) | 报告快速分析实现计划 |
 | `2026-09-10-key-person-expansion-octopus.md` | 🟡 待执行(12 任务:迁移305-307 三表 + 3实体/Mapper/Service/Controller + 新 OctopusDiagram 三层画布 + 成效/关键人录入页 + action-detail 集成 + API 文档同步 + 旧章鱼图清理) | 关键人拓展章鱼图替换实现计划 |

+ 89 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1096,6 +1096,64 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ---
 
+### 4.36.2 舌诊 AI 集成(`/api/health/report/*`)
+
+ 舌诊功能已集成 `aikanshe.com`(愛看舌)开放 API,**作为首选方案**;当 aikanshe.com 不可用时回退至 LangGraph Python 服务。调用流程如下:
+
+**请求路径**:`POST /api/health/report/parse-preview`(`type=tongue`)
+
+**请求体示例**:
+```json
+{
+  "type": "tongue",       // 必填:指定舌诊流程
+  "memberId": 12345,      // 可选:家庭成员 ID
+  "filePath": "/data/...",// 可选:图片路径(auto模式会先检测}
+}
+```
+
+**响应结构**:
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "recordId": 67890,               // 舌诊记录 ID
+    "imageUrl": "http://...",        // 图片访问 URL
+    "overallAssessment": "舌象基本正常...", // 整体评估
+    "indicators": [                  // 7类舌诊指标
+      {"code": "tongue_color", "value": "淡红"},
+      {"code": "coating_color", "value": "薄白"},
+      {"code": "coating_texture", "value": "润"},
+      {"code": "fissure", "value": "无"},
+      {"code": "teeth_mark", "value": "无齿痕"},
+      {"code": "sublingual_vein", "value": "无点刺"},
+      {"code": "constitution", "value": "平和质"}
+    ],
+    "createTime": "2026-10-08T..."
+  }
+}
+```
+
+**优先级方案**(aikanshe.com 优先,LangGraph 兜底):
+1. **首选**:调用 `aikanshe.com` API(`POST /agency/quanxi`,基于 AI 视觉模型)
+2. **兜底**:若 aikanshe.com 超时/失败,自动回退至 LangGraph Python 服务(`/api/v1/tongue/diagnose`)
+3. **最终兜底**:两者均失败时返回默认 mock 数据,确保业务不中断
+
+**置信度阈值**:默认 `0.2`,可通过 `application.yml` 的 `aikanshe.confidence` 配置。
+
+**相关配置**:
+```yaml
+aikanshe:
+  appkey: ${AIKANSHE_APPKEY:}
+  base-url: ${AIKANSHE_BASE_URL:https://api.aikanshe.com}
+  token: ${AIKANSHE_TOKEN:}
+  confidence: ${AIKANSHE_CONFIDENCE:0.2}
+  lang: ${AIKANSHE_LANG:zh-CN}
+```
+
+**已废弃接口(保留,待清理)**:
+- `POST /api/health/report/upload-only`(PDF流程入口,已被 `/api/health/report/parse-preview?type=tongue` 部分取代)
+
 ### 4.37 报告反馈与 AI 解答(`/api/health/feedback/*`)
 
 用户对健康报告提交疑问/建议,后端**持久化**到 `report_feedback` 表并调用 AI 解答(经 `AiGateway`,免费版每日 1 次,付费版不限)。反馈记录含 `aiAnswer`/`aiStatus`,前端可据此展示 AI 解答结果。
@@ -1538,6 +1596,37 @@ interactionType 取值:`PHONE` / `WECHAT` / `MEETING` / `OTHER`。
 
 ---
 
+### 4.48 指标统一层(`/api/indicator/*` + `/api/admin/indicator/*`)
+
+指标变化曲线 / 指标速查 / 指标关联的统一读接口与管理端接口。读接口 `subjectId` 必填且做家庭归属校验(`family_members.id = subjectId AND family_id = 当前用户家庭`,由 `JwtInterceptor` 注入 `@RequestAttribute("familyId")`);管理端接口校验 `@RequestAttribute("role")`,仅 `role=admin` 可访问,否则「无权操作」。
+
+**视图接口(`IndicatorController`,`/api/indicator`)**
+
+| 端点 | 请求要点 | 用途 |
+|---|---|---|
+| `POST /api/indicator/trend` | `{subjectId, definitionIds:[...]}` | 指标曲线(按 definitionId 分组、report_date 升序点序列) |
+| `POST /api/indicator/change-ranking` | `{subjectId, reportType?, category?, scope:adjacent\|first-vs-latest, orderBy:up\|down\|magnitude, limit}` | 变化幅度排名(含 `overall.improvedCount/worsenedCount/flatCount` 汇总) |
+| `POST /api/indicator/quick-lookup` | `{subjectId, category?, page, pageSize}` | 指标速查(分类分页 + 类别汇总 + `dimensionScores` 五/七维评分小节) |
+| `POST /api/indicator/correlation/list` | `{subjectId, definitionId}` | 指标关联查询(含 `subjectEvidence` 成员级配对证据) |
+
+**管理端接口(`/api/admin/indicator/correlation/*`、`/api/admin/indicator/mapping/*`)**
+
+| 端点 | 请求要点 | 用途 |
+|---|---|---|
+| `POST /api/admin/indicator/correlation/save` | `{definitionIdA, definitionIdB, direction, manualStrength, description, manualLocked, sourceNote}` | 关联对定义/编辑(写入前按 `indicator_definitions.code` 字典序强制对序,保证唯一键对称) |
+| `POST /api/admin/indicator/correlation/list` | `{page, size}` | 关联对分页(按 `updated_at` 倒序,含校准状态) |
+| `POST /api/admin/indicator/correlation/calibrate` | `{pairId?}` | 手动触发校准(返回「校准完成,共更新 N 对」) |
+| `POST /api/admin/indicator/mapping/import` | `{sourceType, seeds:[{rawName, definitionId?}]}` | 映射种子批量导入(返回「导入完成,共 N 条」) |
+| `POST /api/admin/indicator/mapping/list` | `{page, size, status?, sourceType?}` | 映射规则分页/review(`status=0` 为 ai_fallback 待确认) |
+
+> **关联校准**:`save` 时 `effective_strength = manual_strength`;每日 03:00 `IndicatorCalibrationJob`(`@Scheduled(cron = "0 0 3 * * ?")`)对 `status=active` 且 `manual_locked=0` 的关联对按 subject 分组做 Spearman + Fisher z 聚合,有效样本 `>=30` 才用 `computed_strength` 覆盖 `manual_strength`。`correlation/calibrate` 手动触发同一逻辑(`pairId` 传入时仍走全量逐对幂等)。
+> **统一指标层写入(单一写者)**:报告入库 / 报告编辑 / 食材推荐刷新三处流程由 `NormalizeIndicatorPipeline` 幂等写入 `indicator_values`(唯一键 `source_type + source_id + definition_id`,`upsertMapping` ON DUPLICATE KEY)。本层读接口只读 `indicator_values`,不写时序;五维/七维评分在速查接口按原表 join,不进时序。
+> **关联表**:`indicator_correlations`(迁移 360)、`indicator_mapping_rules`(迁移 359)。
+
+*文档更新:2026-10-09(指标统一层新增,见 `specs/2026-10-09-indicator-change-curve-design.md`)*
+
+---
+
 *文档最后更新:2026-10-04*
 
 ---

+ 3174 - 0
docs/superpowers/plans/2026-10-09-indicator-change-curve-backend.md

@@ -0,0 +1,3174 @@
+# 指标变化曲线 · 指标速查 · 指标关联 — 后端实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+>
+> 本计划覆盖设计文档 P1(数据层)+ P2(归一化管道)+ P3(视图 API)+ P6(校准任务)四个后端分期。小程序(P4)与管理端(P5)是独立计划,不在此文件。
+
+**目标:** 将 `indicator_definitions`/`indicator_values` 升级为全平台统一指标层:真实报告日期、归一化映射管道(exact→fuzzy→AI→惰性注册)、指标变化曲线/变化幅度排名/指标速查 3 个视图接口、指标关联定义与 Spearman 数据校准。
+
+**架构:** 复用现有 `health_reports`/`health_indicators`/`food_recommend_idx` 作为原始数据,新增 `indicator_mapping_rules` + `indicator_correlations` 两表;`NormalizeIndicatorPipeline` 作为单一写者在报告入库/编辑/食材重算三个钩子处幂等写入 `indicator_values`(唯一键 `(source_type, source_id, definition_id)`);视图层 `IndicatorTrendService` 只读 `indicator_values`,五维/七维评分 API 层单独合入不写入时序;校准用纯 Java 实现的 Spearman 秩相关 + Fisher z 聚合,`@Scheduled` 每日自动跑 + 管理端手动触发。
+
+**技术栈:** Java 8(禁止 var/List.of/record)、Spring Boot 2.7.18、MyBatis-Plus(`LambdaQueryWrapper`)、JUnit5 + Mockito(参照 `FoodServiceTest`,不加载 Spring 上下文)、MySQL 8(utf8mb4)、FastAPI/Pydantic(cfc-langgraph 侧 AI 归类端点)。
+
+---
+
+## 文件结构
+
+| 文件 | 操作 | 职责 |
+|---|---|---|
+| `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 改 | 迁移 356~362(见任务 1) |
+| `cfc-backend/src/main/resources/schema.sql` | 改 | 同步 indicator_definitions / indicator_values / 新两表的完整 DDL 快照 |
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java` | 改 | +5 列字段与 getter/setter |
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java` | 改 | +6 列字段与 getter/setter |
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java` | 新建 | 映射规则实体(Lombok @Data) |
+| `cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java` | 新建 | 关联关系实体(Lombok @Data) |
+| `cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java` | 新建 | 空 BaseMapper |
+| `cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java` | 新建 | 空 BaseMapper |
+| `cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java` | 改 | 新增 `upsertMapping` 幂等写方法(@Insert ON DUPLICATE KEY UPDATE) |
+| `cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java` | 新建 | Spearman rho + Fisher z 聚合(纯函数,约 40 行) |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java` | 新建 | payload 真实报告日期提取 + 历史回填分批任务 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java` | 新建 | resolve(raw)→definitionId(exact→fuzzy→AI→惰性注册);importSeeds |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java` | 新建 | 报告指标/菌属 + 食材快照 幂等写入 indicator_values |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java` | 新建 | trend / change-ranking / quick-lookup(含五维七维小节) |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java` | 新建 | 关联 CRUD + 对序强制 + effective_strength + Spearman 校准 |
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java` | 新建 | /api/indicator/* 4 个视图接口(subjectId 归属校验) |
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java` | 新建 | 关联 save/list/calibrate |
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java` | 新建 | 映射 import/list |
+| `cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java` | 新建 | @Scheduled 每日 03:00 自动校准 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java` | 改 | +classifyIndicator(照 generatePortrait 模板) |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java` | 改 | insertReport() subjectId 块后调 pipeline(钩子 1) |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java` | 改 | updateReportFromPayload() 步骤 5 后调 pipeline(钩子 2) |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java` | 改 | recalculateForUser() 尾部调 pipeline.snapshotFoodIndices(钩子 3) |
+| `cfc-langgraph/app/api/indicator_classify.py` | 新建 | POST /api/v1/indicator/classify(AI 归类兜底) |
+| `cfc-langgraph/app/main.py` | 改 | 注册新 router |
+| `docs/superpowers/api/API_REFERENCE.md` | 改 | 同步记录 9 个新接口 |
+
+**关键决策(锁定,不得自行更改):**
+
+1. **迁移编号从 356 起**(最新现有迁移为 355,`DatabaseInitializer.java` L11855-11873)。
+2. **`indicator_values` 唯一键** `uk_source_definition (source_type, source_id, definition_id)` —— 报告指标/菌属写 `source_id = health_reports.id`,食物指数写 `source_id = food_recommend_idx.id`。重解析/重算同键覆盖更新,保证验收标准 6(幂等)。
+3. **`idx_def` 不再新增**:schema.sql 中该表已有 `idx_definition (definition_id)`,语义等价,迁移只加 `uk_source_definition` 与 `idx_subject_def_date`,避免重复索引。
+4. **新实体用 Lombok `@Data`**(与 `HealthIndicator`/`FoodRecommendIndex` 一致);`IndicatorDefinition`/`IndicatorValue` 维持手工 getter/setter(既有风格,只加字段)。
+5. **惰性注册 code 规则**:`indicator.<category>.<name>`(报告指标)/ `bacteria.<name>`(菌属)/ `food.<name>`(食材,不依赖 foodId 表,raw_name 即食材名)。`source_kind` 对应 `report_indicator`/`bacteria`/`food_index`。
+6. **映射规则表 `source_type` 统一用报告类型**(`gut_flora` 等):报告指标、菌属、食材三条来源都以 `source_type=报告类型` 登记,raw_name 互不相同故不冲突;`food_recommend` 仅作为 indicator_values.source_type 语义保留(食材快照走钩子 3 时 source_type=报告类型,report_id 关联)。
+7. **报告日期回填不伪装上传时间**:提取失败置 `report_date = NULL`;回填筛选条件带 `payload_json LIKE '%reportDate%'`,避免对提取失败的记录无限重复处理。
+8. **五维/七维不进 indicator_values**:quick-lookup 的 dimensionScores 小节在 `IndicatorTrendService` 内 join 原表(`five_dimension_scores` / `health_dimension_scores`)。
+9. **AI 兜底只建议**:`AiGateway.classifyIndicator` 返回建议的 definition code/name,`IndicatorMappingService` 将其登记为 `match_type='ai_fallback', status=0`(待确认),**不自动启用**;调用方拿到建议 definitionId 照常写入(AI 兜底路径允许临时写入,管理端 review 修正规则后重解析自动纠正)。
+10. **`jsonArray()` / `ensureReportTypeRow()` / `seedExpectedIndicator()`** 是 DatabaseInitializer 既有私有助手(L11978-12022),新迁移可复用;`ensureColumn(table,column,definition)` 在 L4855。
+11. **钩子事务边界**:钩子 2 所在 `updateReportFromPayload` 已标 `@Transactional`,管道与其同事务;钩子 1 的 `insertReport` 是私有非事务方法(由异步采集调用),管道方法自身标 `@Transactional(propagation = REQUIRED)` 随调用方;钩子 3 的 `recalculateForUser` 已标 `@Transactional`。**不要在管道中开启新事务的 REQUIRES_NEW**。
+12. **subjectId 归属校验**(视图接口):`@RequestAttribute("familyId")` 必须存在,`IndicatorController` 校验 `subjectId` 属于该家庭(查 `family_members` 表),不属于则返回 `Result.error("成员不属于当前家庭")`。
+
+---
+
+## 任务 1:数据库迁移(P1)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(`runMigrations()` L129 内、文件末 `}` L12023 前追加方法 + 在 runMigrations 中调用)
+- 修改:`cfc-backend/src/main/resources/schema.sql`(indicator_definitions 段约 L2501、indicator_values 段约 L2518,末尾追加两新表)
+
+### 步骤 1:迁移 356 — indicator_definitions 加 5 列
+
+在 `runMigrations()` 末尾(最后一个迁移方法调用之后)追加调用 `migrateIndicatorDefinitionsColumns()`,并新增该方法:
+
+```java
+// 迁移356: indicator_definitions 升级为统一指标层(指标变化曲线/速查/关联 需求)
+private void migrateIndicatorDefinitionsColumns() {
+    ensureColumn("indicator_definitions", "source_kind",
+            "VARCHAR(32) DEFAULT NULL COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score'");
+    ensureColumn("indicator_definitions", "report_type_scope",
+            "VARCHAR(32) DEFAULT NULL COMMENT '适用报告类型(type_id,空=通用)'");
+    ensureColumn("indicator_definitions", "value_type",
+            "VARCHAR(16) DEFAULT NULL COMMENT '语义类型: numeric/categorical/level'");
+    ensureColumn("indicator_definitions", "good_direction",
+            "VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral'");
+    ensureColumn("indicator_definitions", "dimension_code",
+            "VARCHAR(16) DEFAULT NULL COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)'");
+    log.info("迁移356: indicator_definitions 已补齐统一指标层 5 列");
+}
+```
+
+### 步骤 2:迁移 357 — indicator_values 加 6 列
+
+```java
+// 迁移357: indicator_values 升级为统一观测时序(subject_id/family_id/report_id/report_date/unit/status)
+private void migrateIndicatorValuesColumns() {
+    ensureColumn("indicator_values", "subject_id", "BIGINT DEFAULT NULL COMMENT '观测对象(family_members.id)'");
+    ensureColumn("indicator_values", "family_id", "BIGINT DEFAULT NULL COMMENT '所属家庭(family_members.family_id)'");
+    ensureColumn("indicator_values", "report_id", "BIGINT DEFAULT NULL COMMENT '来源报告(health_reports.id,食物指数可空)'");
+    ensureColumn("indicator_values", "report_date", "DATE DEFAULT NULL COMMENT '冗余真实报告日期(时序查询键)'");
+    ensureColumn("indicator_values", "unit", "VARCHAR(32) DEFAULT NULL COMMENT '观测单位'");
+    ensureColumn("indicator_values", "status", "VARCHAR(32) DEFAULT NULL COMMENT '观测状态快照(正常/偏高/偏低等)'");
+    log.info("迁移357: indicator_values 已补齐统一观测时序 6 列");
+}
+```
+
+### 步骤 3:迁移 358 — 唯一键 + 时序索引
+
+```java
+// 迁移358: indicator_values 幂等唯一键(source_type+source_id+definition_id)+ 时序查询索引
+private void addIndicatorValuesKeys() {
+    try {
+        // 先清理极端重复(保留最小 id),避免建唯一键失败;正常数据本无重复
+        jdbcTemplate.execute(
+                "DELETE v FROM indicator_values v " +
+                "JOIN (SELECT MIN(id) keep_id, source_type, source_id, definition_id " +
+                "      FROM indicator_values " +
+                "      GROUP BY source_type, source_id, definition_id " +
+                "      HAVING COUNT(*) > 1) d " +
+                "  ON v.source_type = d.source_type AND v.source_id = d.source_id " +
+                " AND v.definition_id = d.definition_id AND v.id <> d.keep_id");
+        log.info("迁移358: 已清理 indicator_values 重复行(如有)");
+    } catch (Exception e) {
+        log.warn("迁移358: 清理 indicator_values 重复行失败(表可能为空): {}", e.getMessage());
+    }
+    try {
+        jdbcTemplate.execute("ALTER TABLE indicator_values ADD UNIQUE KEY uk_source_definition (source_type, source_id, definition_id)");
+        log.info("迁移358: 已添加 uk_source_definition 唯一键");
+    } catch (Exception e) {
+        log.info("迁移358: uk_source_definition 已存在,跳过");
+    }
+    try {
+        jdbcTemplate.execute("ALTER TABLE indicator_values ADD INDEX idx_subject_def_date (subject_id, definition_id, report_date)");
+        log.info("迁移358: 已添加 idx_subject_def_date 索引");
+    } catch (Exception e) {
+        log.info("迁移358: idx_subject_def_date 已存在,跳过");
+    }
+}
+```
+
+### 步骤 4:迁移 359 + 360 — 新建两表
+
+```java
+// 迁移359: 新建 indicator_mapping_rules(指标归一化映射规则)
+private void createIndicatorMappingRulesTable() {
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_mapping_rules (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                "source_type VARCHAR(32) NOT NULL COMMENT '来源: gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension'," +
+                "raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)'," +
+                "alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配'," +
+                "definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id'," +
+                "match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created'," +
+                "priority INT DEFAULT 0 COMMENT '多规则冲突时取最高'," +
+                "status TINYINT DEFAULT 1," +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                "UNIQUE KEY uk_source_raw (source_type, raw_name)," +
+                "INDEX idx_def (definition_id)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则'");
+        log.info("迁移359: 已创建 indicator_mapping_rules 表");
+    } catch (Exception e) {
+        log.warn("迁移359: 创建 indicator_mapping_rules 表失败: {}", e.getMessage());
+    }
+}
+
+// 迁移360: 新建 indicator_correlations(指标关联关系:人工定义 + 数据校准)
+private void createIndicatorCorrelationsTable() {
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS indicator_correlations (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+                "definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标'," +
+                "definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标'," +
+                "relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation|influence|same_source'," +
+                "direction VARCHAR(10) NOT NULL COMMENT 'positive|negative'," +
+                "manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1'," +
+                "computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)'," +
+                "method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman'," +
+                "sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数'," +
+                "confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)'," +
+                "effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值: 样本>=30 时取 computed 否则取 manual'," +
+                "manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,校准不覆盖'," +
+                "cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population'," +
+                "status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending'," +
+                "computed_at DATETIME NULL COMMENT '最后校准时间'," +
+                "created_by BIGINT NULL COMMENT '创建者'," +
+                "description VARCHAR(500) NULL COMMENT '给用户看的解释'," +
+                "source_note VARCHAR(255) NULL COMMENT '依据/出处'," +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+                "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP," +
+                "UNIQUE KEY uk_pair (definition_id_a, definition_id_b)," +
+                "INDEX idx_def_a (definition_id_a)," +
+                "INDEX idx_def_b (definition_id_b)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义+数据校准)'");
+        log.info("迁移360: 已创建 indicator_correlations 表");
+    } catch (Exception e) {
+        log.warn("迁移360: 创建 indicator_correlations 表失败: {}", e.getMessage());
+    }
+}
+```
+
+### 步骤 5:迁移 361 + 362 — collection_date 兜底 + 五维定义种子
+
+```java
+// 迁移361: health_indicators.collection_date 生产库兜底(schema.sql 已有该列,仅确保老库补齐)
+private void ensureIndicatorCollectionDate() {
+    ensureColumn("health_indicators", "collection_date",
+            "DATETIME COMMENT '指标采集日期(报告检测日期)'");
+    log.info("迁移361: health_indicators.collection_date 已确认存在");
+}
+
+// 迁移362: 预置五维展示定义(dim.body 等,仅作展示标签,不写 indicator_values)
+private void seedDimensionDefinitions() {
+    Object[][] dims = {
+        {"dim.body",     "身", "身体",  "higher_is_better", "higher_is_better"},
+        {"dim.mind",     "心", "心理",  "higher_is_better", "higher_is_better"},
+        {"dim.wisdom",   "智", "智慧",  "higher_is_better", "higher_is_better"},
+        {"dim.action",   "行", "行动",  "higher_is_better", "higher_is_better"},
+        {"dim.wealth",   "富", "财富",  "higher_is_better", "higher_is_better"},
+    };
+    for (Object[] d : dims) {
+        try {
+            Integer exists = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM indicator_definitions WHERE code = ?", Integer.class, (String) d[0]);
+            if (exists == null || exists == 0) {
+                jdbcTemplate.update(
+                        "INSERT INTO indicator_definitions (domain, category, code, name, data_type, unit, " +
+                        "source_kind, value_type, good_direction, dimension_code, sort_order, status) " +
+                        "VALUES ('physical', ?, ?, ?, 'number', '分', 'dimension_score', 'numeric', ?, ?, 0, 1)",
+                        d[1], d[0], d[2], d[4], d[3]);
+                log.info("迁移362: 已预置维度定义 {}", d[0]);
+            }
+        } catch (Exception e) {
+            log.warn("迁移362: 预置维度定义 {} 失败: {}", d[0], e.getMessage());
+        }
+    }
+}
+```
+
+在 `runMigrations()` 末尾追加调用:
+
+```java
+// 指标统一层迁移(356~362)
+migrateIndicatorDefinitionsColumns();
+migrateIndicatorValuesColumns();
+addIndicatorValuesKeys();
+createIndicatorMappingRulesTable();
+createIndicatorCorrelationsTable();
+ensureIndicatorCollectionDate();
+seedDimensionDefinitions();
+```
+
+### 步骤 6:同步 schema.sql(4 处 DDL)
+
+1. `indicator_definitions` CREATE TABLE(约 L2501)在 `sort_order` 前追加 5 列,与迁移 356 同义:
+
+```sql
+CREATE TABLE IF NOT EXISTS indicator_definitions (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    domain VARCHAR(50) NOT NULL COMMENT '领域: physical/mental/nutrition/behavior/social',
+    category VARCHAR(50) NOT NULL COMMENT '分类: 舌色/苔色/情绪/...',
+    code VARCHAR(50) NOT NULL UNIQUE COMMENT '编码: tongue_color/coating_color/...',
+    name VARCHAR(100) NOT NULL COMMENT '显示名: 舌体颜色/苔色/...',
+    data_type VARCHAR(20) NOT NULL DEFAULT 'enum' COMMENT '数据类型: enum/number/string',
+    options JSON COMMENT '枚举值列表: ["淡红","红","绛","青紫"] 或 null',
+    unit VARCHAR(50) COMMENT '单位',
+    ref_range VARCHAR(200) COMMENT '参考范围',
+    source_kind VARCHAR(32) COMMENT '指标来源: report_indicator/bacteria/food_index/dimension_score',
+    report_type_scope VARCHAR(32) COMMENT '适用报告类型(type_id,空=通用)',
+    value_type VARCHAR(16) COMMENT '语义类型: numeric/categorical/level',
+    good_direction VARCHAR(16) DEFAULT 'neutral' COMMENT '变化优劣方向: higher_is_better/lower_is_better/in_range_is_better/neutral',
+    dimension_code VARCHAR(16) COMMENT '关联五维(body/mind/wisdom/action/wealth,可空)',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    status TINYINT DEFAULT 1 COMMENT '0=禁用 1=启用',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标定义';
+```
+
+2. `indicator_values` CREATE TABLE(约 L2518)替换为:
+
+```sql
+CREATE TABLE IF NOT EXISTS indicator_values (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    source_type VARCHAR(50) NOT NULL COMMENT '来源: tongue_record/health_report/assessment',
+    source_id BIGINT NOT NULL COMMENT '来源记录ID',
+    definition_id BIGINT NOT NULL COMMENT 'FK→indicator_definitions.id',
+    value VARCHAR(200) COMMENT '值',
+    numeric_value DECIMAL(10,2) COMMENT '数值(number类型时用)',
+    remark TEXT COMMENT '备注',
+    subject_id BIGINT COMMENT '观测对象(family_members.id)',
+    family_id BIGINT COMMENT '所属家庭(family_members.family_id)',
+    report_id BIGINT COMMENT '来源报告(health_reports.id)',
+    report_date DATE COMMENT '真实报告日期(时序查询键)',
+    unit VARCHAR(32) COMMENT '观测单位',
+    status VARCHAR(32) COMMENT '观测状态快照(正常/偏高/偏低等)',
+    recorded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_source_definition (source_type, source_id, definition_id),
+    INDEX idx_source (source_type, source_id),
+    INDEX idx_definition (definition_id),
+    INDEX idx_subject_def_date (subject_id, definition_id, report_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全域指标值';
+```
+
+3. 文件末尾追加 `indicator_mapping_rules`(与迁移 359 同 DDL)。
+4. 文件末尾追加 `indicator_correlations`(与迁移 360 同 DDL)。
+
+### 步骤 7:验证编译
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`(迁移代码幂等,无需运行时验证)
+
+### 步骤 8:Commit
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
+        cfc-backend/src/main/resources/schema.sql
+git commit -m "feat(indicator): 迁移356-362 升级统一指标层(列/键/两新表/维度种子)"
+```
+
+---
+
+## 任务 2:实体与 Mapper(P1)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java`
+
+### 步骤 1:IndicatorDefinition 加 5 字段
+
+在 `private Integer status;` 与 `private Date createdAt;` 之间加字段,并在对应位置补 getter/setter(文件为手工 getter/setter 风格,维持一致):
+
+```java
+    private String sourceKind;
+    private String reportTypeScope;
+    private String valueType;
+    private String goodDirection;
+    private String dimensionCode;
+```
+
+```java
+    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; }
+```
+
+### 步骤 2:IndicatorValue 加 6 字段
+
+在 `private String remark;` 与 `private Date recordedAt;` 之间加字段:
+
+```java
+    private Long subjectId;
+    private Long familyId;
+    private Long reportId;
+    private java.sql.Date reportDate;
+    private String unit;
+    private String status;
+```
+
+并补 getter/setter(`java.sql.Date` 与 `indicator_values.report_date DATE` 列类型一致):
+
+```java
+    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; }
+```
+
+### 步骤 3:创建 IndicatorMappingRule 实体 + Mapper
+
+`entity/IndicatorMappingRule.java`(完整文件):
+
+```java
+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;
+}
+```
+
+`mapper/IndicatorMappingRuleMapper.java`(完整文件):
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.IndicatorMappingRule;
+
+public interface IndicatorMappingRuleMapper extends BaseMapper<IndicatorMappingRule> {
+}
+```
+
+### 步骤 4:创建 IndicatorCorrelation 实体 + Mapper
+
+`entity/IndicatorCorrelation.java`(完整文件):
+
+```java
+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;
+}
+```
+
+`mapper/IndicatorCorrelationMapper.java`(完整文件):
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.IndicatorCorrelation;
+
+public interface IndicatorCorrelationMapper extends BaseMapper<IndicatorCorrelation> {
+}
+```
+
+### 步骤 5:IndicatorValueMapper 增加幂等 upsert 方法
+
+```java
+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.Param;
+
+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);
+}
+```
+
+(若该文件原有其他内容与 import,保留原 import 与原有方法,仅合并本方法。)
+
+### 步骤 6:编写失败测试验证 upsert
+
+`src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java`:
+
+```java
+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);
+    }
+}
+```
+
+### 步骤 7:运行测试验证编译 + 行为
+
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorValueMapperTest`
+预期:`Tests run: 1, Failures: 0`(若 MyBatis 注解 SQL 有语法错误会在启动 bean 时暴露,此处 mock 层验证注解可编译加载)
+
+### 步骤 8:验证全量编译
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`
+
+### 步骤 9:Commit
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorDefinition.java \
+        cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorValue.java \
+        cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorMappingRule.java \
+        cfc-backend/src/main/java/com/etotem/cfc/entity/IndicatorCorrelation.java \
+        cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorMappingRuleMapper.java \
+        cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorCorrelationMapper.java \
+        cfc-backend/src/main/java/com/etotem/cfc/mapper/IndicatorValueMapper.java \
+        cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorValueMapperTest.java
+git commit -m "feat(indicator): 实体/Mapper 支持统一指标层(cols/新表/幂等upsert)"
+```
+
+---
+
+## 任务 3:ReportDateExtractor(P1 — 真实报告日期提取 + 历史回填)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java`
+
+### 步骤 1:编写失败测试
+
+```java
+package com.etotem.cfc.service;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.text.SimpleDateFormat;
+
+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));
+    }
+}
+```
+
+### 步骤 2:运行确认失败
+
+运行:`cd cfc-backend && mvn test -Dtest=ReportDateExtractorTest`
+预期:`BUILD FAILURE`,`ReportDateExtractor` 不存在(编译错误)
+
+### 步骤 3:实现 ReportDateExtractor
+
+```java
+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;
+
+    /**
+     * 从 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;
+        while (true) {
+            List<HealthReport> batch = healthReportMapper.selectList(
+                    new LambdaQueryWrapper<HealthReport>()
+                            .isNull(HealthReport::getReportDate)
+                            .like(HealthReport::getPayloadJson, "reportDate")
+                            .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);
+                    } else {
+                        // 置 NULL 并清 payload 中可提取性:无法提取的不再反复处理
+                        report.setReportDate(null);
+                        healthReportMapper.updateById(report);
+                        log.info("报告日期无法提取,置 NULL reportId={}", report.getId());
+                    }
+                    total++;
+                } catch (Exception e) {
+                    log.warn("回填报告日期失败 reportId={}: {}", report.getId(), e.getMessage());
+                }
+            }
+        }
+        log.info("ReportDateExtractor 历史回填完成,共处理 {} 行", total);
+        return total;
+    }
+
+    /** 每日 03:10 自动回填(幂等可重跑,连跑两次结果一致) */
+    @Scheduled(cron = "0 10 3 * * ?")
+    public void scheduledBackfill() {
+        backfillHistoricalReports();
+    }
+}
+```
+
+### 步骤 4:运行测试确认通过
+
+运行:`cd cfc-backend && mvn test -Dtest=ReportDateExtractorTest`
+预期:`Tests run: 4, Failures: 0, Errors: 0`
+
+### 步骤 5:验证编译 + Commit
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ReportDateExtractor.java \
+        cfc-backend/src/test/java/com/etotem/cfc/service/ReportDateExtractorTest.java
+git commit -m "feat(indicator): ReportDateExtractor 真实报告日期提取+历史回填(幂等分批)"
+```
+
+---
+
+## 任务 4:SpearmanUtil(P6 — 校准算法纯函数)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java`
+
+### 步骤 1:编写失败测试
+
+```java
+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);
+    }
+}
+```
+
+### 步骤 2:运行确认失败
+
+运行:`cd cfc-backend && mvn test -Dtest=SpearmanUtilTest`
+预期:`BUILD FAILURE`,`SpearmanUtil` 不存在
+
+### 步骤 3:实现 SpearmanUtil
+
+```java
+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);
+    }
+}
+```
+
+### 步骤 4:运行测试确认通过
+
+运行:`cd cfc-backend && mvn test -Dtest=SpearmanUtilTest`
+预期:`Tests run: 6, Failures: 0, Errors: 0`
+
+### 步骤 5:Commit
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/util/SpearmanUtil.java \
+        cfc-backend/src/test/java/com/etotem/cfc/util/SpearmanUtilTest.java
+git commit -m "feat(indicator): SpearmanUtil 秩相关+Fisher z 聚合(纯函数,含并列秩)"
+```
+
+---
+
+## 任务 5:IndicatorMappingService(P2 — 归一化映射核心)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java`
+
+### 步骤 1:编写失败测试
+
+```java
+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.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));
+    }
+}
+```
+
+### 步骤 2:运行确认失败
+
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorMappingServiceTest`
+预期:`BUILD FAILURE`,`IndicatorMappingService` 不存在
+
+### 步骤 3:实现 IndicatorMappingService
+
+```java
+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.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;
+
+    @Resource
+    private AiGateway aiGateway;
+
+    @Resource
+    private ObjectMapper 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;
+    }
+}
+```
+
+### 步骤 4:运行测试确认通过
+
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorMappingServiceTest`
+预期:`Tests run: 4, Failures: 0, Errors: 0`
+
+### 步骤 5:验证编译 + Commit
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorMappingService.java \
+        cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorMappingServiceTest.java
+git commit -m "feat(indicator): IndicatorMappingService exact→fuzzy→AI→惰性注册 映射解析"
+```
+
+---
+
+## 任务 6:NormalizeIndicatorPipeline(P2 — 单一写者 + 三个钩子)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java`(insertReport 钩子)
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java`(updateReportFromPayload 钩子)
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java`(recalculateForUser 钩子)
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.java`
+
+### 步骤 1:编写失败测试
+
+```java
+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)));
+    }
+}
+```
+
+### 步骤 2:运行确认失败
+
+运行:`cd cfc-backend && mvn test -Dtest=NormalizeIndicatorPipelineTest`
+预期:`BUILD FAILURE`,`NormalizeIndicatorPipeline` 不存在
+
+### 步骤 3:实现 NormalizeIndicatorPipeline
+
+```java
+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;
+    }
+}
+```
+
+### 步骤 4:三个钩子接线
+
+**钩子 1 — `ReportCollectService.insertReport()`(L596-652)**:在 `reportBlockService.save(...)` try 块(L641-646)之后、`markCollectCompleted`(L648)之前插入:
+
+```java
+        // 归一化管道:统一指标层幂等写入(同流程,subjectId 非空才有效)
+        try {
+            normalizeIndicatorPipeline.processReport(created, payload);
+        } catch (Exception e) {
+            log.warn("归一化管道写入失败 reportId={}: {}", created.getId(), e.getMessage());
+        }
+```
+
+并在类顶部加注入(与既有 `@Resource` 并列):
+
+```java
+    @Resource
+    private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+```
+
+**钩子 2 — `HealthReportService.updateReportFromPayload()`**:在步骤 6(payload 快照,L1886-1904)之后、方法结束前插入:
+
+```java
+        // 归一化管道:编辑后重写统一指标层(同事务;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());
+        }
+```
+
+(若类中已注入 `normalizeIndicatorPipeline`,复用;未注入则在字段区补 `@Resource private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;`)
+
+**钩子 3 — `FoodRecommendService.recalculateForUser()`**:在方法末尾(`existingMap` 删除循环 L143-159 之后、`}` 之前)插入:
+
+```java
+        // 归一化管道:全量快照最新食材推荐指数到 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());
+        }
+```
+
+并补注入字段:
+
+```java
+    @Resource
+    private com.etotem.cfc.service.NormalizeIndicatorPipeline normalizeIndicatorPipeline;
+```
+
+### 步骤 5:运行测试确认通过
+
+运行:`cd cfc-backend && mvn test -Dtest=NormalizeIndicatorPipelineTest`
+预期:`Tests run: 3, Failures: 0, Errors: 0`
+
+### 步骤 6:验证编译 + Commit
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/NormalizeIndicatorPipeline.java \
+        cfc-backend/src/main/java/com/etotem/cfc/service/ReportCollectService.java \
+        cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java \
+        cfc-backend/src/main/java/com/etotem/cfc/service/FoodRecommendService.java \
+        cfc-backend/src/test/java/com/etotem/cfc/service/NormalizeIndicatorPipelineTest.java
+git commit -m "feat(indicator): NormalizeIndicatorPipeline 单一写者+三钩子(入库/编辑/食材快照)"
+```
+
+---
+
+## 任务 7:AiGateway 指标归类兜底 + LangGraph endpoint(P2)
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java`
+- 创建:`cfc-langgraph/app/api/indicator_classify.py`
+- 修改:`cfc-langgraph/app/main.py`
+
+### 步骤 1:AiGateway 新增 classifyIndicator 方法
+
+在 `AiGateway.java` 的 `generatePortrait` 方法(L392-416)之后追加(全套模板与既有方法一致:`!enabled || isCircuitOpen()` 短路 + `createJsonHeaders()` + `postForEntity` + `consecutiveFailures.set(0)`/`recordFailure()`):
+
+```java
+    /**
+     * 指标归类兜底:将未命中的原始指标名交给 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;
+        }
+    }
+```
+
+(若文件顶部未 import `ObjectNode`/`ArrayNode`/`HttpEntity`/`ResponseEntity`,与既有方法共用全限定名或补充 import;与 `generatePortrait` 使用一致即可。)
+
+### 步骤 2:创建 LangGraph endpoint
+
+`cfc-langgraph/app/api/indicator_classify.py`(完整文件,沿用 report_parse.py 的 router + pydantic 模式;LLM 调用沿用 self_check_analysis_graph 的 `get_llm().ainvoke` 模式):
+
+```python
+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={})
+```
+
+### 步骤 3:main.py 注册 router
+
+在 `cfc-langgraph/app/main.py` 的 include_router 区块(L18 起)追加注册:
+
+```python
+from app.api import indicator_classify
+...
+app.include_router(indicator_classify.router)
+```
+
+(import 放文件顶部 import 区;按项目既有 `from app.api import ...` 风格。**注意:`self_check.router` 被注册了两次是既有 bug,不要模仿重复注册;本 router 只注册一次。**)
+
+### 步骤 4:验证
+
+运行:`cd cfc-langgraph && python -m py_compile app/api/indicator_classify.py app/main.py`
+预期:退出码 0,无语法错误
+
+### 步骤 5:Commit
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java \
+        cfc-langgraph/app/api/indicator_classify.py \
+        cfc-langgraph/app/main.py
+git commit -m "feat(indicator): AiGateway.classifyIndicator + LangGraph /api/v1/indicator/classify"
+```
+
+---
+
+## 任务 8:IndicatorTrendService(P3 — 曲线 / 变化排名 / 速查)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java`
+
+### 步骤 1:编写失败测试
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.IndicatorValue;
+import com.etotem.cfc.mapper.IndicatorValueMapper;
+import com.etotem.cfc.mapper.FiveDimensionScoreMapper;
+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;
+
+    @BeforeEach
+    void setUp() {
+        service = new IndicatorTrendService();
+        valueMapper = mock(IndicatorValueMapper.class);
+        ReflectionTestUtils.setField(service, "valueMapper", valueMapper);
+    }
+
+    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());
+    }
+}
+```
+
+### 步骤 2:运行确认失败
+
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorTrendServiceTest`
+预期:`BUILD FAILURE`,`IndicatorTrendService` 不存在
+
+### 步骤 3:实现 IndicatorTrendService
+
+```java
+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.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 "中性";
+        }
+    }
+}
+```
+
+> 需要 `HealthDimensionScoreMapper`(若不存在则先创建空 BaseMapper;`FiveDimensionScoreMapper` 若已有则复用)。若 `health_dimension_scores` 表访问导致编译失败,先确认两个 mapper 是否存在:
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HealthDimensionScore;
+
+public interface HealthDimensionScoreMapper extends BaseMapper<HealthDimensionScore> {
+}
+```
+
+### 步骤 4:运行测试确认通过
+
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorTrendServiceTest`
+预期:`Tests run: 3, Failures: 0, Errors: 0`
+
+### 步骤 5:验证编译 + Commit
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java \
+        cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthDimensionScoreMapper.java \
+        cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorTrendServiceTest.java
+git commit -m "feat(indicator): IndicatorTrendService 曲线/变化排名/速查(含维度评分小节)"
+```
+
+---
+
+## 任务 9:IndicatorCorrelationService(P3+P6 — 关联 CRUD + 校准)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java`
+- 测试:`cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.java`
+
+### 步骤 1:编写失败测试
+
+```java
+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()));
+    }
+}
+```
+
+### 步骤 2:运行确认失败
+
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorCorrelationServiceTest`
+预期:`BUILD FAILURE`,`IndicatorCorrelationService` 不存在
+
+### 步骤 3:实现 IndicatorCorrelationService
+
+```java
+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;
+    }
+}
+```
+
+### 步骤 4:运行测试确认通过
+
+运行:`cd cfc-backend && mvn test -Dtest=IndicatorCorrelationServiceTest`
+预期:`Tests run: 1, Failures: 0, Errors: 0`
+
+### 步骤 5:验证编译 + Commit
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorCorrelationService.java \
+        cfc-backend/src/test/java/com/etotem/cfc/service/IndicatorCorrelationServiceTest.java
+git commit -m "feat(indicator): IndicatorCorrelationService 关联CRUD+对序强制+Spearman校准"
+```
+
+---
+
+## 任务 10:控制器(P3+P5 后端部分) + 校准 Job(P6)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java`
+
+### 步骤 1:IndicatorController(4 个视图接口,subjectId 归属校验)
+
+```java
+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));
+    }
+}
+```
+
+> 备注:`FamilyMemberMapper` 与 `FamilyMember` 实体若已存在则直接复用;若不存在,按既有实体/Mapper 模式创建一个最小版本(id/familyId 字段 + BaseMapper)。
+
+### 步骤 2:IndicatorCorrelationAdminController
+
+```java
+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 + " 对");
+    }
+}
+```
+
+### 步骤 3:IndicatorMappingAdminController
+
+```java
+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);
+    }
+}
+```
+
+### 步骤 4:IndicatorCalibrationJob(@Scheduled 每日)
+
+```java
+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);
+        }
+    }
+}
+```
+
+### 步骤 5:验证编译
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:`BUILD SUCCESS`
+
+### 步骤 6:新增接口前查重 + Commit
+
+运行:`cd cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u`
+确认无 `/api/indicator/trend`、`/api/indicator/change-ranking`、`/api/indicator/quick-lookup`、`/api/indicator/correlation/list`、`/api/admin/indicator/correlation/*`、`/api/admin/indicator/mapping/*` 重复。
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java \
+        cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorCorrelationAdminController.java \
+        cfc-backend/src/main/java/com/etotem/cfc/controller/admin/IndicatorMappingAdminController.java \
+        cfc-backend/src/main/java/com/etotem/cfc/job/IndicatorCalibrationJob.java
+git commit -m "feat(indicator): 视图+管理控制器(4+5接口) + 每日校准Job"
+```
+
+---
+
+## 任务 11:API 文档同步 + 全量验证(验收收尾)
+
+**文件:**
+- 修改:`docs/superpowers/api/API_REFERENCE.md`
+
+### 步骤 1:同步 API_REFERENCE.md
+
+在 API_REFERENCE.md 中新增 9 个接口记录(前端封装的唯一规范入口,遵守去重规范):
+
+| 接口 | 方法 | 请求要点 | 用途 |
+|---|---|---|---|
+| `POST /api/indicator/trend` | POST | `{subjectId, definitionIds:[...]}` | 指标曲线(多指标点序列) |
+| `POST /api/indicator/change-ranking` | POST | `{subjectId, reportType?, category?, scope:adjacent\|first-vs-latest, orderBy:up\|down\|magnitude, limit}` | 变化幅度排名 |
+| `POST /api/indicator/quick-lookup` | POST | `{subjectId, category?, page, pageSize}` | 指标速查(含 dimensionScores 小节) |
+| `POST /api/indicator/correlation/list` | POST | `{subjectId, definitionId}` | 指标关联查询(含 subjectEvidence) |
+| `POST /api/admin/indicator/correlation/save` | POST | `{definitionIdA, definitionIdB, direction, manualStrength, description, manualLocked, sourceNote}` | 关联对定义/编辑 |
+| `POST /api/admin/indicator/correlation/list` | POST | `{page, size}` | 关联对分页 |
+| `POST /api/admin/indicator/correlation/calibrate` | POST | `{pairId?}` | 手动触发校准 |
+| `POST /api/admin/indicator/mapping/import` | POST | `{sourceType, seeds:[{rawName, definitionId?}]}` | 映射种子批量导入 |
+| `POST /api/admin/indicator/mapping/list` | POST | `{page, size, status?, sourceType?}` | 映射规则分页/review |
+
+(markdown 表格置于既有「指标」或新增「指标统一层」章节;保持既有文档风格。)
+
+### 步骤 2:全量后端验证
+
+运行:
+```bash
+cd cfc-backend && mvn clean compile && mvn test -Dtest='IndicatorValueMapperTest,ReportDateExtractorTest,SpearmanUtilTest,IndicatorMappingServiceTest,NormalizeIndicatorPipelineTest,IndicatorTrendServiceTest,IndicatorCorrelationServiceTest'
+```
+预期:`BUILD SUCCESS`,`Tests run: 22(合计),Failures: 0, Errors: 0`
+
+运行:`cd cfc-backend && mvn test`
+预期:既有全部测试(含原有测试类)`Failures: 0, Errors: 0`
+
+### 步骤 3:Commit
+
+```bash
+git add docs/superpowers/api/API_REFERENCE.md
+git commit -m "docs(api): 同步指标统一层 9 个新接口契约"
+```
+
+---
+
+## 自检(完成计划后必须逐项核对)
+
+**1. 规格覆盖度(对照设计文档 第 10 节文件清单 后端部分):**
+- [ ] DatabaseInitializer 迁移 ✓(任务 1:356~362)
+- [ ] schema.sql 同步 4 处 DDL ✓(任务 1 步骤 6)
+- [ ] IndicatorDefinition / IndicatorValue 实体 ✓(任务 2)
+- [ ] IndicatorMappingRule / IndicatorCorrelation 实体 + Mapper ✓(任务 2)
+- [ ] IndicatorMappingService ✓(任务 5)
+- [ ] NormalizeIndicatorPipeline ✓(任务 6)
+- [ ] IndicatorTrendService ✓(任务 8)
+- [ ] IndicatorCorrelationService ✓(任务 9)
+- [ ] ReportDateExtractor ✓(任务 3)
+- [ ] IndicatorController + 2 admin 控制器 ✓(任务 10)
+- [ ] FoodRecommendService 钩子 ✓(任务 6 步骤 4 钩子 3)
+- [ ] AiGateway + LangGraph endpoint ✓(任务 7)
+- [ ] IndicatorCalibrationJob ✓(任务 10 步骤 4)
+- [ ] API_REFERENCE.md ✓(任务 11)
+
+**2. 验收标准映射:**
+- [ ] 验收#5 report_date = PDF 真实日期、回填幂等 ✓(任务 3;scheduledBackfill 连跑两次:已回填行 report_date 非空不再命中 `isNull` 筛选 → 结果一致)
+- [ ] 验收#6 归一化幂等 ✓(任务 6:uk_source_definition + upsertMapping ON DUPLICATE KEY)
+- [ ] 验收#7 expected_indicators 与种子导入后 100% 命中 ✓(任务 5 importSeeds 支持管理端批量导入;exact 先行)
+- [ ] 验收#8 mvn clean compile 通过 ✓(每个任务含编译验证步骤)
+- [ ] 验收#4 关联校准 computed/manual 切换 ✓(任务 9:EFFECTIVE_SAMPLE_THRESHOLD=30,manual_locked 防护)
+
+**3. 类型一致性:**
+- [ ] `IndicatorValue.reportDate` 用 `java.sql.Date`,与 `IndicatorValueMapper.upsertMapping` 的 `#{v.reportDate}` 及 `trend`/`changeRanking` 的 `fmtDate()` 参数类型一致
+- [ ] `IndicatorMappingService.resolve(sourceType, rawName)` 三处调用(pipeline 指标/菌属/食材)签名一致
+- [ ] `NormalizeIndicatorPipeline.processReport(HealthReport, ParsedReportPayload.Payload)` 在钩子 1 传入 `(created, payload)`、钩子 2 传入 `(oldReport, editedPayload)`,两处均与签名一致
+- [ ] `IndicatorTrendService.changeRanking` 返回 `Map<String,Object>`(items/overall),`IndicatorController` 直接 `Result.success(map)` 透传
+
+**4. 占位符扫描:**
+- [ ] 全计划无「待定/TODO/后续实现」,每个代码块为可直接粘贴的完整代码
+- [ ] `HealthDimensionScoreMapper`/`FiveDimensionScoreMapper`/`FamilyMemberMapper` 若项目中已存在则直接复用,不存在时按任务内提供的创建模板补齐(任务 8 步骤 3 注释中已给出 HealthDimensionScoreMapper 模板)

+ 1238 - 0
docs/superpowers/plans/2026-10-09-indicator-change-curve-miniapp.md

@@ -0,0 +1,1238 @@
+# 指标变化曲线 · 指标速查 - 小程序实施计划(P4)
+
+**版本:** v1.0
+**日期:** 2026-10-09
+**状态:** 待执行
+**关联设计:** `docs/superpowers/specs/2026-10-09-indicator-change-curve-design.md`(v1.0 已确认)
+**依赖后端:** `docs/superpowers/plans/2026-10-09-indicator-change-curve-backend.md`(P1-P3 视图 API 必须先完成)
+
+---
+
+## 面向 AI 代理的工作者
+
+本计划由 AI 代理执行。目标:为 uni-app 微信小程序(`cfc-frontend/`,Vue 2 Options API)实现「指标变化曲线页」「指标速查页」「菌群报告详情页变化区块」及通用 `TrendChart` 折线组件。所有 API 依赖后端 P1-P3。
+
+**执行纪律(违反即失败):**
+
+1. **单一写者**:每个任务必须是「写测试 → 运行确认失败 → 实现 → 运行确认通过 → commit」的 TDD 闭环。测试永远先行。
+2. **禁止打包**:Agent 禁止执行 `npm run build:mp-weixin` / `npm run dev:mp-weixin`。只做语法/结构校验(`node --check`)、审计(`audit-duplicate-api-calls.js`)、jest 单测。最终打包由用户在 HBuilderX 完成。
+3. **前端硬规则硬编码进每一步**:
+   - 禁止可选链 `?.` → 用 `&&`
+   - 禁止 CSS Grid → flexbox
+   - 禁止 `:key` 表达式 → methods 方法 `getItemKey(item)`
+   - 禁止 `:class` 绑定方法调用 → 内联表达式 `:class="'xxx-' + item._cssClass"`;`_cssClass` 由前端 computed/后端字段映射为英文
+   - 禁止直接 `new Date(string)` → `utils/format.js` 的 `parseDate()`
+   - 禁止中文 CSS 类名 → 英文
+   - 禁止 `uni.getSystemInfoSync()` → `uni.getWindowInfo()`
+   - 组件接收数组 prop 必须 `|| []` + `Array.isArray` 兜底(三态兼容)
+   - 页面静态标题在 `pages.json` 的 `navigationBarTitleText` 配置,模板内禁止手写 `<text class="nav-title">`
+4. **API 去重规范**(`cfc-frontend/AGENTS.md`):`_onLoadFired` 标志位;`utils/api.js` 每个 URL 单一规范入口,别名委托;禁止循环内请求;CI 门禁 `node scripts/audit-duplicate-api-calls.js` 必须 exit 0。
+5. **新增 API 封装前查重 API_REFERENCE**;封装改动同步更新 `docs/superpowers/api/API_REFERENCE.md`(接口校准不存在时留待后端实现后补)。
+6. **只修改本计划列出的文件**,禁止顺手改动其他文件;不改样式设计体系 token。
+
+---
+
+## 1. 目标与范围
+
+### 1.1 本期范围(P4)
+
+| # | 交付物 | 文件 | 说明 |
+|---|--------|------|------|
+| 1 | 通用折线组件 | `cfc-frontend/components/TrendChart.vue` | 多序列折线 + 数据点 + 数值标注,手写 canvas(参照 `RadarChart.vue` type=2d + DPR + 重试初始化模式),**不引入图表库** |
+| 2 | 指标变化曲线页 | `cfc-frontend/pages/health/indicator-curve.vue` | 顶部筛选(成员/报告类型/时间范围)+ 曲线区(指标选择器 → TrendChart)+ 变化排名区(Tab:菌群/食材/指标 × 排序:升幅/降幅/绝对幅度;adjacent 相邻两次报告 Δ;按 good_direction 着色) |
+| 3 | 指标速查页 | `cfc-frontend/pages/health/indicator-quicklook.vue` | 分类 Tab + 分页(≤30/页),每项:名称 · 最新值 · 参考区间 · ↑↓→ 趋势 · 最近报告日期;独立小节五维/七维评分趋势;点击单项 → 跳转曲线页携带 definitionId |
+| 4 | 菌群详情页变化区块 | `cfc-frontend/pages/health/gut-flora-detail.vue`(改) | 最近两次同类型报告的菌属 Δ,红/绿标注(一期仅此详情页) |
+| 5 | 页面注册 + API 封装 | `cfc-frontend/pages.json`(改)、`cfc-frontend/utils/api.js`(改) | 注册 2 个新页面路径;新增 trend / change-ranking / quick-lookup / correlation/list 4 个封装 |
+| 6 | 单测 | `cfc-frontend/__tests__/utils/indicator-change.test.js`、`cfc-frontend/__tests__/api/api.indicator.test.js` | 变化规则纯函数 + API 封装契约 |
+
+### 1.2 明确不做(二期)
+
+- DAN / 体检报告详情页的「变化」区块(一期仅菌群报告详情页)
+- 跨报告类型同曲线叠加对比
+- 曲线页知识库弹出框内嵌(复用既有 `health-knowledge-popup` 组件已有能力,如需展示弹出框直接复用,不新增接口)
+- 关联关系在小程序的展示页(`correlation/list` 封装本期提供,展示留二期;`quick-lookup` 页面不渲染关联区块)
+
+### 1.3 数据流(依赖后端 API 契约)
+
+```
+indicator-curve.vue
+  ├─ POST /api/indicator/trend            { subjectId, definitionIds:[...] }
+  │     → { items:[{ definitionId, name, points:[{reportDate,value,unit,status}] }] }
+  ├─ POST /api/indicator/change-ranking    { subjectId, reportType?, category?, scope:"adjacent", orderBy:"up|down|magnitude", limit:50 }
+  │     → { items:[{ definitionId, name, category, sourceKind, unit, before, after, delta, deltaPct, movement, trendLabel }], overall:{...} }
+  └─ POST /api/family/member/list          (成员切换,复用既有封装 getFamilyMemberList)
+
+indicator-quicklook.vue
+  ├─ POST /api/indicator/quick-lookup      { subjectId, category?, page:1, pageSize:30 }
+  │     → { categories:[{name,total}], items:[{definitionId,name,category,value,unit,refRange,status,trend,trendLabel,latestReportDate,pointsCount}], dimensionScores:[...] }
+  └─ 跳转 → indicator-curve.vue?definitionId=X
+
+gut-flora-detail.vue
+  └─ POST /api/indicator/change-ranking    { subjectId, reportType:'gut_flora', scope:"adjacent", orderBy:"magnitude", limit:6 }
+      → 渲染最近两次报告的菌属 Δ 区块(红/绿标注)
+```
+
+---
+
+## 2. 技术栈与关键约定
+
+| 项 | 约定 |
+|----|------|
+| 框架 | uni-app Vue 2(Options API),微信小程序 |
+| 图表 | `TrendChart.vue` 手写 canvas,`<canvas type="2d">` + `uni.createSelectorQuery().in(this).select('#'+canvasId).fields({node:true,size:true,rect:true})` + `uni.getWindowInfo().pixelRatio` DPR 适配 + 重试初始化(参考 `components/RadarChart.vue`) |
+| 单测 | jest(`tests/jest.config.js`:babel-jest + `@/` → `cfc-frontend/` + setup mock uni 全局 + `getCurrentPages`)。运行:`cd cfc-frontend && npx jest __tests__/...` 或 `cd tests && npx jest ../cfc-frontend/__tests__/...`(plan 内统一使用 `cd cfc-frontend && npx jest <path>`) |
+| 语法校验 | 提取 `<script>` 块 `node --check`;模板部分靠人工 + HBuilderX 编译 |
+| 审计 | `node scripts/audit-duplicate-api-calls.js`(exit 0) |
+| 时间 | 展示格式 `yyyy-MM-dd`(截取前 10 位)或 `yyyy-MM-dd HH:mm:ss`;日期解析一律 `parseDate()` |
+
+### 2.1 API 封装规范(utils/api.js)
+
+统一 `export const fnName = (params) => request('/api/indicator/xxx', 'POST', params || {})`。`request()` 已内置 3 秒去重 + PENDING 合并(同 URL+method+body),页面侧仍需遵守 `_onLoadFired`。
+
+---
+
+## 3. 文件结构与变更清单
+
+| 操作 | 文件 | 内容 |
+|------|------|------|
+| 新建 | `cfc-frontend/components/TrendChart.vue` | 通用折线组件 |
+| 新建 | `cfc-frontend/pages/health/indicator-curve.vue` | 变化曲线页 |
+| 新建 | `cfc-frontend/pages/health/indicator-quicklook.vue` | 速查页 |
+| 修改 | `cfc-frontend/pages/health/gut-flora-detail.vue` | 「变化」区块 |
+| 修改 | `cfc-frontend/pages.json` | health 分包注册 2 页 |
+| 修改 | `cfc-frontend/utils/api.js` | 4 个封装(含坐标:change-ranking 已可按 sourceKind/category 过滤) |
+| 新建 | `cfc-frontend/__tests__/utils/indicator-change.test.js` | 变化规则纯函数单测 |
+| 新建 | `cfc-frontend/__tests__/api/api.indicator.test.js` | API 封装契约单测 |
+| 修改 | `docs/superpowers/api/API_REFERENCE.md` | 同步 4 个新增封装对应的接口记录 |
+
+---
+
+## 4. 任务分解(TDD 小步)
+
+> 每个任务验收 = 测试/校验命令通过 + commit。commit message 遵循仓库风格(Conventional Commits 中文,参考 `git log` 历史)。
+
+### 任务 M1:变化规则纯函数 + 单测(TDD 红 → 绿)
+
+**目标**:把「good_direction × 变化方向 → 趋势标签/颜色/CSS 类」提炼成纯函数,供曲线页排名区与详情页变化区块共用,避免两处复制。
+
+**文件**:`cfc-frontend/utils/indicator-change.js`(新建,纯函数,可被 jest require 且不依赖 uni)
+
+**步骤 1 — 写失败测试** `cfc-frontend/__tests__/utils/indicator-change.test.js`:
+
+```js
+/**
+ * 指标变化规则纯函数单测
+ * 规则来源: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%)')
+    })
+  })
+})
+```
+
+**步骤 2 — 运行确认失败**:
+
+```bash
+cd cfc-frontend && npx jest __tests__/utils/indicator-change.test.js --no-coverage
+```
+
+预期:`Cannot find module '@/utils/indicator-change'`(模块不存在 → 红)。
+
+**步骤 3 — 实现** `cfc-frontend/utils/indicator-change.js`:
+
+```js
+/**
+ * 指标变化展示规则纯函数
+ * 规则来源: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'
+}
+```
+
+**步骤 4 — 运行确认通过**:
+
+```bash
+cd cfc-frontend && npx jest __tests__/utils/indicator-change.test.js --no-coverage
+```
+
+预期:7+3 个 test 全部通过(绿)。
+
+**步骤 5 — commit**:`feat(miniapp): 指标变化展示规则纯函数 resolveChangeStyle/formatDelta`
+
+---
+
+### 任务 M2:utils/api.js 新增 4 个封装 + API 契约单测(TDD 红 → 绿)
+
+**目标**:`/api/indicator/trend`、`/api/indicator/change-ranking`、`/api/indicator/quick-lookup`、`/api/indicator/correlation/list` 四个 URL 各一个规范入口。先写契约测试(mock uni.request),再实现。
+
+**步骤 1 — 写失败测试** `cfc-frontend/__tests__/api/api.indicator.test.js`:
+
+```js
+/**
+ * 指标 API 封装契约测试
+ * 验证 URL / method / body 正确,且是规范入口(单 URL 单封装)
+ */
+import * as api from '@/utils/api'
+import { getIndicatorTrend, getIndicatorChangeRanking, getIndicatorQuickLookup, getIndicatorCorrelationList } from '@/utils/api'
+
+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()
+  })
+
+  /** 捕获最近一次 request 调用参数 */
+  function captureRequest(params) {
+    var captured = null
+    global.uni.request.mockImplementation((opts) => {
+      captured = opts
+      process.nextTick(() => { if (opts.success) opts.success({ data: { code: 200, data: {} } }) })
+    })
+    return params.then(() => captured)
+  }
+
+  test('getIndicatorTrend → POST /api/indicator/trend with subjectId+definitionIds', async () => {
+    const opts = await captureRequest(getIndicatorTrend({ subjectId: 5, definitionIds: [1, 2] }))
+    expect(opts.url).toBe('/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 () => {
+    const opts = await captureRequest(getIndicatorChangeRanking({ subjectId: 5, scope: 'adjacent', orderBy: 'magnitude', limit: 50 }))
+    expect(opts.url).toBe('/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 () => {
+    const opts = await captureRequest(getIndicatorQuickLookup({ subjectId: 5, category: '核心菌属', page: 1, pageSize: 30 }))
+    expect(opts.url).toBe('/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 () => {
+    const opts = await captureRequest(getIndicatorCorrelationList({ subjectId: 5, definitionId: 3 }))
+    expect(opts.url).toBe('/api/indicator/correlation/list')
+    expect(opts.method).toBe('POST')
+    expect(opts.data.definitionId).toBe(3)
+  })
+})
+```
+
+**步骤 2 — 运行确认失败**:`cd cfc-frontend && npx jest __tests__/api/api.indicator.test.js --no-coverage` → 预期 `getIndicatorTrend is not a function`(红)。
+
+**步骤 3 — 实现**:在 `cfc-frontend/utils/api.js` 末尾新增(先读文件末尾,确认无同名封装;若有则按 AGENTS.md 规则 2 委托):
+
+```js
+// ============ 指标变化曲线 / 速查 / 关联 ============
+// 规范入口(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 || {})
+```
+
+**步骤 4 — 运行确认通过**:
+
+```bash
+cd cfc-frontend && npx jest __tests__/api/api.indicator.test.js --no-coverage
+node scripts/audit-duplicate-api-calls.js   # exit 0,确认无同 URL 多封装 / N+1
+node --check 校验(提取 api.js script 不适用,api.js 本身是 .js:直接 node --check utils/api.js)
+```
+
+**步骤 5 — commit**:`feat(miniapp): 指标 API 封装 getIndicatorTrend/ChangeRanking/QuickLookup/CorrelationList`
+
+---
+
+### 任务 M3:TrendChart.vue 通用折线组件
+
+**目标**:多序列折线图(横轴=报告日期升序,纵轴=数值;数据点标注数值;支持多指标序列叠加),手写 canvas。
+
+**文件**:`cfc-frontend/components/TrendChart.vue`(新建)
+
+**关键实现(参照 RadarChart.vue 的 canvas 2d 模式)**:
+
+```vue
+<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 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(this._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 (selfShowValues(si)) {
+            ctx.fillStyle = '#475569'
+            ctx.font = '10px sans-serif'
+            ctx.textAlign = 'center'
+            ctx.fillText(selfFmtValue(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>
+```
+
+> 注:上述 `selfShowValues`/`selfFmtValue` 是占位,实现时需改为直接引用组件方法(Vue 2 methods 内通过 `this.showValues` / `this._fmtValue` 访问)。**交付时必须替换为实际代码,无占位符。**
+
+**验证(通过才 commit)**:
+
+```bash
+# 提取 <script> 块做语法校验
+node -e "const fs=require('fs');const s=fs.readFileSync('components/TrendChart.vue','utf8');const m=s.match(/<script[^>]*>([\s\S]*?)<\/script>/);require('fs').writeFileSync('/tmp/trend-check.js',m[1]);" && node --check /tmp/trend-check.js
+```
+
+**commit**:`feat(miniapp): TrendChart 通用折线组件(canvas 2d + DPR,参照 RadarChart 模式)`
+
+---
+
+### 任务 M4:指标变化曲线页 indicator-curve.vue
+
+**目标**:成员切换 + 报告类型筛选 + 指标选择(TrendChart)+ 变化排名区(Tab + 排序 + good_direction 着色)。
+
+**文件**:`cfc-frontend/pages/health/indicator-curve.vue`(新建)
+
+**页面结构**:
+
+```vue
+<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="theIndicatorId === getIndicatorId(def) ? '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 ' + colorClassFor(getChangeStyle(item).cssClass)">→</text>
+            <text class="rank-after">{{ item.after ? item.after.value : '--' }}</text>
+            <text :class="'rank-delta ' + colorClassFor(getChangeStyle(item).cssClass)">{{ formatDelta(item.delta, item.deltaPct) }}</text>
+          </view>
+          <text :class="'rank-trend ' + colorClassFor(getChangeStyle(item).cssClass)">{{ getChangeStyle(item).trendLabel }} {{ getChangeStyle(item).arrow }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+```
+
+**script 关键逻辑**(`_onLoadFired` 模式 + 参考既有健康页面的成员加载 `getFamilyMemberList`):
+
+```js
+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 },
+    getChangeStyle: function(item) { return resolveChangeStyle(item.goodDirection, item.movement) },
+
+    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) {
+        self.rankItems = (res.data && res.data.items) || []
+        self.overall = (res.data && res.data.overall) || {}
+      })
+    }
+  }
+}
+</script>
+```
+
+> 注:上述脚本中 `loadCurve` 委托 `loadRankingThenCurve` 已满足单 URL 单请求;`onLoad` 只触发 `loadMembers` 一次(`_onLoadFired` 跳过 onShow 首刷),成员选中后并行 `loadCurve`+`loadRanking`。**实现时若发现 `loadMembers` 内部还有默认成员触发多次 ranking,需用 `_loading` 标志防重。**
+
+**验证**:
+
+```bash
+# 提取 <script> 块做语法校验(与 M3 同法,仓库规范:node --check)
+node -e "const fs=require('fs');const s=fs.readFileSync('pages/health/indicator-curve.vue','utf8');const m=s.match(/<script[^>]*>([\s\S]*?)<\/script>/);require('fs').writeFileSync('/tmp/curve-check.js',m[1]);" && node --check /tmp/curve-check.js
+# 另校验 quicklook 页(M5 完成后同样执行)
+node -e "const fs=require('fs');const s=fs.readFileSync('pages/health/indicator-quicklook.vue','utf8');const m=s.match(/<script[^>]*>([\s\S]*?)<\/script>/);require('fs').writeFileSync('/tmp/ql-check.js',m[1]);" && node --check /tmp/ql-check.js
+node scripts/audit-duplicate-api-calls.js   # exit 0(生命周期双发 / 同 URL 双封装检测)
+```
+
+**commit**:`feat(miniapp): 指标变化曲线页(筛选 + TrendChart + 变化排名 Tab/排序/着色)`
+
+---
+
+### 任务 M5:指标速查页 indicator-quicklook.vue
+
+**目标**:分类 Tab + 分页 + 五维/七维小节 + 点击单项跳转曲线页。
+
+**文件**:`cfc-frontend/pages/health/indicator-quicklook.vue`(新建)
+
+**页面结构(关键部分)**:
+
+```vue
+<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 ' + colorClassFor(getChangeStyle(item).cssClass)">{{ getChangeStyle(item).arrow }} {{ getChangeStyle(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="getStatusClass(item)">{{ item.status || '' }}</text>
+        </view>
+        <text class="ql-date">最近报告: {{ formatDate(item.latestReportDate) }}</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 ' + colorClassFor(getChangeStyle(d).cssClass)">{{ getChangeStyle(d).arrow }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+```
+
+**script 关键逻辑**(`getChangeStyle` 对 `dimensionScores` 需兼容:dimension 的 `trend` 字段同 movement;`getChangeStyle` 签名不变):
+
+```js
+import { getIndicatorQuickLookup } from '@/utils/api'
+import { getFamilyMemberList } from '@/utils/api'
+import { resolveChangeStyle, 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 / currentMemberName 同 M4 */ },
+  onLoad: function() { this.loadMembers(); this._onLoadFired = true },
+  onShow: function() { if (this._onLoadFired) { this._onLoadFired = false } else if (this.currentMemberId) { this.reload() } },
+  methods: {
+    /* getItemKey/getItemId/getCatKey/getDimKey/goCurve 等 key 方法(禁止 :key 表达式) */
+    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 || {}
+        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 })
+    },
+    getChangeStyle: function(item) {
+      var movement = item.trend || item.movement || 'flat'
+      var gd = item.goodDirection || 'neutral'
+      return resolveChangeStyle(gd, movement)
+    },
+    getStatusClass: function(item) { return 'status-' + (item._cssClass || 'normal') },
+    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) }
+  }
+}
+</script>
+```
+
+**验证**:与 M4 相同的语法校验 + audit 脚本 + jest 已有 M1/M2 保障纯函数。
+
+**commit**:`feat(miniapp): 指标速查页(分类分页 + 五维/七维小节 + 跳转曲线)`
+
+---
+
+### 任务 M6:gut-flora-detail.vue 增加「变化」区块
+
+**目标**:报告详情页展示最近两次同类型报告的菌属 Δ(红/绿标注),一期仅此页。
+
+**文件**:`cfc-frontend/pages/health/gut-flora-detail.vue`(改)
+
+**插入位置**:`quick-nav` 区块(L72 后)与 `菌群功能分析`(L74 前)之间插入「变化」区块。
+
+**实现**:在 template 的 quick-nav 之后插入:
+
+```vue
+      <!-- 指标变化(最近两次报告) -->
+      <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="colorClassFor(getChangeStyle(item).cssClass)">{{ getChangeStyle(item).arrow }}</text>
+            <text class="change-after">{{ item.after ? item.after.value : '--' }}</text>
+            <text class="change-delta" :class="colorClassFor(getChangeStyle(item).cssClass)">{{ formatDelta(item.delta, item.deltaPct) }}</text>
+            <text class="change-trend" :class="colorClassFor(getChangeStyle(item).cssClass)">{{ getChangeStyle(item).trendLabel }}</text>
+          </view>
+          <view class="change-more" @tap="goCurve" v-if="changeItems.length > 0">
+            <text class="change-more-text">查看完整变化曲线 ›</text>
+          </view>
+        </view>
+      </view>
+```
+
+**script 新增**(data + methods):
+
+```js
+// data 内新增:
+// changeItems: [], _changeLoaded: false
+
+// methods 内新增:
+loadChangeBlock: function() {
+  var self = this
+  // subjectId 取当前详情页已加载的 report.subjectId(报告头部已有 personName 等)
+  if (!this.report || !this.report.subjectId) return
+  getIndicatorChangeRanking({
+    subjectId: this.report.subjectId,
+    reportType: 'gut_flora',
+    scope: 'adjacent',
+    orderBy: 'magnitude',
+    limit: 6
+  }).then(function(res) {
+    self.changeItems = (res.data && res.data.items) || []
+  })
+},
+getChangeStyle: function(item) { return resolveChangeStyle(item.goodDirection, item.movement) },
+getChangeKey: function(item, i) { return item.definitionId || i },
+goCurve: function() {
+  uni.navigateTo({ url: '/pages/health/indicator-curve?subjectId=' + this.report.subjectId + '&reportType=gut_flora' })
+}
+```
+
+**接入点**:在该页 `onLoad`/数据加载完成的回调末尾调用一次 `loadChangeBlock()`(在 report 数据就绪后),并遵守 `_onLoadFired` 模式避免 onLoad/onShow 双发。若该页本身已用 `_onLoadFired` 模式,在数据加载函数的尾部追加一次调用即可。
+
+**imports 追加**:
+
+```js
+import { getIndicatorChangeRanking } from '@/utils/api'
+import { resolveChangeStyle, formatDelta, colorClassFor } from '@/utils/indicator-change'
+```
+
+**验证**:语法校验(node --check 提取 script)+ audit 脚本 exit 0。
+
+**commit**:`feat(miniapp): 菌群报告详情页增加指标变化区块(最近两次对比)`
+
+---
+
+### 任务 M7:pages.json 注册 2 个新页面
+
+**目标**:health 分包注册 `indicator-curve` 与 `indicator-quicklook`,配 navigationBarTitleText。
+
+**文件**:`cfc-frontend/pages.json`(改)
+
+**改动**:找到 health 分包(`"root": "pages/health"`)的 pages 数组,追加两项(保持数组末尾不含多余逗号):
+
+```json
+{
+  "path": "indicator-curve",
+  "style": { "navigationBarTitleText": "指标变化" }
+},
+{
+  "path": "indicator-quicklook",
+  "style": { "navigationBarTitleText": "指标速查" }
+}
+```
+
+**验证**:
+
+```bash
+# JSON 合法性
+node -e "const p=require('./pages.json');const s=p.subPackages.find(x=>x.root==='pages/health');if(!s.pages.some(x=>x.path==='indicator-curve')){throw new Error('missing indicator-curve')}if(!s.pages.some(x=>x.path==='indicator-quicklook')){throw new Error('missing indicator-quicklook')}console.log('pages.json OK')"
+```
+
+**commit**:`feat(miniapp): pages.json 注册 indicator-curve / indicator-quicklook`
+
+---
+
+### 任务 M8:API_REFERENCE 同步 + 全量门禁验证
+
+**文件**:`docs/superpowers/api/API_REFERENCE.md`(改)
+
+**改动**:在指标模块章节新增 4 项接口记录(若 API_REFERENCE 尚无「指标」章节则新增):
+
+| 接口 | 方法 | 请求 | 响应 | 状态 |
+|------|------|------|------|------|
+| `/api/indicator/trend` | POST | `{subjectId, definitionIds:[]}` | `{items:[{definitionId,name,points:[{reportDate,value,unit,status}]}]}` | 新增(本期) |
+| `/api/indicator/change-ranking` | POST | `{subjectId, reportType?, category?, scope, orderBy, limit}` | `{items:[...], overall:{improvedCount,worsenedCount,flatCount}}` | 新增(本期) |
+| `/api/indicator/quick-lookup` | POST | `{subjectId, category?, page, pageSize}` | `{categories:[], items:[], dimensionScores:[]}` | 新增(本期) |
+| `/api/indicator/correlation/list` | POST | `{subjectId, definitionId}` | `{relations:[...]}` | 新增(本期,展示二期) |
+
+**全量门禁**:
+
+```bash
+cd cfc-frontend && npx jest __tests__/utils/indicator-change.test.js __tests__/api/api.indicator.test.js --no-coverage
+node scripts/audit-duplicate-api-calls.js && echo "AUDIT PASS"
+# 三个新增 .vue 全部 node --check script 块(TrendChart / curve / quicklook / gut-flora-detail)
+```
+
+**commit**:`docs(api): 同步指标 4 接口至 API_REFERENCE`
+
+---
+
+### 任务 M9:HBuilderX 打包验证(用户在 HBuilderX 完成,非 Agent)
+
+**目标**:HBuilderX 打开 `cfc-frontend/` → 运行到微信开发者工具 → 真机/模拟器人工验证。
+
+**人工验收清单(对应 design.md §9 验收 #1/#2/#3):**
+
+- [ ] 上传第二份同类型(gut_flora)报告后,曲线页显示完整前后对比(多数据点曲线)
+- [ ] 变化排名按幅度排序正确(绝对幅度 / 升幅 / 降幅)
+- [ ] 变化箭头颜色符合 good_direction:炎症类 lower_is_better 下降=绿;双歧杆菌 higher_is_better 上升=绿
+- [ ] 速查页分类分页正常(每类 ≤30 项),含五维/七维小节
+- [ ] 速查页点击单项 → 跳转曲线页并展示该指标
+- [ ] 菌群详情页「变化」区块显示最近两次 Δ(红/绿),点击「查看完整变化曲线」跳转曲线页
+- [ ] 无控制台报错(canvas 初始化成功日志可见)
+
+---
+
+## 5. 自检清单(全计划完成前逐项过)
+
+- [ ] 5 个前端文件新建/修改完成、无占位符代码(尤其 TrendChart 的 `selfShowValues` 占位已替换)
+- [ ] jest 单测全绿:`indicator-change.test.js` + `api.indicator.test.js`
+- [ ] `node scripts/audit-duplicate-api-calls.js` exit 0(API 去重规范)
+- [ ] 2 个新 .vue 页面 + 1 个组件 + 改动页面的 script 块 `node --check` 通过
+- [ ] `pages.json` JSON 合法且 2 页已注册(任务 M7 校验命令通过)
+- [ ] 前端硬规则逐条过:无可选链 / 无 CSS Grid / 无 `:key` 表达式 / 无 `:class` 方法调用 / 无中文 CSS 类 / 无 `uni.getSystemInfoSync` / 数组 prop 有兜底 / 静态标题在 pages.json
+- [ ] `_onLoadFired` 模式在 2 个新页面 + 详情页改动中落实,无 onLoad/onShow 双发
+- [ ] `docs/superpowers/api/API_REFERENCE.md` 已同步 4 接口
+- [ ] 未触碰计划外文件(除 API_REFERENCE 外)
+- [ ] commit 历史清晰:M1-M8 每个任务一个 commit
+- [ ] HBuilderX 人工验收清单已由用户确认(任务 M9)
+
+---
+
+## 6. 风险与缓解
+
+| 风险 | 缓解 |
+|------|------|
+| 后端 P1-P3 未就绪时前端联调受阻 | 前端严格按 design.md §6 契约开发;接口先以 mock 数据(本地 fixture)跑通渲染,后端就绪后替换为真实调用(仅替换数据源,不改变页面结构) |
+| canvas 在部分机型初始化失败 | 参照 RadarChart 的重试机制(5 次 × 60ms)+ `uni.getWindowInfo()` DPR 兜底 |
+| 曲线页 ranking+trend 两次请求时序 | `onIndicatorTap` 用 `theIndicatorId` 比较防重复;series 每次 `loadTrendFor` 全量覆盖,无竞态 |
+| 指标候选过多(200+ 菌属) | 曲线页指标选择器取 ranking 前 10 作为候选,不一次性拉全量;速查页分类分页 30/页 |
+| good_direction 缺失时颜色误判 | `resolveChangeStyle` 对 `neutral` 兜底中性色;后端保证返回 goodDirection |
+
+---
+
+## 7. 依赖与顺序
+
+1. 依赖后端 `2026-10-09-indicator-change-curve-backend.md` 的 P3(视图 API)先行或并行(前端用 mock 先行)。
+2. 任务顺序:M1(纯函数)→ M2(API 封装)→ M3(组件)→ M4/M5/M6(页面,可并行)→ M7(注册)→ M8(文档+门禁)→ M9(人工验收)。
+3. M4/M5/M6 相互独立,可并行执行(若子代理驱动),但都依赖 M1/M2。
+
+---
+
+## 8. 完工定义(Definition of Done)
+
+- 所有任务 M1-M8 commit 完成,M9 人工验收清单逐项勾选
+- jest 单测 + audit + node --check 三门禁全绿
+- 与 design.md §7.1 / §9 验收 #1-#3 对齐

+ 320 - 0
docs/superpowers/plans/2026-10-09-indicator-change-curve-webadmin.md

@@ -0,0 +1,320 @@
+# 指标变化曲线 · 指标速查 · 关联配置 - Web 管理端实施计划(P5)
+
+**版本:** v1.0
+**日期:** 2026-10-09
+**状态:** 待执行
+**关联设计:** `docs/superpowers/specs/2026-10-09-indicator-change-curve-design.md`(v1.0 已确认)
+**依赖后端:** `docs/superpowers/plans/2026-10-09-indicator-change-curve-backend.md`(P4-P5 管理端 5 个 admin 接口必须先完成;本计划**不新增任何后端接口**)
+
+---
+
+## 面向 AI 代理的工作者
+
+本计划由 AI 代理执行。目标:为 Vue 2 + Element UI Web 管理端(`cfc-web/`)实现「指标关联对配置页」「指标映射管理页」两个管理页面及对应 API 封装。所有接口复用后端计划 P4-P5 提供的 5 个 `/api/admin/indicator/*` 端点,**不新增、不修改任何后端代码**。
+
+**执行纪律(违反即失败):**
+
+1. **单一写者 TDD 闭环**:每个任务必须是「写测试 → 运行确认失败 → 实现 → 运行确认通过 → commit」的闭环。测试永远先行。`src/api/indicator.js` 与 `src/utils/indicatorSeeds.js` 必须配 axios-mock-adapter 单测(先例:`cfc-web/src/__tests__/api/guide.test.js`)。`.vue` 页面组件无组件单测先例 → 不做组件级 jest,用语法校验 + 构建门禁替代。
+2. **禁止 `npm run build`**:`package.json` 的 `prebuild` 脚本会执行 `bump-version` + `generate-changelog`(污染 git 工作区)。构建门禁一律用 `npx vue-cli-service build`。禁止 `npm run serve`(占用端口且不产出验证结果)。
+3. **请求层纪律**:
+   - 所有 API 调用必须封装在 `cfc-web/src/api/indicator.js`,组件内禁止直接 `axios` / `this.$http`(`cfc-web/AGENTS.md` ANTI-PATTERNS)。
+   - 统一走 `@/utils/request`(`res.code === 200` 才 resolve、401 → clear+login、504/超时/网络错误分文案)。
+   - **禁止复制** `views/admin/indicators/index.vue` 的 `this.$http` + `res.code === 0` 旧模式(`main.js` 未注册 `$http`,属遗留写法,新页面不得沿用)。
+4. **时间显示规范**:后端返回 ISO 8601(`2026-08-15T14:30:00`),页面统一 `formatTime(t) { return t ? t.replace('T', ' ').substring(0, 19) : '-' }`;禁止 `new Date(str).toLocaleString()`。
+5. **只修改本计划列出的文件**,禁止顺手改动其他文件;不在本计划内做样式体系重构。
+6. **无删除端点**:后端契约无删除接口 → 关联对列表**不提供删除按钮**;映射规则确认通过「携带 definitionId 重新 import 覆盖」实现(见 W4)。
+7. **新增封装前查重**:`/api/admin/indicator/*` 5 个端点已在后端计划中记录至 `docs/superpowers/api/API_REFERENCE.md`,本计划仅前端复用,**不再改动 API_REFERENCE**;前端 `url: '/api/admin/indicator'` 必须在 `src/api/` 下单一封装(W1 审计确认)。
+8. **API_REFERENCE / PROJECT-OVERVIEW 同步由编排者负责**,不在代理任务内(本计划任务不含文档同步)。
+9. Element UI 组件一律按现有管理页模式:`admin-page` + `page-header` + `el-table border stripe v-loading` + `el-pagination` + `SortMixin`(参照 `views/admin/EnergyRuleManagement.vue` 261 行模板)。
+
+---
+
+## 1. 目标与范围
+
+### 1.1 本期范围(P5)
+
+| # | 交付物 | 文件 | 说明 |
+|---|--------|------|------|
+| 1 | 指标 API 封装 | `cfc-web/src/api/indicator.js`(新建) | 6 个封装:listIndicatorDefinitions(复用)/ saveIndicatorCorrelation / listIndicatorCorrelations / calibrateIndicatorCorrelations / importIndicatorMappings / listIndicatorMappings |
+| 2 | 指标关联对配置页 | `cfc-web/src/views/admin/IndicatorCorrelations.vue`(新建) | 关联对列表 + 新增/编辑 dialog(A/B 定义搜索框、方向、人工强度、说明、人工锁定开关)+ 单对校准 + 一键校准 |
+| 3 | 指标映射管理页 | `cfc-web/src/views/admin/IndicatorMappings.vue`(新建) | 映射规则列表(sourceType/rawName/aliasText/关联指标/matchType/priority/status)+ 批量导入 dialog(JSON/CSV)+ 未匹配 raw review(待确认 → 选择定义重新 import 确认) |
+| 4 | 种子解析工具 | `cfc-web/src/utils/indicatorSeeds.js`(新建) | `parseSeedsText(text)` 纯函数:JSON 数组或 CSV 两列 → seeds 数组 |
+| 5 | 路由注册 | `cfc-web/src/router/index.js`(改) | 在 L799(indicators 路由后)追加 2 条子路由,perm `config:health` |
+| 6 | 菜单回退项 | `cfc-web/src/views/Layout.vue`(改) | 硬编码 `menuItems`「健康配置」组(L325-333)追加 2 项,perm `config:health`(动态菜单优先,此为回退可见性) |
+| 7 | 单测 | `cfc-web/src/__tests__/api/indicator.test.js`、`cfc-web/src/__tests__/utils/indicatorSeeds.test.js`(新建) | axios-mock-adapter 契约测试(先例 guide.test.js)+ 纯函数测试 |
+
+### 1.2 明确不做
+
+- **不做后端改动**:无新增 Controller/Service/实体/迁移;5 个 admin 端点全部由后端计划交付。
+- **不做关联对删除**:后端无删除端点,本期不提供;后续需要时后端补 `delete` 端点再加按钮。
+- **不做映射规则的独立更新/确认端点**:后端仅 import + list;「确认 AI 建议」= 携带 definitionId 重新 import 单条覆盖(status 0→1)。
+- **不做动态菜单数据**:生产侧边栏可见性依赖后端动态菜单树(`/api/admin/menu/tree` 返回数据);本计划只保证路由可直达 + 硬编码菜单回退可见,动态菜单数据项需另行在管理端菜单配置或后端菜单种子中补充(编排者与用户确认)。
+- **不做 e2e(强制)**:Playwright e2e 为可选验收形态(参照 `tests/e2e/energy-rules-config.spec.js`,`http://cfc.iwintrue.com/admin`),默认不下发。
+
+### 1.3 数据流(依赖后端 API 契约,与 backend 计划 L3113-3117 完全一致)
+
+```
+IndicatorCorrelations.vue
+  ├─ POST /api/admin/indicator/correlation/list   { page, size }            → { code, message, data:{ records:[...], total } }
+  │     record 字段:id, definitionIdA, definitionIdB, relationType, direction, manualStrength,
+  │                 computedStrength, method, sampleSize, confidence, effectiveStrength, manualLocked,
+  │                 cohortScope, status, computedAt, createdBy, description, sourceNote, createdAt, updatedAt
+  ├─ POST /api/admin/indicator/correlation/save   { definitionIdA, definitionIdB, direction, manualStrength,
+  │                                                 description, manualLocked, sourceNote }
+  │     role!='admin' → code!=200 message='无权操作';缺 def → 'definitionIdA/definitionIdB 不能为空';IAE → 其 message
+  ├─ POST /api/admin/indicator/correlation/calibrate { pairId? }            → message='校准完成,共更新 N 对'
+  │     pairId 传了但不存在 → message='关联对不存在'
+  └─ POST /api/admin/indicator/list               { domain?, category?, status? } → List<IndicatorDefinition>(定义搜索框数据源,客户端过滤)
+
+IndicatorMappings.vue
+  ├─ POST /api/admin/indicator/mapping/list       { page, size, status?, sourceType? } → Page<IndicatorMappingRule>
+  │     record 字段:id, sourceType, rawName, aliasText, definitionId, matchType, priority, status(0=待确认/1=生效), createdAt, updatedAt
+  ├─ POST /api/admin/indicator/mapping/import     { sourceType, seeds:[{rawName, definitionId?}] }
+  │     → message='导入完成,共 N 条';sourceType 空 → 'sourceType 不能为空';seeds 空 → 'seeds 不能为空'
+  └─ POST /api/admin/indicator/list               (同上,映射编辑时选择 definitionId)
+```
+
+---
+
+## 2. 前置事实(实现依据,均已核验)
+
+| 事实 | 依据 |
+|------|------|
+| 复用 `POST /api/admin/indicator/list` 做定义搜索框 | `IndicatorAdminController` 既有端点(params domain/category/status → `List<IndicatorDefinition>`),**无 keyword** → 前端按 name/code 客户端过滤(参照 `views/admin/indicators/index.vue` 的 filterKeyword 思路,但请求方式用 `@/api` 封装) |
+| `IndicatorDefinition` 字段 | id, domain, category, code, name, dataType, options, unit, refRange, sortOrder, status, createdAt, updatedAt |
+| `IndicatorCorrelation` 字段 | 见 1.3 契约(列表列 = 实体字段;`computedAt` 即设计文档的 `last_calibrated_at`) |
+| `IndicatorMappingRule` 字段 | 见 1.3 契约(`status=0` 为 ai_fallback 待确认;`status=1` 为生效) |
+| saveCorrelation 服务语义 | 后端 L2491:A/B 按 code 排序存储(defA 恒为 code 较小者),已存在则更新;单人工强度/方向/说明可空 |
+| calibrate 语义 | 样本量 ≥ 30 时 computed 覆盖 manual;未校准 effectiveStrength = manualStrength;返回文本「校准完成,共更新 N 对」 |
+| importSeeds 语义 | seed.rawName 必填(null/空跳过);definitionId 缺省 → lazyRegister;registerRule 按 sourceType+rawName upsert(已存在则更新字段)→ **重新 import 单条 = 覆盖确认** |
+| request.js 行为 | `res.code === 200` → resolve 整个 Result 体;非 200 → Message.error(res.message) + reject;401 → clear + /login;504/超时/网络错误分文案 |
+| jest 先例 | `cfc-web/src/__tests__/api/guide.test.js`:`MockAdapter(axios)`(axios=@/utils/request)+ `mock.onPost(url).reply(200, {code:200, data:...})` + `expect(res.code).toBe(200)`;`jest.config.js` testMatch 含 `**/__tests__/**/*.test.js`、moduleNameMapper `@/` → src |
+| 路由插入点 | `cfc-web/src/router/index.js` L794-799 = indicators 路由(perm `config:health`),L800 起为「虚拟服务商团队管理」注释 → **新路由插在 L799 与 L800 之间** |
+| 菜单回退插入点 | `cfc-web/src/views/Layout.vue`「健康配置」组 L325-333(子项 perm 均为 `config:health`)→ 追加 2 项 |
+| 构建门禁 | `npx vue-cli-service build`(禁 `npm run build`,prebuild 含 bump-version + generate-changelog 副作用) |
+| 语法校验 | 提取 `.vue` `<script>` 块写 `/tmp/*.mjs` 后 `node --check`(ESM 语法需 `.mjs` 扩展名) |
+
+---
+
+## 3. 任务分解(TDD 小步,每步 commit)
+
+> 每个 W 任务结尾必须 `git commit`(message 遵循仓库既有风格,如 `feat(web): ...`)。禁止一次 commit 覆盖多个 W。
+
+### W1 · 指标 API 封装(TDD:红 → 绿)
+
+**W1a(红)** 新建 `cfc-web/src/__tests__/api/indicator.test.js`(照抄 guide.test.js 骨架):
+
+- `beforeEach` `mock = new MockAdapter(axios)`;`afterEach` `mock.restore()`
+- 对 6 个封装各写 1 个用例,断言三件事:**URL**(`mock.history.post[i].url`)、**method=POST**、**res.code===200 透传**(`expect(res.code).toBe(200)`)
+- 用例清单:
+  - `listIndicatorDefinitions({domain:'gut', status:1})` → `/api/admin/indicator/list`,断言 data 数组透传
+  - `saveIndicatorCorrelation({definitionIdA:1, definitionIdB:2, direction:'positive', manualStrength:0.6, description:'x', manualLocked:false, sourceNote:'y'})` → `/api/admin/indicator/correlation/save`
+  - `listIndicatorCorrelations({page:1, size:10})` → `/api/admin/indicator/correlation/list`,断言 `res.data.records` 透传
+  - `calibrateIndicatorCorrelations({})` → `/api/admin/indicator/correlation/calibrate`;`calibrateIndicatorCorrelations({pairId:1})` 断言 body 含 `pairId:1`
+  - `importIndicatorMappings({sourceType:'gut', seeds:[{rawName:'乳酸菌', definitionId:3}]})` → `/api/admin/indicator/mapping/import`
+  - `listIndicatorMappings({page:1, size:10, status:0, sourceType:'gut'})` → `/api/admin/indicator/mapping/list`,断言 body 含 status/sourceType
+- 运行 `npx jest src/__tests__/api/indicator.test.js` → **确认红灯**(模块不存在,import 报错)
+
+**W1b(实现)** 新建 `cfc-web/src/api/indicator.js`:
+
+```js
+import request from '@/utils/request'
+
+export function listIndicatorDefinitions(params) {
+  return request({ url: '/api/admin/indicator/list', method: 'post', data: params })
+}
+
+export function saveIndicatorCorrelation(data) {
+  return request({ url: '/api/admin/indicator/correlation/save', method: 'post', data })
+}
+
+export function listIndicatorCorrelations(params) {
+  return request({ url: '/api/admin/indicator/correlation/list', method: 'post', data: params })
+}
+
+export function calibrateIndicatorCorrelations(params) {
+  return request({ url: '/api/admin/indicator/correlation/calibrate', method: 'post', data: params })
+}
+
+export function importIndicatorMappings(data) {
+  return request({ url: '/api/admin/indicator/mapping/import', method: 'post', data })
+}
+
+export function listIndicatorMappings(params) {
+  return request({ url: '/api/admin/indicator/mapping/list', method: 'post', data: params })
+}
+```
+
+**W1c(绿)** 运行 `npx jest src/__tests__/api/indicator.test.js` → 全绿。
+
+**W1d(审计)** `grep -rn "indicator" cfc-web/src/api/ cfc-web/src/views/ | grep -v __tests__` → 确认 `/api/admin/indicator` 仅在本封装出现(`views/admin/indicators/index.vue` 的 `this.$http` 遗留调用的 URL 已失效,标记为遗留不改,但本计划新增页面一律走新封装)。commit。
+
+### W2 · 路由注册 + 菜单回退项
+
+**W2a** `cfc-web/src/router/index.js`:在 L799(indicators 路由)与 L800(`// ========== 虚拟服务商团队管理 ==========`)之间插入:
+
+```js
+      {
+        path: 'indicator-correlations',
+        name: 'IndicatorCorrelations',
+        component: () => import('@/views/admin/IndicatorCorrelations.vue'),
+        meta: { title: '指标关联配置', perm: 'config:health' }
+      },
+      {
+        path: 'indicator-mappings',
+        name: 'IndicatorMappings',
+        component: () => import('@/views/admin/IndicatorMappings.vue'),
+        meta: { title: '指标映射管理', perm: 'config:health' }
+      },
+```
+
+**W2b** `cfc-web/src/views/Layout.vue`「健康配置」组(L325-333)children 末尾追加:
+
+```js
+                { path: '/indicator-correlations', label: '指标关联配置', icon: 'el-icon-connection', perm: 'config:health' },
+                { path: '/indicator-mappings', label: '指标映射管理', icon: 'el-icon-sort', perm: 'config:health' },
+```
+
+**W2c(审计)** 路由 path 去重:`grep -n "path: 'indicator" cfc-web/src/router/index.js` → 各 1 条;`grep -n "indicator-correlations\|indicator-mappings" cfc-web/src/views/Layout.vue` → 各 1 条。commit。
+
+### W3 · 指标关联对配置页(`views/admin/IndicatorCorrelations.vue`)
+
+模板参照 `EnergyRuleManagement.vue`(admin-page + header + table + pagination + dialog + SortMixin)。结构:
+
+**模板区:**
+- `admin-page` > `page-header`(标题「指标关联配置」+ 右侧「一键校准」按钮 el-button type=warning)
+- `el-table` border stripe v-loading,列:ID / 指标A(显示定义名称)/ 指标B / 方向(`positive`→tag「正向」type=success,`negative`→tag「负向」type=danger)/ 人工强度 manualStrength / 计算强度 computedStrength / 生效强度 effectiveStrength / 样本量 sampleSize / 置信度 confidence / 最近校准时间 `formatTime(row.computedAt)` / 人工锁定(manualLocked → tag「锁定」)/ 状态(status tag)/ 操作(编辑 · 单对校准)
+- `el-pagination`(page/total → `listCorrelations()` 刷新)
+- 新增/编辑 dialog `el-dialog`:
+  - 指标A 定义搜索框:`el-select filterable clearable :filter-method="filterDefsA"`(选项来自 W3 加载的定义数组,显示 `${name}(${code})`,value=id)
+  - 指标B 同(filterDefsB);A 与 B 不能选同一个(save 前校验,`this.$message.error('指标A与指标B不能相同')`)
+  - 方向 `el-select`(positive/negative,默认 positive)
+  - 人工强度 `el-input-number`(min 0 / max 1 / step 0.05 / precision 2)
+  - 说明 `el-input type=textarea`(description)
+  - 人工锁定 `el-switch`(manualLocked,开启时后续校准不覆盖人工值)
+  - 来源备注 `el-input`(sourceNote,可空)
+
+**脚本区:**
+- data:`list`、`total`、`page`、`size`、`loading`、`dialogVisible`、`form`(含 definitionIdA/B/direction/manualStrength/description/manualLocked/sourceNote)、`definitions`、`defMap`(id→name)、`defOptionsA/B`(过滤后的候选数组)、`editingId`
+- `created() { this.loadDefinitions(); this.listCorrelations() }`
+- `loadDefinitions()`:`listIndicatorDefinitions({ status: 1 }).then(res => { this.definitions = res.data || []; 构建 defMap })`——status=1 仅取启用定义;失败静默(列表仍可展示,名称显示 `#id`)
+- `listCorrelations()`:`listIndicatorCorrelations({ page, size }).then(res => { this.list = res.data.records; this.total = res.data.total })`,`.finally` 关 loading
+- `openCreate()` / `openEdit(row)`(回填 form + editingId)
+- `subCorrelations()`:校验 A≠B(`if (!form.definitionIdA || !form.definitionIdB) return message('请选择指标A和指标B')`)→ `saveIndicatorCorrelation({...form})` → message 成功 → 关 dialog → 刷新列表
+- `calibrateOne(row)`:`this.$confirm('确定对该关联对执行校准?', ...)` → `calibrateIndicatorCorrelations({ pairId: row.id }).then(res => this.$message.success(res.message))` → 刷新
+- `calibrateAll()`:`this.$confirm('将基于样本数据重新计算全部关联对的有效强度,确定?')` → `calibrateIndicatorCorrelations({}).then(res => this.$message.success(res.message))` → 刷新
+- 定义名称解析:`defName(id) { return (this.defMap[id] || `#${id}`) }`
+- `filterDefsA(keyword)` / `filterDefsB(keyword)`:按 name/code 包含过滤 → `defOptionsA/B`(keyword 空则回退全量)
+
+**不做:** 删除按钮(无端点);row 直接编辑(走 dialog)。
+
+**验证:** `node --check` 提取 script 块(见 §4 命令)→ commit。
+
+### W4 · 指标映射管理页(`views/admin/IndicatorMappings.vue`)
+
+**W4a(红)** 新建 `cfc-web/src/__tests__/utils/indicatorSeeds.test.js`:
+
+- `parseSeedsText('')` → `[]`;`null/undefined` → `[]`
+- JSON 数组:`'[{"rawName":"乳酸菌","definitionId":3},{"rawName":"双歧杆菌"}]'` → 2 条,`definitionId` 缺省为 `null`
+- CSV 两列:`'乳酸菌,3\n双歧杆菌\n'` → 第 1 条 `{rawName:'乳酸菌', definitionId:3}`,第 2 条 `{rawName:'双歧杆菌', definitionId:null}`
+- 空行/纯空白行跳过;`rawName` 为空的条目跳过
+- 非法 JSON 但行结构可解析 → 按行解析不抛异常(回退 CSV 逻辑)
+- 运行 `npx jest src/__tests__/utils/indicatorSeeds.test.js` → 红灯
+
+**W4b(实现)** 新建 `cfc-web/src/utils/indicatorSeeds.js`:
+
+- `parseSeedsText(text)`:trim 空 → `[]`;先尝试 `JSON.parse` 为数组 → 逐条规范化 `{rawName: String(item.rawName||'').trim(), definitionId: item.definitionId || null}`,rawName 空跳过;JSON 解析失败 → 按行 CSV 解析:`line.split(/[,,]/)`,col0=rawName(trim,空跳过),col1=definitionId(可空,空 → null,非数字 → null)
+- 纯函数、无副作用、无状态(模块内不 import request)
+
+**W4c(绿)** jest 全绿 → commit。
+
+**W4d 页面模板/**`views/admin/IndicatorMappings.vue`:
+
+**模板区:**
+- `admin-page` > `page-header`(标题「指标映射管理」+ 右侧「批量导入」按钮)
+- 筛选区:`el-select`(状态:全部 / 生效 status=1 / 待确认 status=0)+ `el-input`(来源 sourceType)+ 「查询」按钮 → `listMappings()`
+- `el-table` border stripe v-loading,列:ID / 来源 sourceType / 原始名称 rawName / 别名 aliasText / 关联指标 `defName(definitionId)` / 匹配方式 matchType(`ai_fallback` → tag「AI 建议」warning,其余原样)/ 优先级 priority / 状态(status=0 → tag「待确认」warning,1 → tag「生效」success)/ 创建时间 `formatTime(row.createdAt)` / 操作(status=0 时显示「确认映射」)
+- `el-pagination` + SortMixin
+- 批量导入 dialog:
+  - 来源 `el-input`(sourceType,必填)
+  - 数据 `el-input type=textarea`(支持 JSON 数组 或 CSV 两列,placeholder 示例 `[{"rawName":"乳酸菌","definitionId":3}]` 或 `乳酸菌,3`)
+  - 提交:`parseSeedsText(text)` → `importIndicatorMappings({ sourceType, seeds })` → message 显示返回文本 → 关 dialog → 刷新
+- 确认映射 dialog(status=0 行):
+  - 展示待确认 rawName(`已确认 AI 建议:defName(aliasText 或 definitionId)` 若无关联定义则空)
+  - 定义搜索框:`el-select filterable`(选项=definitions,同 W3 加载与过滤方式)选择目标定义
+  - 确认提交:`importIndicatorMappings({ sourceType: row.sourceType, seeds: [{ rawName: row.rawName, definitionId: 选中的 id }] })` → 覆盖 status→1 → message 成功 → 刷新
+
+**脚本区:**
+- data:`list`、`total`、`page`、`size`、`loading`、`statusFilter`、`sourceTypeFilter`、`importDialogVisible`、`importForm{sourceType, text}`、`confirmDialogVisible`、`confirmRow`、`confirmDefId`、`definitions`、`defMap`、`defOptions`
+- `created() { this.loadDefinitions(); this.listMappings() }`(loadDefinitions 同 W3,可抽公共但**本期不新增公共组件**,两页各保留一份轻量实现,注释标注来源即可——遵循「只改计划列出文件」)
+- `listMappings()`:`listIndicatorMappings({ page, size, status: this.statusFilter || undefined, sourceType: this.sourceTypeFilter || undefined })`
+- `submitImport()`:校验 sourceType/text 非空 → parse → `if (!seeds.length) return message('未解析到有效条目')` → import → 刷新
+- `openConfirm(row)` / `submitConfirm()`:校验 `confirmDefId` 非空 → 单条 import → 刷新
+- `defName(id)` 同 W3
+
+**验证:** node --check 两页 → commit。
+
+### W5 · 验证门禁(全部通过才算完成)
+
+1. **语法校验**:对 `src/api/indicator.js`、`src/utils/indicatorSeeds.js` 及两个 `.vue` 提取的 script 块执行 `node --check`(命令见 §4),全部 exit 0。
+2. **单测**:`npx jest` 全量 → 通过(含既有 guide 测试不得被破坏)。
+3. **构建**:`npx vue-cli-service build` → BUILD SUCCESSFUL(严禁 `npm run build`)。
+4. **路由/封装去重审计**:见 W1d/W2c 命令,exit 0。
+5. **契约对齐抽查**:`grep -n "correlation/save\|correlation/list\|correlation/calibrate\|mapping/import\|mapping/list" cfc-web/src/api/indicator.js` → 5 个 URL 与 backend 计划 L3113-3117 完全一致。commit(如需)。
+
+### W6 · 收尾
+
+- 汇总变更文件清单(仅本计划列表内文件)。
+- 交付说明:页面入口 = 侧边栏「健康配置」组(动态菜单缺失时回退)或直连 `/admin#/indicator-correlations`、`/admin#/indicator-mappings`;依赖后端 P4-P5 已部署。
+- 不做任何文档同步(编排者负责 PROJECT-OVERVIEW / API_REFERENCE)。
+
+---
+
+## 4. 命令清单
+
+```bash
+# 单测(每步 TDD 红/绿)
+cd cfc-web && npx jest src/__tests__/api/indicator.test.js --silent
+cd cfc-web && npx jest src/__tests__/utils/indicatorSeeds.test.js --silent
+cd cfc-web && npx jest --silent                      # W5 全量
+
+# .vue script 提取 + 语法校验(ESM 需 .mjs 扩展名)
+node -e "const fs=require('fs');const s=fs.readFileSync('cfc-web/src/views/admin/IndicatorCorrelations.vue','utf8');const m=s.match(/<script>([\\s\\S]*?)<\\/script>/);if(!m){console.error('no script');process.exit(1)}fs.writeFileSync('/tmp/IndicatorCorrelations.mjs',m[1])"
+node --check /tmp/IndicatorCorrelations.mjs
+# 同上对 IndicatorMappings.vue
+cp cfc-web/src/api/indicator.js /tmp/indicator-api.mjs && node --check /tmp/indicator-api.mjs
+cp cfc-web/src/utils/indicatorSeeds.js /tmp/indicatorSeeds.mjs && node --check /tmp/indicatorSeeds.mjs
+
+# 构建门禁(禁 npm run build)
+cd cfc-web && npx vue-cli-service build
+
+# 去重审计
+grep -n "path: 'indicator" cfc-web/src/router/index.js
+grep -n "indicator-correlations\|indicator-mappings" cfc-web/src/views/Layout.vue
+grep -rn "indicator/correlation\|indicator/mapping" cfc-web/src/api/ cfc-web/src/views/ | grep -v __tests__
+
+# 契约对齐抽查
+grep -n "correlation/save\|correlation/list\|correlation/calibrate\|mapping/import\|mapping/list" cfc-web/src/api/indicator.js
+```
+
+---
+
+## 5. 验收标准(映射设计 §9)
+
+| # | 验收项 | 判定 |
+|---|--------|------|
+| 1 | 管理端可配置关联对(A/B 定义搜索框、方向、人工强度、说明、manualLocked 开关),保存后列表立即可见 | 手测:保存 → 列表刷新出现新行,字段回显正确 |
+| 2 | 列表展示 computed_strength / sample_size / confidence / last_calibrated_at(=computedAt 格式化) | 手测:有真实配对数据显示校准强度+样本量;样本 <30 显示人工值(effectiveStrength=manualStrength) |
+| 3 | 一键校准 + 单对校准,返回「校准完成,共更新 N 对」 | 手测:message 提示正确,列表刷新 |
+| 4 | 映射规则管理:sourceType + rawName → definitionId;批量导入 JSON/CSV;status=0 待确认行可确认(重新 import 覆盖) | 手测:导入返回「导入完成,共 N 条」;确认后行状态 tag 变「生效」 |
+| 5 | `node --check` 通过、`npx jest` 通过、`npx vue-cli-service build` 通过、API 去重审计通过(对应设计 §9 #8) | §4 命令全部 exit 0 |
+| 6 | 只修改计划列出文件;git 提交记录每个 W 独立 commit | `git show --stat` 抽查 |
+
+---
+
+## 6. 风险与备注
+
+- **动态菜单可见性**:生产侧边栏由后端 `/api/admin/menu/tree` 驱动,硬编码 menuItems 仅回退。若动态菜单数据未含新页面,入口不可见但路由可直达(URL 直连)。需与用户确认是否同步补充菜单配置数据(超出本计划代码范围)。
+- **`views/admin/indicators/index.vue` 遗留 `this.$http`**:其 URL 实为失效旧模式,**不在本计划范围**;新页面严格走 `@/api/indicator.js`。若后续清理该页可另立任务。
+- **A/B 同指标**:save 无后端校验同对重复,前端 dialog 拦截 A=B;同 defA/defB 重复保存由后端 upsert 处理(更新而非报错)。
+- **no-delete 约束**:产品侧如要求删除功能,需后端新增 delete 端点后追加(本期明确不做)。

+ 200 - 0
docs/superpowers/specs/2026-10-08-tongue-diagnosis-aikanshe-integration.md

@@ -0,0 +1,200 @@
+# 舌诊 aikanshe.com API 集成设计方案
+
+**日期**: 2026-10-08  
+**状态**: 已实现(代码已完成)  
+**优先级**: P1
+
+---
+
+## 一、背景
+
+CFC 项目已有舌诊功能的初步实现,但目前依赖 LangGraph Python 服务(`cfc-langgraph`),在 LangGraph 不可用时使用 mock 数据兜底。  
+为提供更准确、更丰富的中医舌诊分析,计划接入 `aikanshe.com`(愛看舌)开放 API,作为备用舌诊服务提供者。
+
+---
+
+## 二、aikanshe.com API 概览
+
+### 2.1 认证方式
+
+| 方式 | 说明 | 适用场景 |
+|------|------|----------|
+| `appkey` + `x-appkey` header | 注册后获取的 API Key | 推荐,对接简单 |
+| JWT Token | `POST /account/login` 获取,放入 `Authorization: Bearer {token}` | 需登录态的场景 |
+
+每个账号默认 **25 次免费调用**,超额需购买算力包。
+
+### 2.2 核心接口
+
+| 接口 | 消耗 | 说明 |
+|------|------|------|
+| `POST /agency/quanxi` | 1 次 | AI 全息舌诊(multipart 上传) |
+| `POST /agency/quanxiWithUrl` | 1 次 | AI 全息舌诊(imageUrl 方式) |
+| `POST /agency/quanxiWithReport` | 2 次 | 附带网页报告 |
+| `POST /agency/quanxiWithReportAndUnderside` | 3 次 | 带舌底 + 网页报告 |
+
+### 2.3 请求参数(`quanxiWithUrl`)
+
+```json
+{
+  "age": 30,           // 年龄(小孩 2-16,成人 >15)
+  "male": 1,           // 1=男,0=女,2=其他
+  "name": "张三",       // 姓名/昵称
+  "imageUrl": "data:image/jpeg;base64,...",
+  "appkey": "xxx",     // appkey 或 token 认证
+  "desease": "",       // 疾病筛选(拼音,填 all 检测所有)
+  "confidence": 0.2,   // 置信度阈值
+  "lang": "zh-CN"      // 语言偏好
+}
+```
+
+### 2.4 响应结构(`AiQuanxiReportDTO`)
+
+```json
+{
+  "code": "20000",
+  "data": {
+    "id": 123,
+    "name": "张三",
+    "age": 30,
+    "feel": "身体表现...",          // 整体评估(映射到 overall_assessment)
+    "recomand": "...",              // 调理方案
+    "aijiu": "调理方案:艾灸与按摩",
+    "bagang": "阴阳虚实表里寒热",     // 八纲辨证
+    "botai": 0,                     // 是否有剥苔(0/1)
+    "chagndao": 0,                  // 肠道是否异常
+    "chagndaoDesc": "...",
+    "chagndaoMean": "...",
+    "chihen": 0,                    // 是否有齿痕(0/1)
+    "chihenDesc": "...",
+    "chihenMean": "...",
+    "dianci": 0,                    // 是否有点刺(0/1)
+    "dianciDesc": "...",
+    "fei": 0,
+    "feiDesc": "...",
+    "createTime": "2026-10-08T..."
+    // ... 其他字段
+  },
+  "meta": {}
+}
+```
+
+---
+
+## 三、现有架构
+
+### 3.1 现有舌诊流程
+
+```
+TongueDiagnosisService.parsePreview()
+    ↓
+AIService.sendTongueDiagnosis(imageBytes, filename, userId)
+    ↓
+AiGateway.analyzeTongue(imageBytes, filename, userId)  ← 调用 LangGraph
+    ↓
+LangGraph: POST /api/v1/tongue/diagnose
+    ↓
+返回: { overall_assessment, indicators: [{code, value}] }
+```
+
+### 3.2 新增兜底逻辑
+
+当 LangGraph 不可用时,自动降级到 aikanshe.com API:
+
+```
+AIService.sendTongueDiagnosis(imageBytes, filename, userId)
+    ↓
+AiGateway.analyzeTongue(...)  ← 失败则继续
+    ↓ (LangGraph 返回 null)
+callAikansheTongueDiagnosis(imageBytes, filename, userId)  ← 新增
+    ↓
+POST https://api.aikanshe.com/agency/quanxi
+    ↓
+parseAikansheIndicators(data)  ← 新增:字段映射
+    ↓
+返回: { overall_assessment, indicators: [{code, value}] }
+```
+
+---
+
+## 四、实现变更
+
+### 4.1 application.yml — 新增配置
+
+```yaml
+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}
+```
+
+环境变量可通过 Docker 部署或 `.env` 文件覆盖。
+
+### 4.2 AIService.java — 新增方法
+
+- 新增 6 个 `@Value` 注入字段(`aikansheAppkey`, `aikansheBaseUrl`, `aikansheToken`, `aikansheDesease`, `aikansheConfidence`, `aikansheLang`)
+- 修改 `sendTongueDiagnosis()`:aikanshe.com 优先,失败时回退至 LangGraph
+- 新增 `callAikansheTongueDiagnosis()`:HTTP 调用 aikanshe.com API
+- 新增 `parseAikansheIndicators()`:将 aikanshe 的丰富指标映射到 CFC 标准的 7 类指标
+- 新增 `isValidTongueColor()`:验证舌色值合法性
+- 新增 `makeIndicator()`:辅助方法
+
+### 4.3 指标映射关系
+
+| aikanshe 字段 | CFC 标准 code | 映射逻辑 |
+|---------------|--------------|---------|
+| `feel` 文本含"舌色" | `tongue_color` | 从 feel 文本中提取 |
+| `botai` = 0/1 | `coating_color` | 有苔/无苔 |
+| `botai` = 0/1 | `coating_texture` | 润/滑腻 |
+| `bagang` 含虚/实 | `fissure` | 有/无 |
+| `chihen` = 0/1 | `teeth_mark` | 有齿痕/无齿痕 |
+| `dianci` = 0/1 | `sublingual_vein` | 有点刺/无点刺 |
+| `desease` 关键词 | `constitution` | 气虚质/阴虚质/血瘀质/平和质 |
+
+---
+
+## 五、后续优化建议(YAGNI)
+
+以下不在本次范围,留待后续迭代:
+
+1. **传真实用户信息**:当前 `age/male/name` 使用占位值,实际应从 JWT 上下文获取
+2. **图片存储后传 URL**:当前使用 base64 data URL,建议先上传到 JD Cloud S3,再传 URL 给 aikanshe(减少带宽和延迟)
+3. **完整指标提取**:aikanshe 返回约 50 个字段,可提取更丰富的体质/脏腑信息用于五维能量计算
+4. **调用量监控**:添加调用次数统计和配额告警
+
+---
+
+## 五、调用优先级
+
+**aikanshe.com 优先,LangGraph 兜底**:
+
+```
+启动 → 调用 aikanshe.com API
+       │
+       ├── 成功 → 返回 aikanshe 结果(7类标准指标 + overall_assessment)
+       │
+       └── 失败/超时 → 调用 LangGraph /api/v1/tongue/diagnose
+               │
+               ├── 成功 → 返回 LangGraph 结果
+               │
+               └── 失败 → 返回 mockTongueResult() 默认值
+```
+
+- 配置 `AIKANSHE_APPKEY` 后自动使用 aikanshe.com;未配置或调用失败时自动回退 LangGraph
+- 最终兜底为 mock 数据,确保业务不中断
+
+---
+
+## 六、验证步骤
+
+1. 配置环境变量:
+   ```bash
+   export AIKANSHE_APPKEY="your_appkey_here"
+   export AIKANSHE_BASE_URL="https://api.aikanshe.com"
+   ```
+2. 启动后端:`mvn spring-boot:run`
+3. 测试端点:`POST /api/tongue/diagnose/parse-preview`(上传舌象图片)
+4. 验证返回结构包含 `overall_assessment` 和 `indicators` 数组

+ 454 - 0
docs/superpowers/specs/2026-10-09-indicator-change-curve-design.md

@@ -0,0 +1,454 @@
+# 指标变化曲线 · 指标速查 · 指标关联 设计规范
+
+**版本:** v1.0
+**日期:** 2026-10-09
+**状态:** 已确认(头脑风暴 + Oracle 架构评审通过)
+**作者:** Sisyphus
+
+---
+
+## 1. 背景与目标
+
+### 1.1 现状问题
+
+- 同一类报告(如肠道菌群报告)多次上传后,页面只展示**单份报告**的详情,无法看到各项指标**随时间的变化**(哪项菌群升了、哪项降了)
+- 食材推荐指数虽有 `food_recommend_idx_log` 变化日志和 `getTrend()` 接口,但**没有按变化幅度排序**的能力
+- 指标名散落在各报告的 `health_indicators`(文本名),**没有统一的指标身份**,无法跨报告类型对比、无法做指标间的关联
+- 报告与报告之间、指标与指标之间的**关联关系**(如「记忆力 ↔ 影响睡眠的菌群含量」正/负相关、强度)完全没有数据模型支撑
+- `health_reports.report_date` 目前存的是**上传时间**而非报告真实日期,曲线横轴必然错位
+- 用户与规划师无法快速回答:「这个孩子最近 3 次菌群报告,哪些指标在好转、哪些在恶化?」
+
+### 1.2 设计目标
+
+1. **指标变化曲线**:同一类报告、不同报告日期 → 展示各指标数值随报告日期的变化(菌群增减、食材推荐升降),支持**按变化幅度排名**
+2. **指标速查**:查询某一成员当前所有指标的数值 + 变化趋势(分类分页)
+3. **指标关联关系**:定义指标之间的关联(正/负相关、强度、说明),管理端人工定义 + 真实数据校准(混合模式)
+
+### 1.3 用户故事
+
+- 作为家长,我想看孩子两次菌群报告之间哪些菌群/营养指标上升了、哪些下降了,好判断调理方向
+- 作为家长,我想知道「记忆力」这类认知指标和菌群报告里「影响睡眠的菌群」是否相关、相关性多强
+- 作为规划师/运营,我想在管理端维护指标关联关系和归一化映射规则,校准数据
+
+### 1.4 不在本文档范围(二期)
+
+- DAN / 体检等其余报告详情页的「变化」区块(一期只做菌群报告详情页)
+- 跨报告类型的同曲线叠加对比(如菌群乳杆菌 vs 体检某项)
+- `good_direction` 自动推断(一期管理端手配)
+
+---
+
+## 2. 现状调研结论
+
+| 现有资产 | 内容 | 对本次的可用性 |
+|---|---|---|
+| `health_reports` | 报告主表(report_type/report_date/subject_id/payload_json) | 曲线原数据;`report_date` 需修正为真实日期 |
+| `health_indicators` | 解析后的指标明细(report_id/category/indicator_name/indicator_value/status) | 报告指标原数据;保持为单一写者目标,**不删除** |
+| `report_type_registry` | 报告类型登记 + expected_indicators | 「同一类报告」分组依据;定义/映射种子来源 |
+| `food_recommend_idx` / `_log` | 食材推荐指数 + 变更日志(previous/new/report_id/changed_at) | 食材曲线几乎现成;缺变化幅度排名;保留语义 |
+| `indicator_definitions` / `indicator_values` | 通用指标字典 + 泛用指标值(sourceType/sourceId/definitionId/numericValue/recordedAt) | **升级为全平台统一指标层**(当前仅舌诊在用) |
+| `five_dimension_scores` / `health_dimension_scores` | 五维 / 七维评分(含 assessedAt) | 维度评分趋势现成;**不写入统一时序**,API 层单独合入 |
+| 指标关联表 | **不存在** | 新建 `indicator_correlations` |
+
+**关键缺口**:统一指标身份、归一化映射层、指标关联表、真实报告日期、变化幅度排名接口。
+
+---
+
+## 3. 设计决策
+
+| 决策项 | 选择 | 理由 |
+|--------|------|------|
+| 指标统一身份 | **方案 A**:复用 `indicator_definitions` + `indicator_values` 升级为统一指标层 | 项目本就有意的通用结构;跨类型统一;关联可跨类型;速查一处查全 |
+| 指标范围 | 报告解析指标(含菌属、营养、维生素等)+ 食材推荐指数 + 五维/七维评分 | 用户确认「全报告类型统一」+「上述 + 五维/七维评分」 |
+| 五维/七维存储 | **不进** `indicator_values`,保留原表,API 层单独合入「维度评分」小节 | 维度评分是聚合值,与原始测量混在一个时序中层级混乱 |
+| 关联关系 | 人工定义(候选对+方向+人工强度)+ 真实数据校准(computed_strength + sample_size),混合模式 | 用户确认 |
+| 报告日期 | 从 PDF 提取真实报告/采样日期;历史回填 | 曲线横轴准确性前提,用户确认纳入 |
+| 归一化映射 | 独立表 `indicator_mapping_rules`(source_type+raw_name→definition_id),exact→fuzzy→AI兜底→惰性注册 | Oracle 评审:比在 definitions 上加 aliases 列更可运营、可支撑 fuzzy/AI 兜底 |
+| 食材推荐 | 保留 `food_recommend_idx`+`log`(变化日志语义),同时**增写** `indicator_values`(source_type=food_recommend) | 不破坏现有变化日志接口 |
+| 双写一致性 | 单一写者模式:解析照旧写 `health_indicators`,`NormalizeIndicatorPipeline` 同流程写 `indicator_values`,幂等覆盖 | Oracle 评审:避免两表分叉 |
+| 校准算法 | Spearman 秩相关;单对象 ≥3 配对、聚合有效样本 ≥30 才用 computed 覆盖 manual;非数值型对跳过 | 菌群丰度偏态分布,Spearman 稳健;样本不足不误导 |
+| 图表实现 | 沿用小程序手写 canvas 模式,新增 `TrendChart.vue`,不引入图表库 | 小程序包体积 + HBuilderX 打包约束 |
+| 变化着色 | `good_direction`(越高越好/越低越好/区间内越好)驱动箭头与颜色 | Oracle 评审:没有它无法判断变化是改善还是恶化 |
+| 关联对排序 | `definition_id_a < definition_id_b`(按 code 字典序),应用层强制 | 唯一键对称,避免 A-B / B-A 重复 |
+
+---
+
+## 4. 数据模型
+
+### 4.1 报告日期修正
+
+- PDF 解析阶段(`PdfParseService` / LangGraph `report_parse_agent.py`)新增提取「报告日期 / 采样日期」→ `payload.reportDate`
+- 入库时 `health_reports.report_date` 优先使用真实日期,缺失回退上传时间
+- 历史回填任务:扫描 `report_date` 缺失或为上传时间的旧报告,从其 `payload_json` / 存储 PDF 中重新提取真实日期并回填;**幂等可重跑**;无法提取的置 NULL(不伪装成上传时间)
+- **复用既有入口**:`HealthReportService` 已有 reportDate 修正逻辑(约 1749 行,读 `payload.reportDate` → `yyyy-MM-dd` 解析 → `setReportDate`),新提取只负责把真实日期写入 `payload.reportDate`,入库逻辑复用
+- `health_indicators.collection_date` **已存在于 schema.sql(1868 行)**,无需补列;实施时仅需确认生产库同样存在(用 `ensureColumn` 兜底)
+
+### 4.2 `indicator_definitions` 扩展
+
+新增列:
+
+| 列 | 类型 | 说明 |
+|---|---|---|
+| `source_kind` | VARCHAR(32) | `report_indicator` / `bacteria` / `food_index` / `dimension_score` |
+| `report_type_scope` | VARCHAR(32) | 适用报告类型(空=通用),支撑「同一类报告」分组 |
+| `value_type` | VARCHAR(16) | `numeric` / `categorical` / `level`(语义类型,与 dataType 区分) |
+| `good_direction` | VARCHAR(16) | `higher_is_better` / `lower_is_better` / `in_range_is_better` / `neutral` |
+| `dimension_code` | VARCHAR(16) | 关联五维(body/mind/wisdom/action/wealth,可空) |
+
+保留现有:`domain` / `category` / `code` / `name` / `dataType` / `options` / `unit` / `refRange` / `sortOrder` / `status`。
+
+**目录来源**:
+- `report_type_registry.expected_indicators` → 预生成每类报告的指标定义
+- 菌属 200+ / 食材 224 → **惰性注册**(首次出现即 upsert,不预埋),code 使用 `bacteria.<name>` / `food.<foodId>` / `indicator.<category>.<name>` 风格
+- 五维/七维 → 预置少量定义(`dim.body` 等,仅作展示标签,不写 indicator_values)
+
+### 4.3 `indicator_values` 扩展(统一观测时序)
+
+新增列:
+
+| 列 | 类型 | 说明 |
+|---|---|---|
+| `subject_id` | BIGINT | 观测对象(对齐 `health_reports.subject_id` / family_members.id) |
+| `family_id` | BIGINT | 所属家庭 |
+| `report_id` | BIGINT | 来源报告(可空:食物指数 / 维度评分类) |
+| `report_date` | DATE | 冗余报告日期,供时序查询 |
+| `unit` | VARCHAR(32) | 观测单位(若与定义不一致,写入时告警) |
+| `status` | VARCHAR(32) | 观测时状态快照(正常/偏高/偏低…) |
+
+保留现有:`source_type` / `source_id` / `definition_id` / `value` / `numeric_value` / `remark` / `recorded_at`。
+
+**索引与约束**:
+```sql
+ALTER TABLE indicator_values
+  ADD COLUMN subject_id BIGINT NULL,
+  ADD COLUMN family_id BIGINT NULL,
+  ADD COLUMN report_id BIGINT NULL,
+  ADD COLUMN report_date DATE NULL,
+  ADD COLUMN unit VARCHAR(32) NULL,
+  ADD COLUMN status VARCHAR(32) NULL,
+  ADD UNIQUE KEY uk_source_definition (source_type, source_id, definition_id),
+  ADD INDEX idx_subject_def_date (subject_id, definition_id, report_date),
+  ADD INDEX idx_def (definition_id);
+```
+
+- 唯一约束保证**幂等**:同一 (source_type, source_id, definition_id) 重解析只覆盖更新
+- `source_type` 取值:报告类型(`gut_flora` 等)/ `food_recommend`(五维/七维评分**不入本表**,见 4.2)
+
+### 4.4 `indicator_mapping_rules`(新建)
+
+```sql
+CREATE TABLE IF NOT EXISTS indicator_mapping_rules (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  source_type VARCHAR(32) NOT NULL COMMENT 'gut_flora|physical_exam|dan_a2|food_recommend|five_dimension|health_dimension',
+  raw_name VARCHAR(200) NOT NULL COMMENT '报告中的原始名称(指标名/菌名/食材名)',
+  alias_text TEXT COMMENT '别名 JSON 数组,用于模糊匹配',
+  definition_id BIGINT NOT NULL COMMENT 'FK → indicator_definitions.id',
+  match_type VARCHAR(20) DEFAULT 'exact' COMMENT 'exact|fuzzy|ai_fallback|auto_created',
+  priority INT DEFAULT 0 COMMENT '多规则冲突时取最高',
+  status TINYINT DEFAULT 1,
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_source_raw (source_type, raw_name),
+  INDEX idx_def (definition_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标归一化映射规则';
+```
+
+### 4.5 `indicator_correlations`(新建)
+
+```sql
+CREATE TABLE IF NOT EXISTS indicator_correlations (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  definition_id_a BIGINT NOT NULL COMMENT 'code 字典序较小的指标',
+  definition_id_b BIGINT NOT NULL COMMENT 'code 字典序较大的指标',
+  relation_type VARCHAR(20) DEFAULT 'correlation' COMMENT 'correlation(相关)|influence(影响)|same_source(同源)',
+  direction VARCHAR(10) NOT NULL COMMENT 'positive|negative',
+  manual_strength DECIMAL(3,2) NOT NULL COMMENT '人工标定强度 0~1,样本不足时使用',
+  computed_strength DECIMAL(4,3) NULL COMMENT '统计计算相关系数(Spearman rho)',
+  method VARCHAR(20) DEFAULT 'spearman' COMMENT 'pearson|spearman',
+  sample_size INT DEFAULT 0 COMMENT '参与计算的聚合有效样本数',
+  confidence VARCHAR(10) COMMENT 'high(>=30)|medium(10~29)|low(<10)',
+  effective_strength DECIMAL(4,3) NOT NULL COMMENT '展示值:有效样本>=30 时取 computed,否则取 manual',
+  manual_locked TINYINT DEFAULT 0 COMMENT '1=人工锁定,数据校准不覆盖 direction/manual_strength/effective_strength',
+  cohort_scope VARCHAR(20) DEFAULT 'subject' COMMENT 'subject|family|population',
+  status VARCHAR(20) DEFAULT 'active' COMMENT 'active|inactive|pending',
+  computed_at DATETIME NULL COMMENT '最后校准时间',
+  created_by BIGINT NULL COMMENT '创建者(人工定义时)',
+  description VARCHAR(500) NULL COMMENT '给用户看的解释',
+  source_note VARCHAR(255) NULL COMMENT '依据/出处',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_pair (definition_id_a, definition_id_b),
+  INDEX idx_def_a (definition_id_a),
+  INDEX idx_def_b (definition_id_b)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='指标关联关系(人工定义 + 数据校准)';
+```
+
+**对序规则**:写入前按 `definition.code` 字典序比较,小的存 `a`、大的存 `b`,应用层强制,保证唯一键对称。
+
+### 4.6 数据校准(Spearman 任务)
+
+- 定时任务(Spring `@Scheduled`,每日凌晨)+ 管理端手动触发(`/api/admin/indicator/correlation/calibrate`)
+- 范围:`status='active'` 且 `manual_locked=0` 的关联对
+- 算法:
+  1. 仅计算两端指标均为数值型/有序等级型的配对(`value_type IN ('numeric','level')`)
+  2. 按 `subject_id` 分组,对每个对象取该指标对的**配对测量序列**(按 report_date 对齐,≥3 个配对点才参与)
+  3. 每对象算 Spearman rho → Fisher z 变换 → 全对象 z 均值 → 还原为平均 rho
+  4. 有效样本量 = 参与聚合的对象配对点总数;`confidence`:≥30 → high,10~29 → medium,<10 → low
+  5. 更新 `computed_strength` / `sample_size` / `method` / `confidence` / `computed_at`
+  6. `effective_strength = 有效样本>=30 ? computed_strength : manual_strength`(方向若与人工矛盾且锁定时,保持人工)
+- Java 8 无内置相关函数:新增约 20 行工具方法(排序后计算秩差平方),不引外部依赖
+
+---
+
+## 5. 归一化映射管道(单一写者)
+
+```
+报告解析/采集确认(或重解析)
+  ├─ 原有逻辑照旧写 health_indicators(不动现有消费者:HealthAnalysisService 等)
+  └─ NormalizeIndicatorPipeline(同流程/同事务内)
+       1. 收集该报告全部:health_indicators 明细 + 菌属(payload/gut_flora)+ 食物(payload/食材)
+       2. 每条 (source_type=报告类型, raw_name) 解析 definition_id:
+            exact 命中 indicator_mapping_rules → definition_id
+            fuzzy 命中(CJK 二元组 / 拼音归一化)→ definition_id
+            AI 兜底(AiGateway → LangGraph 归类)→ 建议 definition_id(登记 pending 规则,管理端确认)
+            新发现 → 惰性注册新 definition + 自动登记 exact 规则(match_type=auto_created)
+       3. 幂等写入 indicator_values
+            INSERT ... ON DUPLICATE KEY UPDATE (source_type, source_id, definition_id)
+       4. 记录 subject_id / family_id / report_id / report_date(真实报告日期)
+```
+
+**食物推荐指数钩子**:`FoodRecommendService.recalculateForUser()` 完成后,把该用户最新 `food_recommend_idx` 全量快照写入 `indicator_values`(`source_type=food_recommend`,`source_id=food_recommend_idx.id`,`definition_id` 经 `food.<foodId>` 映射)。
+
+**批量种子**:管理端 `/api/admin/indicator/mapping/import` 一次性导入菌属 200+ / 食材 224 / expected_indicators 的映射种子。
+
+---
+
+## 6. 后端 API 契约
+
+> 统一 `@PostMapping`、`Result<T>`、JWT;`subject_id` 服务端校验归属(当前用户家庭内成员)。
+
+### 6.1 指标曲线
+
+```
+POST /api/indicator/trend
+body: { subjectId, definitionIds: [1,2,3] }
+→ data: {
+    "items": [{
+      "definitionId": 1, "name": "双歧杆菌属",
+      "points": [ { "reportDate":"2026-08-15", "value":"12.8", "unit":"%", "status":"偏高" }, ... ]
+    }]
+  }
+```
+
+横轴 `reportDate` = 真实报告日期,升序。
+
+### 6.2 变化幅度排名
+
+```
+POST /api/indicator/change-ranking
+body: { subjectId, reportType?, category?, scope: "adjacent"|"first-vs-latest", orderBy: "up"|"down"|"magnitude", limit: 50 }
+→ data: {
+    "items": [{
+      "definitionId": 5, "name": "维生素B1", "category": "维生素",
+      "sourceKind": "report_indicator", "unit": "mg",
+      "before": { "reportDate":"2026-08-15", "value":"2" },
+      "after":  { "reportDate":"2026-10-01", "value":"4" },
+      "delta": 2, "deltaPct": 100,
+      "movement": "up"|"down"|"flat",
+      "trendLabel": "改善"|"恶化"|"中性"   // 按 good_direction 判定
+    }],
+    "overall": { "improvedCount": 12, "worsenedCount": 3, "flatCount": 5 }
+  }
+```
+
+- `scope=adjacent`:相邻两次报告之差(默认);`first-vs-latest`:首尾对比
+- 排序:`magnitude` 按 Δ 绝对值降序(默认);`up`/`down` 按 Δ 正/负降序
+- 食材指数单独过滤(`sourceKind=food_index`)或混合(按 category 过滤)
+
+### 6.3 指标速查
+
+```
+POST /api/indicator/quick-lookup
+body: { subjectId, category?, page: 1, pageSize: 30 }
+→ data: {
+    "categories": [ { "name":"核心菌属", "total": 32 }, ... ],
+    "items": [{
+      "definitionId": 1, "name": "双歧杆菌属", "category": "核心菌属",
+      "value": "12.8", "unit": "%", "refRange": "0.19-14.59", "status": "偏高",
+      "trend": "up"|"down"|"flat", "trendLabel": "改善",
+      "latestReportDate": "2026-10-01", "pointsCount": 3
+    }],
+    "dimensionScores": [   // 五维/七维评分小节(不写 indicator_values,API 层 join 原表)
+      { "dimensionCode":"body", "name":"身", "score": 82, "assessedAt":"2026-09-30", "trend": "up" }
+    ]
+  }
+```
+
+分类分页(每类 ≤30 项),避免一次拉取 200+ 菌属。
+
+### 6.4 指标关联查询
+
+```
+POST /api/indicator/correlation/list
+body: { subjectId, definitionId }
+→ data: {
+    "relations": [{
+      "relation": { "pairId": 1, "indicatorA": {"definitionId":1,"name":"记忆力"}, "indicatorB": {...},
+                    "direction":"positive", "effectiveStrength": 0.72,
+                    "strengthSource":"computed"|"manual", "sampleSize": 45,
+                    "confidence":"high", "manualLocked": false,
+                    "description":"记忆力与睡眠相关菌群含量正相关……" },
+      "subjectEvidence": {   // 该成员自己的配对数据支撑(如有)
+        "points": 4, "personalRho": 0.68, "hasEnough": true
+      }
+    }]
+  }
+```
+
+### 6.5 管理端(cfc-web)
+
+| 接口 | 用途 |
+|---|---|
+| `POST /api/admin/indicator/correlation/save` | 人工定义/编辑关联对(A/B/方向/人工强度/说明/locked/来源) |
+| `POST /api/admin/indicator/correlation/list` | 关联对分页列表(含校准状态) |
+| `POST /api/admin/indicator/correlation/calibrate` | 手动触发全校准或单对校准 |
+| `POST /api/admin/indicator/mapping/import` | 批量导入映射种子(JSON/CSV) |
+| `POST /api/admin/indicator/mapping/list` | 映射规则分页 + 未匹配 raw 的 review 列表 |
+
+### 6.6 复用既有接口
+
+- `POST /api/food/recommendation/trend`(食材单指标趋势)继续用于菌群食物详情页
+- `POST /api/health/knowledge/query` 知识库弹出框继续用于速查/曲线页指标名称点击
+
+---
+
+## 7. 前端页面
+
+### 7.1 小程序(家长端双角色视图)
+
+**指标变化曲线页** `pages/health/indicator-curve.vue`(新增)
+- 入口:菌群报告详情页顶部「变化」入口、`pages/health/report-list` 顶部、身维度页
+- 布局:
+  - 顶部筛选:成员切换 + 报告类型 + 时间范围
+  - 曲线区:指标选择器 → `TrendChart` 折线(横轴=真实报告日期,多报告点,数据点标注数值)
+  - 变化排名区:Tab(菌群变化 / 食材变化 / 指标变化)+ 排序(升幅↑ / 降幅↓ / 绝对幅度);相邻两次报告 Δ;按 `good_direction` 着色
+- 图表:新增通用 `components/TrendChart.vue`(多序列折线 + 数据点 + 箭头),沿用**手写 canvas** 模式(参考 `RadarChart.vue` 的 type=2d + DPR 适配 + 重试初始化),不引入图表库
+
+**指标速查页** `pages/health/indicator-quicklook.vue`(新增)
+- 分类 Tab + 分页(每类 ≤30 项/页)
+- 每项一行:名称(可点弹知识库,复用 `health-knowledge-popup`)| 最新值 | 参考区间 | ↑↓→ 趋势 | 最近报告日期
+- 独立小节:五维/七维评分趋势
+- 点击单项 → 跳转该指标曲线页(携带 definitionId)
+
+**菌群报告详情页** `pages/health/gut-flora-detail.vue`(改)
+- 增加「变化」区块:最近两次同类型报告的菌属 Δ,红/绿标注(一期仅此报告详情页;DAN/体检详情页变化区块留到二期)
+
+**pages.json**:注册 2 个新页面路径;**utils/api.js**:新增 trend / change-ranking / quick-lookup / correlation/list 封装(遵守 API 去重规范,同 URL 单一规范入口)
+
+### 7.2 Web 管理端(cfc-web)
+
+**`views/admin/IndicatorCorrelations.vue`**(新增)
+- 关联对配置:选 A/B 指标(定义搜索框)、方向、人工强度、说明、`manual_locked` 开关
+- 列表:computed_strength / sample_size / confidence / last_calibrated_at;一键触发校准
+
+**`views/admin/IndicatorMappings.vue`**(新增)
+- 归一化规则管理(source_type + raw_name → definition_id)+ 批量导入 + 未匹配 raw review(AI 建议确认)
+
+**router**:注册对应管理端路由(admin 权限)
+
+---
+
+## 8. 变化颜色与箭头规则
+
+由 `good_direction` × 变化方向判定,全局统一:
+
+| good_direction | 数值↑ | 数值↓ | 备注 |
+|---|---|---|---|
+| `higher_is_better` | 🟢 绿 ↑(改善) | 🔴 红 ↓(恶化) | 如双歧杆菌属 |
+| `lower_is_better` | 🔴 红 ↑(恶化) | 🟢 绿 ↓(改善) | 如炎症水平 |
+| `in_range_is_better` | 向参考区间内移动 → 🟢 绿 ↑/↓;远离 → 🔴 红 | 结合 refRange 判定 | 如维生素 |
+| `neutral` | ⚪ 灰 ↑ | ⚪ 灰 ↓ | 无优劣方向 |
+| 无变化 | ⚪ 灰 → | | |
+
+实现遵循小程序限制:`:class="'mb-' + item._cssClass"` 内联表达式(禁方法调用、禁中文类名),`_cssClass` 由后端或前端 computed 映射为英文(`impr-up`/`worst-down`/`flat` 等)。
+
+---
+
+## 9. 验收标准
+
+| # | 验收项 | 验证方式 |
+|---|---|---|
+| 1 | 上传第二份同类型报告后,曲线页显示完整前后对比;变化排名按幅度排序正确 | 手工 + e2e |
+| 2 | 变化箭头颜色符合 good_direction(如炎症下降=绿、双歧杆菌上升=绿) | 手工 |
+| 3 | 速查按分类分页正常,含五维/七维小节;点击单项跳转曲线 | 手工 |
+| 4 | 管理端配置关联对 → 小程序立即可见;有真实配对数据显示校准强度+样本量;样本<30 显示人工值 | 手工 |
+| 5 | 新报告 report_date = PDF 真实日期;历史回填任务幂等可重跑(连跑两次结果一致) | 脚本 + 手工 |
+| 6 | 归一化幂等:同一报告重解析不产生重复 observation(唯一键生效) | 脚本断言 |
+| 7 | `report_type_registry.expected_indicators` 与默认映射种子导入后,解析出的常见指标 100% 命中映射(无 AI 兜底) | 脚本统计 |
+| 8 | `mvn clean compile` 通过;前端 script 语法校验(node --check)通过;API 去重审计通过 | CI 命令 |
+
+---
+
+## 10. 文件变更清单
+
+| 层 | 文件 | 操作 | 内容 |
+|---|---|---|---|
+| 后端 | `config/DatabaseInitializer.java` | 改 | 迁移:definitions/values 加列 + 索引 + 唯一键;新建 mapping_rules/correlations 两表;report_date 真实日期回填;collection_date 已存在(schema.sql 1868 行)用 ensureColumn 兜底生产库;种子映射导入(当前最新迁移编号 355,从 356 起递增) |
+| 后端 | `docs/superpowers/api/API_REFERENCE.md` | 改 | 新增接口前查重、新增后同步记录(trend/change-ranking/quick-lookup/correlation + 管理端 5 接口) |
+| 后端 | `resources/schema.sql` | 改 | 同步 4 处 DDL |
+| 后端 | `entity/IndicatorDefinition.java` / `IndicatorValue.java` | 改 | 新字段 |
+| 后端 | `entity/IndicatorMappingRule.java` / `entity/IndicatorCorrelation.java` | 新建 | 新实体 |
+| 后端 | `mapper/IndicatorMappingRuleMapper.java` / `mapper/IndicatorCorrelationMapper.java` | 新建 | BaseMapper |
+| 后端 | `service/IndicatorMappingService.java` | 新建 | resolve(raw)→definitionId;register;import 种子 |
+| 后端 | `service/NormalizeIndicatorPipeline.java` | 新建 | 解析后钩子,幂等写 indicator_values |
+| 后端 | `service/IndicatorTrendService.java` | 新建 | trend / change-ranking / quick-lookup |
+| 后端 | `service/IndicatorCorrelationService.java` | 新建 | 关联 CRUD + effective_strength + Spearman 校准 |
+| 后端 | `service/ReportDateExtractor.java` | 新建 | PDF/payload 真实日期提取(写入 `payload.reportDate` 后复用 HealthReportService 既有入库逻辑)+ 历史回填 |
+| 后端 | `controller/IndicatorController.java` | 新建 | /api/indicator/*(4 个视图接口) |
+| 后端 | `controller/admin/IndicatorCorrelationAdminController.java` / `IndicatorMappingAdminController.java` | 新建 | 管理端接口 |
+| 后端 | `service/FoodRecommendService.java` | 改 | recalculateForUser 后增写 indicator_values |
+| 后端 | `service/AiGateway.java`(及 LangGraph) | 改 | 新增指标归类兜底方法/endpoint |
+| 后端 | 新增 `job/IndicatorCalibrationJob.java` | 新建 | @Scheduled 每日校准 |
+| 小程序 | `components/TrendChart.vue` | 新建 | 通用折线组件(手写 canvas) |
+| 小程序 | `pages/health/indicator-curve.vue` / `indicator-quicklook.vue` | 新建 | 曲线页 / 速查页 |
+| 小程序 | `pages/health/gut-flora-detail.vue` | 改 | 增加「变化」区块 |
+| 小程序 | `pages.json` | 改 | 注册 2 页 |
+| 小程序 | `utils/api.js` | 改 | 新增 4 个接口封装 |
+| Web 端 | `views/admin/IndicatorCorrelations.vue` / `IndicatorMappings.vue` | 新建 | 关联配置 / 映射管理 |
+| Web 端 | `router/index.js` | 改 | 注册管理端路由 |
+| 测试 | `tests/e2e/indicator-curve-flow.spec.js` 等 | 新建 | 曲线/速查/关联冒烟 |
+
+---
+
+## 11. 风险与权衡
+
+| 风险 | 缓解 |
+|---|---|
+| 归一化映射质量(AI 兜底可能错配) | 映射规则管理端可 review;AI 建议先登记 pending,人工确认后生效 |
+| 双写一致性(health_indicators vs indicator_values) | 单一写者;同事务;唯一键幂等覆盖 |
+| 速查性能(200+ 菌属 × 多次报告) | subject_id + 复合索引;分类分页;每类 30 项 |
+| 历史报告回填工作量大 | 分批、幂等、可中断续跑;取不出日期的置 NULL |
+| 方向/颜色误判 | good_direction 管理端可维护;一期手配,二期自动推断 |
+| 关联校准样本不足 | effective_strength 逻辑:样本 ≥30 才用计算值,否则人工值;confidence 分级展示 |
+| 指标名变化(同一菌不同报告写法不同) | fuzzy 匹配 + alias_text + AI 兜底多级回落 |
+
+---
+
+## 12. 实现顺序(分期)
+
+1. **P1 后端数据层**:迁移(4 处 DDL + report_date 回填,collection_date 已存在仅兜底)→ 实体/Mapper/Service → `mvn clean compile`
+2. **P2 归一化管道**:`IndicatorMappingService` + `NormalizeIndicatorPipeline` + 食物钩子 + 种子导入 → 幂等验证
+3. **P3 视图 API**:trend / change-ranking / quick-lookup(含五维七维小节)+ correlation/list → `mvn clean compile`
+4. **P4 小程序页面**:`TrendChart` → 曲线页 → 速查页 → 菌群详情「变化」区块 → pages.json / api.js → HBuilderX 打包人工验证
+5. **P5 管理端**:`IndicatorCorrelations.vue` + `IndicatorMappings.vue` + admin API + 路由
+6. **P6 校准任务**:Spearman 工具 + `IndicatorCalibrationJob` + 管理端手动触发 + 验收清单逐项过
+
+---
+
+## 13. 二期展望
+
+- DAN / 体检等其余报告详情页「变化」区块
+- 跨报告类型同曲线叠加对比(菌群乳杆菌 vs 体检某项)
+- 关联关系的知识库弹出框内展示(点击关联项弹出说明)
+- `good_direction` 从参考区间/历史数据自动推断
+- 关联方向与人工矛盾时的冲突提醒与运营工作流