Sfoglia il codice sorgente

docs(plan): 指标变化曲线·指标速查·关联 - backend/miniapp/webadmin 三份实施计划 + PROJECT-OVERVIEW 同步

- backend: 迁移356-362 两新表 + NormalizeIndicatorPipeline + 4视图API + change-ranking + Spearman 校准 + 2 admin Controller 5端点 + API_REFERENCE 同步
- 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 份计划索引 + 状态行更新
iwt 3 giorni fa
parent
commit
66c974c00f

+ 5 - 2
docs/superpowers/PROJECT-OVERVIEW.md

@@ -3,7 +3,7 @@
 **文档版本:** v2.16
 **日期:** 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`)**
-**指标变化曲线·指标速查·指标关联(✅ 设计已确认 v1.0,待实施,见 `specs/2026-10-09-indicator-change-curve-design.md`)**
+**指标变化曲线·指标速查·指标关联(✅ 设计已确认 v1.0 + 3 份实施计划已就绪(backend/miniapp/webadmin),见 `specs/2026-10-09-indicator-change-curve-design.md`)**
 **维护:** 所有需求变更需更新本文档
 
 ---
@@ -117,7 +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 待实施(统一指标层升级 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` |
+| 指标变化曲线·指标速查·指标关联 | 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` |
 
 ---
 
@@ -385,6 +385,9 @@
 
 | 文件 | 状态 | 说明 |
 |------|:----:|------|
+| `2026-10-09-indicator-change-curve-backend.md` | 🟡 待执行(11 任务:迁移 356-362 两新表 indicator_mapping_rules/indicator_correlations + indicator_definitions/indicator_values 升级、NormalizeIndicatorPipeline 单一写者、4 视图 API、change-ranking、Spearman 校准、2 个 admin Controller 5 端点(L3113-3117)、API_REFERENCE 同步) | 指标变化曲线·速查·关联 — 后端实施计划(设计稿: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 文档同步 + 旧章鱼图清理) | 关键人拓展章鱼图替换实现计划 |

+ 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 端点后追加(本期明确不做)。