瀏覽代碼

docs(spec): 补充指标多期展示设计文档与营养商品标注 PRD

- 2026-10-25-indicator-timeline-display-design.md(T02-T05 依据)
- 2026-10-25-indicator-timeline-display-prd.md
- 2026-10-27-nutrition-product-tagging-prd.md(T19 B/C 线依据)
Agnes Agent 2 周之前
父節點
當前提交
cd4fb3619e

+ 3327 - 0
docs/superpowers/specs/2026-10-25-indicator-timeline-display-design.md

@@ -0,0 +1,3327 @@
+# 指标多期展示 · 增量系统设计 + 任务分解
+
+| 项 | 内容 |
+|---|---|
+| 文档编号 | DS-2026-10-25-01 |
+| 创建日期 | 2026-10-25 |
+| 作者 | 高见远(架构师) |
+| 上游输入 | `2026-10-25-indicator-timeline-display-prd.md`(许清楚,PRD-2026-10-25-01) |
+| 状态 | 待评审(廖总拍板 §9 待明确事项) |
+| 涉及模块 | `cfc-backend`(Spring Boot 2.7.18 + MyBatis-Plus + Java 8)、`cfc-frontend`(uni-app 微信小程序 Vue2 Options API)、`cfc-web`(Vue2 + Element UI) |
+| 交付性质 | **增量改造设计(非从零设计);本轮只出设计,不改任何现有文件** |
+
+> **代码事实基线**:本文所有行号引用均基于 2026-10-25 工作区代码实际核验,非推测。核验清单见 §10。
+
+---
+
+## 1. 设计目标与范围
+
+### 1.1 一句话技术方案
+
+> **不引入新表、不引入图表库、不改现有写入契约**:在现有 `indicator_values` 上加 2 列(日期来源 + 参考范围快照),补齐 confirm 链路的时序写入,抽出一个纯计算的 `IndicatorChangeService` 作为前后端共同的变化语义底座,然后按「报告详情页内嵌对照(第一现场)→ 统一时间轴页(深度视图)→ 任意两期对比(复盘视图)」三层递进改造展示层。
+
+### 1.2 本轮做什么(按批次)
+
+| 批次 | 内容 | 对应 PRD 需求 | **D12 上线段** |
+|---|---|---|---|
+| **P0** | ① confirm 链路补写 `indicator_values`;② 新增 `IndicatorChangeService`(上一期查找 / skipGaps / refRange 解析 / in_range 判定 / Δ 规则);③ 报告详情页指标列表改造为「本期 vs 上一期」多档形态 + 筛选;④ 新建统一时间轴页 `indicator-timeline`(含 n 决定图表形态的降级策略);⑤ 修 3 个阻断 bug(api.js 导出、onLoad 参数、quickLookup null 排序);⑥ 新增 `pending_backfill` 降级中间态 | P0-1 ~ P0-7 | **见下方分拆** |
+| **P1** | ① 任意两期对比(`scope=pair` + 期次列表接口 + 双期对比页);② 列表内嵌 sparkline;③ 报告列表选两份对比;④ 身维度卡片升级;⑤ 日期来源标记落地;⑥ 非数值指标文字历史;⑦ 体脂秤并入时间轴 | P1-1 ~ P1-7 |
+| **P2** | ① 管理端成员指标档案页(最小版);② 图表长按读数;③ 导出分享;④ 多指标同图归一化 | P2-1 ~ P2-4 |
+
+### 1.2.1 D12 分拆上线(✅ **已裁定 = B:先上报告详情页对照**)
+
+> ✅ **裁定时间与依据**:2026-10-25 深夜,主理人就"分拆时谁先上"重新向用户征询,用户在明确知晓「60–90% 分拆时**先上报告详情页对照**」这一表述的前提下选择该方案。
+> **解决了此前的标签冲突**:主理人原话「采纳 PM 的 B 方案 + 第一段 = 时间轴页」确系参照 PM 第 2 轮旧稿(旧 B = 先时间轴页);PM 第 3 轮定稿已将 B 改为**先对照**、先时间轴页标为 C 已撤回。现裁定与 **PM 定稿(B)、架构师建议(先对照 + 折中)三方一致**。
+> **T04 可按本节开工**,不再受阻。
+
+**分两段发版,拆的是前端接线时机,不是后端部署次数。**(此原则不受裁定影响 ✅)
+
+| 段 | 内容(**已裁定**) | 放行条件 | 对应任务 |
+|---|---|---|---|
+| **第一段** | 报告详情页「本期 vs 上一期」对照 + 趋势入口(跳旧 curve 页 `mode='single'`)<br>**另含**:P0-7 三个阻断 bugfix + P0-5 降级策略 + P1-5 日期来源标记 + P1-6 非数值通道 + P1-7 体脂秤 | **无需等待回填**。但落点页面**必须**随本段上线「残缺横幅」(见 §6.3 红线) | T01、T02、T03 + T04 第一段文件 + T05-5a/5c 部分 |
+| **第二段** | 新时间轴页(多指标对比 + 共享筛选状态 + 关注指标收藏);入口改指向新页 | 报告详情对照**须先通过 §7 的第二段验收门槛**(L1 ≥ 85% ∧ L2 ≥ 90% ∧ L3 抽样通过) | T04 第二段文件 |
+
+> **两项配套约束(PM 定稿,主理人确认)**:
+> ① **对照与「查看趋势」入口必须同批上** —— 否则用户看到变化却无处下钻。
+> ② **灰度阀门用 D1 开关,不用"首周只对菌群报告开启"** —— 菌群是主力且流量最大的报告类型,限定它等于把风险放在最敏感位置。D1 已裁定**默认开**;若需保守放量,改为首周默认关、用户可自开,放量只改一个默认值,零额外代码。
+
+> **⚠️ 本节曾经的错误(已闭环)**:本节标题曾为「主理人已拍板」且内容写死"第一段 = 时间轴页",与 PM 定稿标签冲突。**教训:文档里"已拍板"这类状态标签必须与存疑项显式关联,不能孤立存在;选项字母(A/B/C)在不同轮次文档中含义可能漂移,跨轮引用时必须核对定稿标签。**
+
+**分拆的收益(三种结论下均成立)**:
+1. 新能力先落地,不必被历史数据整理拖住。
+2. 报告详情页的对照是**对数据的断言**,回填完成前上会对手里有 3 份报告的用户说"首次记录"——那是**错误陈述**,不是保守表达。有了 `pending_backfill` 后降级为"历史数据整理中",诚实,但功能价值仍不完整。
+3. **后端只发一次版**(T02 全量部署,含 `/api/health/report/indicators`)—— 接口上线 ≠ 功能上线,前端不接就不可见。
+
+**不受裁定影响、可立即开工的部分**(PM 与我共同确认,防止"顺序未定 = 全线停摆"的误读):
+- 残缺横幅已组件化(`IndicatorIncompleteBanner.vue`,归入 T03),两种顺序共用
+- 四条件文案契约已定
+- **T01 / T02 / T03 全部不阻塞,可立即开工**
+- 唯一被阻塞的是 **T04 的两段文件划分**
+
+> **架构附注(我对 D12 上线顺序的意见)**:
+> 我主张"先上对照",理由是①残缺代价不对称(对照残缺 = 局部降级;时间轴页残缺 = 核心承诺"完整历史"整体失效);②两者数据依赖同源,分拆不能规避数据缺口风险;③对照是 G1「第一现场就能看懂变化」的载体。
+> 主理人先采纳了相反顺序(时间轴页先上),理由是新页面易回滚、不牵动用户默认入口。
+> **PM 随后撤回其 B 方案,改为主张先上对照**(见 §9-Q15 的讨论记录)。
+>
+> **无论最终取哪个顺序,「残缺横幅」都是前提**——因为主理人原始论证中的「时间轴页缺数据时显示『暂无趋势数据』属诚实降级」**只有在用户真的只有 1 份报告时才成立**。用户有 5 份报告时显示「暂无趋势数据」是**声称数据不存在**,比「首次记录」更严重的错误陈述。
+>
+> **结论:有横幅,两个顺序都成立;没横幅,两个顺序都不成立。**
+
+### 1.2.1.1 顺序无关的不变量:P0-2 入口必须与落点页面同批
+
+> **PM 提出、架构确认。这是唯一不受 D12 裁定结果影响的不变量。**
+
+**规则:入口永远不能单独延后,必须与它指向的落点页面同批上线。**
+
+| D12 裁定结果 | F1 段内容 | 落点 |
+|---|---|---|
+| **B(先对照)** | 对照 + **入口跳旧 `indicator-curve` 页** | 旧 curve 页(须已完成 P0-7 修复 + 挂横幅 + `mode='single'`) |
+| **C(先时间轴页)** | 新 `indicator-timeline` 页 + **入口** | 新 timeline 页 |
+
+**违反的后果**:
+- 若只有对照没有入口 → 用户看到「较 08-12 ↓ -0.48 改善」却**无处下钻**
+- 若只有新页面没有入口 → **新页面无入口可达**,等于没上线
+
+**旧 curve 页作为落点时的三条硬约束(P0-2 验收标准)**:
+
+| # | 约束 | 原因 |
+|---|---|---|
+| ① | 必须先完成 P0-7 三个 bugfix | 旧 curve 页当前**必崩**:`getIndicatorSourceTypes` 未导出 → 进页面即 TypeError |
+| ② | **必须挂残缺横幅**(复用 `IndicatorIncompleteBanner`) | 否则 n=0 时显示「暂无趋势数据」= 声称数据不存在 |
+| ③ | **传 `definitionId` 时进入 `mode='single'`**:隐藏变化排名区 + 跳过 ranking 调用 + 直接拉 trend | 否则用户点「LDL-C 的趋势」看到的是"变化排名 Top 50",界面错误且白拉一次请求 |
+
+> **架构注**:③ 是 PM 方案里没有、我补充的一条。F2 期新 timeline 页做好后,入口改指过去,旧 curve 页的 `mode='single'` 分支可随页面一同废弃。
+
+### 1.2.2 残缺横幅 —— 组件化(两个上线顺序的共同前提)
+
+**因为两个顺序都可能被采用,残缺横幅必须做成组件而非页面内嵌**,否则顺序一变就要重写。
+
+| 组件 | 落点 | 用途 |
+|---|---|---|
+| **`components/indicator/IndicatorIncompleteBanner.vue`** | **新建**(归入 T03 公共底座) | 承接 `pointsCount < totalReportCount` 的橙色提示条。由 `IndicatorDegrade.vue` 的 `scene='incomplete'` 扩展而来,或直接复用 `IndicatorDegrade` |
+| 使用方 | ① `indicator-timeline.vue`(新时间轴页)<br>② `indicator-curve.vue`(旧曲线页,若作为 P0-2 落点则必须用)<br>③ `report-detail.vue`(卡片头,用 `summary.pendingCount`) | 三处复用同一文案,避免漂移 |
+
+**文案契约**(三处共用,由组件硬编码,页面不得改写。**采纳 PM 的四条件表,比我原三档更完整**):
+
+| 条件 | 文案 | 颜色 |
+|---|---|---|
+| `0 < pointsCount < totalReportCount` | ⓘ 该成员共 **N** 份报告,本指标目前 **M** 期数据(历史数据整理中) | 橙 |
+| `pointsCount == 0 && totalReportCount > 0` | ⓘ 该成员共 **N** 份报告,本指标暂无可显示的历史数据 | 橙 |
+| `pointsCount == 0 && totalReportCount == 0` | ⓘ 该成员暂无报告数据 | 灰 |
+| `pointsCount == totalReportCount` | **横幅隐藏**(数据完整,无需解释) | — |
+
+**硬性禁止**(写进验收,防实现时退回老文案):
+
+- ❌ 任何情况下**不得**显示孤立的「暂无趋势数据」(不带报告总数)
+- ❌ 不得用「暂无趋势数据」覆盖 `totalReportCount > pointsCount` 的情形
+- ❌ 第四条件(数据完整)时**不得**显示横幅——否则用户每次看正常数据都被打扰
+
+> **PM 补的第四条件修正了我原方案的一个漏洞**:我原来只分了三档,没有 `totalReportCount == 0` 的分支,会导致文案变成「共 **0** 份报告,本指标暂无可显示的历史数据」——**自相矛盾的荒谬文案**。
+> 这个分支同时也是**真正的空态**:成员一份报告都没有,此时说「该成员暂无报告数据」才是诚实的,不该提"历史整理中"。**四条件缺一不可。**
+
+### 1.2.3 D1 开关终稿 = **默认开**(PM 产品决策,架构认可)
+
+**终稿:D1 开关默认开。** 我原提的"首周默认关"已撤回。
+
+#### 为什么撤回(PM 的拆解说服了我)
+
+我提"默认关一周"时,想挡的风险是"功能出问题时影响面大"。但 PM 把风险类型拆开后指出:
+
+| 风险类型 | 已被什么覆盖 |
+|---|---|
+| 数据缺失 → 显示错 | **已被 B2「整理中」+ §6.3.1 的 `error` 态静默降级覆盖完** |
+| **语义算错 → 把恶化标成改善** | **未覆盖。这是医疗语境下唯一真正有害的一类** |
+
+**而默认关只是把语义错误藏起来,并不验证它。藏一周,错误还在,只是没人看见。**
+
+所以我原方案是**用钝化灰度去挡一个它挡不住的风险**,属于手段与目的错配。PM 的两条补充理由也成立:
+1. 开关是用户可见控件,默认关意味着用户要主动理解并操作才看得到 → **功能形同不存在。藏起来的功能等于没做的功能**
+2. 用户痛点就是"看不出变化",这是我们全篇的立论起点
+
+#### 用精准验收替代钝化灰度
+
+**① 上线前:语义方向人工核验(必做,可重复执行)**
+
+抽样 **20 个方向已知**的指标,逐条核对 `trendCode` 输出:
+
+| 指标 | 构造变化 | 期望 `trendCode` |
+|---|---|---|
+| LDL-C | 降 | `improved`(lower_is_better) |
+| HDL-C | 升 | `improved`(higher_is_better) |
+| 血糖 | 升 | `worsened`(lower_is_better) |
+| 白细胞 | 升 | `worsened`(in_range_is_better,出区间上界) |
+| 同型半胱氨酸 | 降 | `improved`(lower_is_better) |
+| BMI | 区间内移动 | `flat` |
+| BMI | 入区间 | `improved` |
+| BMI | 出区间 | `worsened` |
+| 微小基数(0.01→0.05) | — | `deltaPct=null` + 排最后,**但 `delta` 有值** |
+| 基数为 0 | — | `deltaPct=null` |
+| 单位跨期不一致 | — | `deltaPct=null` + `unitMismatch=true` |
+| 非数值(舌象薄白→厚腻) | — | `movement='changed'` + `trendCode='flat'`,**不给颜色** |
+| 无上一期 + 无更早报告 | — | `first_record` |
+| 无上一期 + 有 3 份更早报告 | — | `pending_backfill` |
+| 跨期跳比(中间缺 2 期) | — | `skipGaps=2` |
+| …(共 20 条,覆盖全部 trendCode 分支 + 全部降级路径) | | |
+
+> **架构补充(PM 已采纳):这 20 条必须固化为 `IndicatorChangeService` 的回归测试用例(T02 验收),不是一次性人工核验。**
+> 理由:`goodDirection` / `refRange` 是**字典配置驱动的**,后续运营改字典会静默改变判定结果。一次性人工核验挡不住未来的回归。
+> 固化后,任何对 `computeChange` 或字典的改动都会跑这 20 条。
+>
+> **这 20 条的定位(PM 补充,架构认可)**:**它的作用不是校验每一条字典配置,而是检测系统性问题。**
+> 例如 `in_range` 解析器写坏,会让**一批**指标同时判反 —— 20 条里会同时红好几条。
+> 这个定位也解释了为什么下文"错 1 个就阻断"是合理的:**单个失败大概率不是孤立的**,它背后通常是解析器或字典的系统性缺陷,不是抽样噪声。
+
+**② 兜底预案:有明确触发条件(不是拍脑袋)**
+
+~~**语义错误率 > 5% → 首周转默认关,修完再开。**~~(PM 原案,**已被 PM 本人撤回**)
+
+**最终阈值(架构提出 → PM 采纳,双方共识)**:
+
+| 错误性质 | 容忍度 | 理由 |
+|---|---|---|
+| **方向判反**(把恶化标成改善,或反之) | **0 容忍,1 个即阻断上线** | 这是医疗语境下**唯一真正有害**的一类。1 个就说明字典 `goodDirection` 或 `refRange` 解析器有**系统性问题**,不是抽样噪声;而修一个字典条目成本极低,**不值得赌** |
+| **判定保守**(该判改善却回落为持平) | 可容忍,记为待治理 | 保守方向是安全的,不误导用户 |
+
+> **原阈值 5% 的问题**:20 个样本里错 1 个 = **恰好 5%**,按原阈值**正好卡在边界不触发**,就这么上线了。
+> **而 PM 在同一段里写着"方向判反是医疗语境下唯一真正有害的一类"** —— 用 5% 的抽样误差容忍度去放过自己认定为唯一真正有害的错误类型,是**自相矛盾**的。
+> **PM 自查后撤回原案**,改为上述分级。这是 PM 本轮第二次自行撤回自己的结论(第一次是 D12 的 B 方案)。
+
+**③ 内部账号提前开(PM 提议,架构采纳并提前)**
+内部账号**从 T02 联调阶段就开**,不是等首周——联调期就该拿真实信号,否则首周才第一次拿到数据。
+
+**④ 源头治理(PM 提出,架构认可 —— 登记为下轮,不入本轮)**
+
+> **管理端编辑 `goodDirection` / `refRange` 时加二次确认或影响提示**(如"修改后将影响历史数据的趋势判定")。
+
+**架构对实现方式的一处修正**:
+
+| 方案 | 判定 |
+|---|---|
+| ~~二次确认弹窗~~ | 运营高频操作时会被**习惯性点掉**,拦截率随时间趋近于 0 |
+| **✅ 内联影响提示**(编辑框下方常显) | 「修改后将影响 **N 条历史观测**的趋势判定」 |
+
+**理由**:二次确认是"打断式"的,用户会形成肌肉记忆直接点确认;内联提示是"告知式"的,不打断流程但始终可见。**对高频操作,告知优于打断。**
+且 `N` 可实时算出(`SELECT COUNT(*) FROM indicator_values WHERE definition_id = ?`),成本极低。
+
+**为什么必须下轮做**:本轮管理端改动已被 Q17 占满(读路径 + 列表过滤 + 治理 tab),再扩容会稀释 T05 的验收焦点。**登记为下轮字典治理项,与 §9-Q2 的 `refRange` 提示合并处理。**
+
+> **常驻保护仍是 §6.3.1 的 `error` 态兜底**(接口失败 → 静默降级为现状单期渲染,不弹错、不阻断)。
+> 现在三层保护完整了:**① 回归用例挡语义错误(事前)→ ② `error` 态兜底挡接口故障(运行时)→ ③ D1 开关作为紧急熔断(事后)**。
+> 开关从"先怂一周的钝器"降级为"有触发条件的应急预案",这是正确的定位。
+
+### 1.3 明确不做(本轮边界)
+
+| 不做 | 原因 |
+|---|---|
+| 引入 `report_periods` 物理「期次」表 | 见 §3.5,用 `(subject_id, source_type, report_date)` 三元组即可,引入新表要改所有读取端,投入产出比低 |
+| 引入 echarts / uCharts 等第三方图表库 | 沿用 D10 结论 A;自绘 Canvas 已支持多序列,改造成本可控 |
+| 单位跨期换算 | D6 结论 A;换算规则未经医学确认,静默换算是重大风险 |
+| 跨报告类型对比 | PRD §6.5 明确规定不同 `sourceType` 是两条独立时间轴 |
+| 重构 `member_indicator_current` | 本轮多期能力全部基于 `indicator_values`,`member_indicator_current` 保持只读兼容,不动其 `LIMIT 2` 逻辑 |
+| 改 `/api/health/indicator/list` 老接口的返回结构 | 新增 `/api/health/report/indicators` 承载新能力,老接口原样保留(cfc-web 仍在调) |
+| 医学判读 / 干预建议 / 预测外推 | 合规红线 |
+
+### 1.4 三个架构原则
+
+1. **单一写者不变**:`NormalizeIndicatorPipeline` 仍是 `indicator_values` 的唯一写者。本轮只补调用点,不改写入模型。
+2. **单一事实来源下沉**:变化语义(Δ/箭头/颜色/文案)的**判定规则在后端 `IndicatorChangeService`,格式化在前端 `utils/indicator-change.js`**。后端输出 `trendCode`(`improved`/`worsened`/`flat`)+ `movement`,前端只做"码 → 文案 + 颜色"的映射,不重新判定。
+3. **图表形态由后端建议**:后端在 `trend` 响应里给出 `chartMode`(`empty`/`single`/`pair`/`line`/`line_aggregated`),前端照做,避免 PRD §5.2 选型表在前后端各实现一遍而漂移。
+
+---
+
+## 2. 总体方案(分层改动图)
+
+```mermaid
+graph TB
+    subgraph FE["展示层 cfc-frontend(uni-app 微信小程序)"]
+        RD["report-detail.vue<br/>【改造】指标行三档形态<br/>+ 卡片头筛选 + 入口"]
+        TL["indicator-timeline.vue<br/>【新建 P0-4】多期主视图"]
+        CMP["indicator-compare.vue<br/>【新建 P1-1】双期对比"]
+        QL["indicator-quicklook.vue<br/>【改造 P1】带上次日期+sparkline"]
+        BD["body-detail/index.vue<br/>【改造 P1-4】卡片带日期+跳时间轴"]
+        GF["gut-flora-detail.vue<br/>【改造】limit 6→20 + 降级文案"]
+        RL["report-list.vue<br/>【改造 P1-3】选两份对比"]
+        subgraph SHARED["前端公共底座"]
+            ICR["IndicatorChangeRow.vue 【新建】<br/>变化对照行"]
+            IDG["IndicatorDegrade.vue 【新建】<br/>降级态文案"]
+            SPK["SparkLine.vue 【新建】<br/>view 点阵迷你图"]
+            TC["components/TrendChart.vue【改造】<br/>mode/refRange/断线/抽稀"]
+            UTIL["utils/indicator-change.js【改造】<br/>单一事实来源"]
+            API["utils/api.js【改造】<br/>补导出 + 新接口"]
+        end
+    end
+
+    subgraph APIL["接口层 cfc-backend controller"]
+        HRC["HealthReportController<br/>【改造】+ /api/health/report/indicators<br/>+ /api/admin/indicator/backfill"]
+        IC["IndicatorController<br/>【改造】trend / change-ranking / quick-lookup<br/>【新增】/api/indicator/periods"]
+        BFC["AdminBodyFatScaleController<br/>【改造 P1-7】"]
+    end
+
+    subgraph SVCL["服务层 cfc-backend service"]
+        ICS["IndicatorChangeService 【新建】<br/>resolvePrevious / computeChange<br/>parseRefRange / judgeInRange"]
+        ITS["IndicatorTimelineService 【新建】<br/>timeline / periods / pairCompare"]
+        ITREND["IndicatorTrendService 【改造】<br/>trend / changeRanking / quickLookup"]
+        HRS["HealthReportService 【改造】<br/>createReport 补 pipeline<br/>+ getReportIndicatorsWithPrevious"]
+        NIP["NormalizeIndicatorPipeline 【改造】<br/>写 dateSource/refRangeSnapshot<br/>+ 非数值通道(P1-6)"]
+        RCS["ReportCollectService 【改造 P1-5】<br/>去掉 new Date() fallback"]
+        BFS["BodyFatScaleService 【改造 P1-7】<br/>sourceId = recordId"]
+    end
+
+    subgraph DATAL["数据层 MySQL"]
+        IV[("indicator_values<br/>【+2 列】date_source<br/>ref_range_snapshot")]
+        HI[("health_indicators<br/>collection_date 放开映射")]
+        DEF[("indicator_definitions<br/>只读")]
+        HR[("health_reports<br/>payload_json 供回填")]
+        BFR[("body_fat_scale_records 【新建 P1-7】")]
+        MIC[("member_indicator_current<br/>不动")]
+    end
+
+    RD -->|"POST /api/health/report/indicators"| HRC
+    TL -->|"POST /api/indicator/trend<br/>/periods /source-types"| IC
+    CMP -->|"POST /api/indicator/change-ranking<br/>scope=pair"| IC
+    QL -->|"POST /api/indicator/quick-lookup"| IC
+    GF -->|"POST /api/indicator/change-ranking"| IC
+    BD -->|"POST /api/health/indicators"| HRC
+    RL --> CMP
+
+    RD -.复用.-> ICR
+    RD -.复用.-> IDG
+    QL -.复用.-> ICR
+    TL -.复用.-> TC
+    TL -.复用.-> IDG
+    CMP -.复用.-> ICR
+
+    HRC --> HRS
+    HRC --> ICS
+    IC --> ITREND
+    IC --> ITS
+    ITREND --> ICS
+    ITS --> ICS
+    HRS --> NIP
+    HRS --> ICS
+    BFC --> BFS
+    BFS --> NIP
+
+    NIP --> IV
+    ITS --> IV
+    ITREND --> IV
+    ICS --> IV
+    ICS --> DEF
+    HRS --> HI
+    HRS --> HR
+    BFS --> BFR
+    BFS --> IV
+
+    style ICS fill:#ffe6cc
+    style ITS fill:#ffe6cc
+    style TL fill:#d5e8d4
+    style CMP fill:#d5e8d4
+    style ICR fill:#d5e8d4
+    style IDG fill:#d5e8d4
+    style SPK fill:#d5e8d4
+    style IV fill:#fff2cc
+    style BFR fill:#fff2cc
+```
+
+**改动性质图例**:🟧 新建 Service(核心)· 🟩 新建前端资产 · 🟨 数据层增量 · 无底色 = 改造现有文件
+
+---
+
+## 3. 数据层增量设计
+
+### 3.1 `indicator_values` 新增列
+
+#### 决策结论
+
+| 候选列 | 结论 | 理由 |
+|---|---|---|
+| `date_source` | ✅ **新增** | PRD P1-5 硬需求(区分采样日/上传日)。无替代列:`status` 是观测状态(正常/偏高),语义完全不同,复用会污染 |
+| `ref_range_snapshot` | ✅ **新增** | `in_range_is_better` 判定必须知道**那一期**的参考范围。只读 `indicator_definitions.ref_range` 会导致"字典改了,历史趋势判定跟着变",历史数据不可复现 |
+| `value_type` | ❌ **不新增** | `indicator_definitions.value_type` 已存在(迁移 356 已加),`definition_id` join 即可。冗余列会带来"字典改了列不同步"的维护负担,且本轮所有读取点都要 join definition 拿 `refRange`/`goodDirection`,顺手就拿到了 |
+
+#### DDL(同步写入 `schema.sql` 的 `indicator_values` 定义,`recorded_at` 之前)
+
+```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 '观测状态快照(正常/偏高/偏低等)',
+    /* ↓↓↓ DS-2026-10-25-01 迁移422 新增 ↓↓↓ */
+    date_source VARCHAR(16) DEFAULT 'unknown'
+        COMMENT 'report_date 来源: real=报告提取的采样日 / upload=上传日(未识别到采样日) / confirm=确认入库日 / unknown=未知(存量行)',
+    ref_range_snapshot VARCHAR(100) DEFAULT NULL
+        COMMENT '该期参考范围快照(为空时回落到 indicator_definitions.ref_range)',
+    /* ↑↑↑ 迁移422 新增 ↑↑↑ */
+    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='全域指标值';
+```
+
+#### `DatabaseInitializer` 迁移写法(**迁移编号 422**,当前最大为 421)
+
+```java
+// 迁移422: indicator_values 增加日期来源标记 + 参考范围快照(指标多期展示 DS-2026-10-25-01)
+private void migrateIndicatorValuesTimelineColumns() {
+    ensureColumn("indicator_values", "date_source",
+        "VARCHAR(16) DEFAULT 'unknown' COMMENT 'report_date 来源: real=采样日/upload=上传日/confirm=确认日/unknown=未知(存量)'");
+    ensureColumn("indicator_values", "ref_range_snapshot",
+        "VARCHAR(100) DEFAULT NULL COMMENT '该期参考范围快照(为空时回落 indicator_definitions.ref_range)'");
+    log.info("迁移422: indicator_values 已补齐日期来源与参考范围快照 2 列");
+}
+```
+
+> **幂等性**:`ensureColumn` 内部已 catch `Duplicate`/`already exists` 并跳过(见 `DatabaseInitializer:5245-5260`),可安全重复执行。
+> **同步要求**:改完 `schema.sql` 后必须在 `runMigrations()`(151 行)内调用本方法,两处缺一不可(项目规定)。
+
+#### 实体改造 `entity/IndicatorValue.java`
+
+```java
+private String dateSource;          // + getter/setter
+private String refRangeSnapshot;    // + getter/setter
+```
+
+#### Mapper 改造 `mapper/IndicatorValueMapper.java`
+
+`upsertMapping` 的 `INSERT` 列清单与 `ON DUPLICATE KEY UPDATE` 子句各补 2 列:
+
+```java
+@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, " +
+        " date_source, ref_range_snapshot, 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}, " +
+        " #{v.dateSource}, #{v.refRangeSnapshot}, 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), " +
+        " date_source = VALUES(date_source), ref_range_snapshot = VALUES(ref_range_snapshot), " +
+        " recorded_at = NOW()")
+int upsertMapping(@Param("v") IndicatorValue v);
+```
+
+#### 写入侧 `NormalizeIndicatorPipeline` 改造
+
+```java
+// processReport() 中,每个 IndicatorValue 落库前补两行:
+v.setDateSource(resolveDateSource(report));                    // real / upload / confirm / unknown
+v.setRefRangeSnapshot(def != null ? def.getRefRange() : null); // 快照,需先查出 IndicatorDefinition
+
+/**
+ * 日期来源判定(与 ReportDateExtractor 政策对齐:取不到不伪装,只标记)
+ *  real    —— report.reportDate 由报告内容/元数据成功提取
+ *  upload  —— 提取失败,但报告有明确的上次修改/创建时间可用作近似
+ *  confirm —— 连上传时间都没有,用确认入库时间(DAN 报告当前行为)
+ *  unknown —— 存量行 / 完全无信息
+ */
+private String resolveDateSource(HealthReport report) { ... }
+```
+
+> **关键**:`resolveDateSource()` 只是**打标记**,**不改变** `reportDate` 的取值逻辑(存量链路的 `new Date()` fallback 在 P1-5 才移除,见 §3.4)。这样 P0 阶段可以先把"哪些日期是假的"暴露出来,P1 再修根因。
+
+---
+
+### 3.2 `health_indicators.collectionDate` 的 `exist=false` 不一致处置
+
+#### 现状(已核实)
+
+| 事实 | 位置 |
+|---|---|
+| 实体标注 `@TableField(exist = false)`,注释写"DB列不存在" | `entity/HealthIndicator.java:50-52` |
+| 物理列**确实存在** | `DatabaseInitializer.java:7651-7652`(主迁移)+ `12405-12409`(兜底) |
+| `createReport()` 已经在往这个字段写值 | `HealthReportService.java:151-153`(`ind.setCollectionDate(report.getReportDate())`) |
+
+**结论**:这是**实体与库表不一致的技术债**,不是"列不存在"。写入端一直在写、读取端一直查不出,等于白写。
+
+#### 处置方案(**推荐 A:放开映射**)
+
+**A 方案(推荐)**:删掉 `HealthIndicator.java:51` 的 `@TableField(exist = false)`,让 `collectionDate` 成为普通映射列。
+
+- **收益**:`createReport()` 已有的写入立刻生效;报告详情页可直接拿到指标级采集日(多期对照的日期来源之一)。
+- **风险与前置条件**:
+  1. MyBatis-Plus 的 `selectList` 会自动 `SELECT` 该列。若**任何环境**该列缺失 → 所有 `health_indicators` 查询报 `Unknown column` → 报告详情页整个挂掉。**这是本方案唯一的高危点。**
+  2. **强制前置动作**:在迁移 422 同批加一个 `columnExists("health_indicators", "collection_date")` 检查,不存在则 `ensureColumn` 补建:
+
+```java
+// 迁移422 附带:确保 health_indicators.collection_date 物理列存在(放开实体映射的前置条件)
+if (!columnExists("health_indicators", "collection_date")) {
+    ensureColumn("health_indicators", "collection_date",
+        "DATE DEFAULT NULL COMMENT '指标采集日期(从报告提取的检测日期,非入库时间)'");
+    log.info("迁移422: 已补建 health_indicators.collection_date 列");
+}
+```
+
+**B 方案(备选,风险更低)**:保持 `exist=false` 不动,报告详情页的"本期日期"统一取 `health_reports.report_date`(本来就该如此)。`collectionDate` 单列用 `healthIndicatorMapper` 的自定义 `@Select` 单独查。
+
+> **架构建议**:**本轮走 A,但必须把 3.2 的 `columnExists` 检查作为 T01 的第一个提交**。若灰度环境发现任何异常,回退到 B(只需把注解加回去,5 分钟)。
+>
+> **注意**:即使走 A,报告详情页的"本期日期"仍以 **`health_reports.report_date` 为第一优先**,`collection_date` 仅作 fallback(因为 confirm 链路上 `report.getReportDate()` 可能已被污染为 `new Date()`,而 `collectionDate` 是在 `createReport()` 里从同一个污染值赋的,两者同源,优先级差异只在 DAN 报告等少数场景体现)。
+
+---
+
+### 3.3 体脂秤 `sourceId` 策略改造(P1-7)
+
+#### 现状(已核实,比 PRD 描述更严重)
+
+```java
+// BodyFatScaleService.java:253-264
+iv.setSourceType("body_fat_scale");
+iv.setSourceId(memberId);              // ← 恒定等于 memberId
+iv.setRecordedAt(new Date());
+indicatorValueMapper.insert(iv);       // ← 用的是 insert,不是 upsertMapping
+```
+
+`indicator_values` 有 `UNIQUE KEY uk_source_definition (source_type, source_id, definition_id)`。
+
+> **PRD S12 描述为"单值覆盖",实际更糟**:第二次测量时 `insert` 会直接抛 `DuplicateKeyException`,而 `saveMetrics()` 的循环里**没有 per-metric try/catch**(已核实 235-265 行),异常会一路冒泡到 `AdminBodyFatScaleController:50`,**整个批量保存请求失败,一条都存不进去**。
+> 即:体脂秤目前不是"只有 1 期",而是"**第二次测量就整体报错**"。这一点需要在 T05 一并修掉。
+
+#### 改造方案(**推荐:新增轻量测量记录表**)
+
+**新增 `body_fat_scale_records` 表**(迁移 423),一次测量一条记录:
+
+```sql
+CREATE TABLE body_fat_scale_records (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)',
+    family_id BIGINT COMMENT '所属家庭',
+    image_url VARCHAR(500) COMMENT '体脂秤照片',
+    measured_at DATE COMMENT '测量日期(取不到为NULL,观测 report_date 记为上传日)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_member_date (member_id, measured_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体脂秤测量记录';
+```
+
+```java
+// 迁移423: 体脂秤测量记录表(sourceId 由 memberId 改为 recordId,使体脂秤指标具备多期时序)
+private void createBodyFatScaleRecordsTable() {
+    if (!tableExists("body_fat_scale_records")) {
+        jdbcTemplate.execute(
+            "CREATE TABLE body_fat_scale_records (" +
+            " id BIGINT AUTO_INCREMENT PRIMARY KEY," +
+            " member_id BIGINT NOT NULL COMMENT '家庭成员ID(family_members.id)'," +
+            " family_id BIGINT COMMENT '所属家庭'," +
+            " image_url VARCHAR(500) COMMENT '体脂秤照片'," +
+            " measured_at DATE COMMENT '测量日期(取不到为NULL,观测 report_date 记为上传日)'," +
+            " created_at DATETIME DEFAULT CURRENT_TIMESTAMP," +
+            " INDEX idx_member_date (member_id, measured_at)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='体脂秤测量记录'");
+        log.info("迁移423: 已创建 body_fat_scale_records 表");
+    }
+}
+```
+
+**改造后的 `saveMetrics` 流程**:
+
+```
+1. 插 body_fat_scale_records(member_id, family_id, image_url, measured_at)→ 拿到 recordId
+2. 对每个 metric:
+     iv.sourceType   = "body_fat_scale"
+     iv.sourceId     = recordId            // ← 关键改动
+     iv.reportId     = null
+     iv.reportDate   = measuredAt != null ? measuredAt : today
+     iv.dateSource   = measuredAt != null ? "real" : "upload"
+     iv.refRangeSnapshot = def.getRefRange()
+     valueMapper.upsertMapping(iv)          // ← 改用幂等 upsert,不再 insert
+3. 单条 metric 失败要 try/catch 跳过,不能让整批挂掉
+```
+
+#### 为什么这样设计
+
+| 维度 | 说明 |
+|---|---|
+| **唯一键语义** | 从「一个成员 × 一个指标 = 一行」变成「**一次测量 × 一个指标 = 一行**」,天然支持多期 |
+| **不影响现有唯一键** | `uk_source_definition` 结构完全不动,只是 `source_id` 的取值含义变了。报告类来源 `source_id=reportId` 的语义保持不变,两类来源在同表共存无冲突 |
+| **同日多次测量** | 生成两条 record → 时间轴上同一天两个点。UI 按 `report_date` 聚合展示时取末值,或在明细表里都列出并标注时间 |
+| **`sourceKind` 区分** | 时间轴页的「全部来源/仅报告/仅设备」过滤靠 `source_type = 'body_fat_scale'` 与其他报告类型区分即可,**不需要额外列** |
+
+#### 存量数据迁移(P1-7 附带,必须做)
+
+```sql
+-- 存量 body_fat_scale 行:source_id = memberId,全部指向"第一次测量"
+-- 策略:按 memberId 分组,为每个 memberId 建一条 record(measured_at 取该成员最早的 recorded_at),
+--       然后把这些行的 source_id 改为该 record.id
+-- 冲突处理:同一 memberId + definitionId 若存在多行(理论上不可能,唯一键挡住了),保留 id 最大的一行,其余删除
+```
+
+**执行方式**:写在迁移 424 里,用 `jdbcTemplate` 分步执行,每步 try/catch,失败只 log 不阻断启动。
+
+---
+
+### 3.4 时间口径污染的三处修复(P1-5,技术侧落地)
+
+| 位置 | 现状 | 改造后 |
+|---|---|---|
+| `ReportCollectService.java:768-770` | `if (report.getReportDate() == null) report.setReportDate(new Date());` | **删除该 fallback**。`report_date` 保持 `NULL`,同时由 `NormalizeIndicatorPipeline` 在观测行上打 `date_source='unknown'` |
+| `HealthReportController.java:2390/2393/2396` | 同上 fallback | 同上 |
+| DAN 报告 confirm 用确认时间(`HealthReportController:391`) | 把 confirm 时间当 `reportDate` | 保留取值,但 `date_source='confirm'`,UI 显示时按 PRD §6.6 加 `*` |
+| `service/ReportDateExtractor.java` | 政策已是"取不到置 NULL,不伪装" | **不动**(这是正确的一方,以它为准) |
+
+> **`report_date = NULL` 的下游影响(必须一并处理)**:
+> 1. `indicator_values.report_date` 为 NULL 的行,**不参与**"上一期"计算(PRD §6.5 明确要求),但仍在期次明细表里显示并标「日期未知」。
+> 2. `IndicatorTrendService.trend()` 当前有 `.isNotNull(IndicatorValue::getReportDate)`(60 行)→ NULL 行的点会**整个消失**。改造后改为:NULL 日期行仍返回,但 `reportDate: null` + `dateSource: "unknown"`,前端在图里不画、只在明细表末段列出。
+> 3. `changeRanking()`(104 行)、`listSourceTypes()`(233 行)同样有 `isNotNull(reportDate)`,逐个放开。
+> 4. 放开后排序必须显式处理 NULL:**NULL 排最后**(升序时),见 §5.2 的 `NULLS_LAST` 比较器。
+
+---
+
+### 3.5 是否需要"报告期次"概念?
+
+#### 决策:**不引入期次物理表**。期次 = `(subject_id, source_type, report_date)` 三元组。
+
+| 判据 | 分析 |
+|---|---|
+| 现有接口契约 | `trend` / `changeRanking` / `quickLookup` 全部以 `report_date` 为排序键与分组键。引入期次表要改所有读取端 + 所有前端,成本极高 |
+| 数据模型 | `indicator_values` 已有 `report_id` + `report_date`。「一份报告 = 一期」的映射关系由 `report_id` 天然承载,不需要额外表 |
+| 边界场景 | 「同一天上传了两份不同报告」→ 时间轴上出现两个同日期点。PRD §5.4 已规定「两期日期相同 → 提示可能为同一次检测的重复上传」,用提示覆盖即可,不需要靠期次表去重 |
+| 唯一新增需求 | 双期对比页的两个 picker 需要"该成员该报告类型的全部期次列表" → 用**新增的 `/api/indicator/periods` 接口**从 `indicator_values` 去重 `(report_date, report_id)` 得到,见 §4.5 |
+
+**期次列表的 SQL 语义**:
+
+```sql
+SELECT report_date, MIN(report_id) AS report_id,
+       MAX(date_source) AS date_source,          -- 任一期为 upload 则该期标 upload
+       COUNT(DISTINCT definition_id) AS indicator_count
+FROM indicator_values
+WHERE subject_id = ? AND source_type = ?
+GROUP BY report_date
+ORDER BY report_date IS NULL, report_date ASC;   -- NULL 排最后
+```
+
+> `MAX(date_source)` 的字典序恰好满足"upload > confirm > real",但这是巧合、不可依赖。**实现时改为显式枚举优先级**:`real=3 / upload=2 / confirm=1 / unknown=0`,取该期 `date_source` 的**最小值**(最差的一个),即"只要这一期里有任何一条观测的日期是上传日,整期就标上传日"。这是诚实表达的正确口径。
+
+---
+
+### 3.6 【实测修正】时序数据缺失的四个来源 —— P0-3 的范围比原判更窄,但需补两个新缺口
+
+> **这是我在回应 PM 的 Q16 时追加核实出的关键事实,直接改变了 Q15 的判断基础。**
+
+#### 主线上传链路**已经**在写 `indicator_values`
+
+实测链路(`report-upload.vue:321` → `ReportCollectService` → 前端轮询 → 直接跳详情页):
+
+```
+report-upload.vue:321  uploadReportOnly()
+   → 后端建 draft + submitAsyncCollect(draftId)                [HealthReportController:937 附近]
+   → ReportCollectService.collect(draftId)                     [ReportCollectService:147]
+   → healthReportService.createReport(...)                     [ReportCollectService 内]
+   → normalizeIndicatorPipeline.processReport(created, payload)【ReportCollectService:728】← 已有!
+   → 前端 startCollectPolling → parseStatus='completed'
+   → uni.redirectTo(详情页)  —— 【不经过 /api/health/report/confirm】
+```
+
+**结论**:标准 PDF 报告的主流上传路径**已经在写时序**,`/api/health/report/confirm` 只服务于手动预览确认(`report-confirm.vue:854`)、菌群页预览态确认(`gut-flora-detail.vue:966`)与舌诊(`confirmTongue`)三条支线。
+
+#### 因此真正的缺口是这四类(而不是"全部 confirm 报告")
+
+| # | 缺口来源 | 证据 | 本轮修法 | 批次 |
+|---|---|---|---|---|
+| **G1** | **管线上线之前的历史报告** | 迁移 357 才加时序列、才有 pipeline;之前的报告无论走哪条路都无 `indicator_values` | `/api/admin/indicator/backfill`(§4.6) | T02 接口就绪后跑 |
+| **G2** | **confirm 路径创建的报告** | `HealthReportController:1434` 后无 pipeline 调用 | confirm 内补 `processReport`(§3.1) | T01 |
+| **G3** | **`subjectId` 为空时创建、事后才绑定成员的报告** | `NormalizeIndicatorPipeline:43` 遇 `subjectId == null` **直接 return**;而 `bindReportToMember:1282-1302` 只做 `UPDATE subject_id`,**不会重跑 pipeline** | **`bindReportToMember` 内补 pipeline 重跑**(见下) | T01 |
+| **G4** | **体脂秤**(第二次起整体报错) | `BodyFatScaleService:253-264` 用 `insert` 撞唯一键 | `sourceId=recordId` + `upsertMapping`(§3.3) | T05 |
+
+> **G3 是本轮新发现的缺口,且可能是量最大的一类**:用户上传时若不选成员 → `collect()` 里 `subjectId` 为 null → pipeline 静默跳过 → 事后在报告详情页点「绑定成员」→ `bindReportToMember` 只改 `subject_id`,**时序数据永远不会补上**。这类报告在用户视角"明明绑了成员,为什么没有历史"。
+
+#### G3 的修法(T01 必做)
+
+```java
+// HealthReportService.bindReportToMember() 尾部新增:
+// 报告归属确定后,若该报告尚无时序观测,补跑归一化管道
+public void bindReportToMember(Long reportId, Long subjectId) {
+    ...  // 原有 UPDATE 逻辑不变
+    try {
+        HealthReport rep = healthReportMapper.selectById(reportId);
+        if (rep != null && rep.getSubjectId() != null && rep.getPayloadJson() != null) {
+            ParsedReportPayload.Payload payload =
+                objectMapper.readValue(rep.getPayloadJson(), ParsedReportPayload.Payload.class);
+            normalizeIndicatorPipeline.processReport(rep, payload);
+        }
+    } catch (Exception e) {
+        log.warn("绑定成员后补写时序失败 reportId={}: {}", reportId, e.getMessage());
+    }
+}
+```
+
+> **幂等性**:`processReport` 底层走 `upsertMapping`,重复执行只覆盖不新增,与 `markConfirmed` 后的补写互不冲突。
+> **性能**:绑定成员是低频操作,且整段 try/catch,失败不阻断绑定。
+
+#### G3 的前端提示(PM 提出 → 采纳,但**必须与 P0-1 同批**,不单独排期)
+
+PM 指出的用户心理序列很重要:
+> 上传(没选成员)→ 后来点了「绑定成员」→ 报告出现在我的档案里了 → **但点进去没有历史、没有趋势**
+> → 用户第一反应是「绑定功能坏了」「数据丢了」,**客诉会指向错误的方向**(指向绑定功能,而非时序写入)。
+
+**落地**:绑定成功后若确有指标被补写,前端给一次轻提示。
+
+**文案(PM 终稿,架构认可 —— 我的原文案有同样的毛病)**:
+
+| 文案 | 判定 |
+|---|---|
+| ~~已关联成员,指标历史已同步~~ | ❌ **"历史已同步"暗示"历史补上了"**。但多数场景这份报告是用户的**第 1 份**,根本没有历史可看 —— **说了用户找不到的东西** |
+| **✅ 已关联成员,{N} 项指标已纳入趋势** | ✅ 描述的是"这些指标**从此**有趋势可看",**准确且前瞻**,不承诺眼下就有历史 |
+
+> PM 这条改得对,而且**与我原文案是同一个毛病的两种表现**:我原来担心的是"P0-1 之前弹 toast 用户看不到新东西"(时机问题),PM 指出的是"toast 内容本身承诺了不存在的历史"(内容问题)。
+> 这不是文案洁癖,与全篇"不编造、不暗示不存在的数据"是同一条原则。
+
+| 项 | 内容 |
+|---|---|
+| 后端 | `/api/health/report/bind` 的返回从 `Result<String>` 改为 `Result<Map>`,增加 `indicatorsSynced`(本次补写的观测条数) |
+| 前端 | 报告详情页 / 报告列表页的绑定回调里,`indicatorsSynced > 0` 时 `uni.showToast({ title: '已关联成员,' + n + ' 项指标已纳入趋势' })` |
+| **`indicatorsSynced > 0` 硬约束** | **必须判断**。一条都没补写到却弹「已同步」是**虚假告知**,与全篇诚实原则直接冲突 |
+| **排期** | **必须与 P0-1 同批上线** |
+
+> **为什么必须与 P0-1 同批,不能单独排 P1**:
+> P0-1 之前,报告详情页**没有任何可见的"历史" UI**。此时弹「指标历史已同步」,用户看不到任何新东西,反而更困惑("历史在哪?")。
+> **只有 P0-1 上线后,"历史"才以「较 MM-DD」的形式可见**,这个 toast 才有指代对象、才能消解疑惑。
+> 另外必须判 `indicatorsSynced > 0` 才弹 —— 若没补写到任何观测却弹「已同步」,那是**虚假告知**,与全篇的诚实原则直接冲突。
+
+#### 对 Q15 的影响
+
+由于 G2 只是三条支线、G3/G1 要靠回填兜,**`indicator_values` 的实际覆盖率很可能远高于我原先的悲观假设**(原假设是"所有 confirm 报告都没有")。
+
+→ 但覆盖率到底多高,**只有跑一次 `dryRun` 才知道**。因此 Q15 的决策必须建立在实测数据上,见 §9-Q15 的修订。
+
+---
+
+## 4. 接口契约
+
+### 4.0 通用约定
+
+| 项 | 约定 |
+|---|---|
+| 方法 | 统一 `@PostMapping` |
+| 响应 | `Result<T>` = `{ code: 200 \| 500, message, data }` |
+| 认证 | JWT,`@RequestAttribute("familyId")` / `("userId")` / `("currentMemberId")` |
+| 越权 | `Result.error("成员不属于当前家庭")`(沿用 `IndicatorController.checkSubjectBelongs`) |
+| 日期格式 | 字符串 `yyyy-MM-dd`(`java.sql.Date` → String,方法内 `new SimpleDateFormat` |
+| 数值精度 | `delta` 保留 4 位(`BigDecimal` scale 4, HALF_UP);`deltaPct` 保留 1 位;前端按 PRD §6.4 有效数字规则二次格式化 |
+
+---
+
+### 4.1 【新增】`POST /api/health/report/indicators` — 报告指标(含上一期对照)
+
+> **为什么新增而不是改造 `/api/health/indicator/list`**:
+> 老接口返回裸 `List<HealthIndicator>`,cfc-web `views/nutritionist/HealthIndicators.vue:86-89` 在调(虽然因缺 reportId 实际是空页)。改返回结构会破坏它。按项目 API_REFERENCE 规范「有相似功能但需调整 → 评估修改成本,必要时新增(标注依赖)」,此处新增。
+
+**路径**:`/api/health/report/indicators`
+**Controller**:`HealthReportController`(新增方法)
+**Service**:`HealthReportService.getReportIndicatorsWithPrevious(reportId, withPrevious)` + `IndicatorChangeService`
+
+#### 入参
+
+```json
+{
+  "reportId": 12345,
+  "withPrevious": true
+}
+```
+
+| 字段 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---|---|---|
+| `reportId` | Long | 是 | — | 报告 ID |
+| `withPrevious` | Boolean | 否 | `true` | 为 `false` 时跳过 previous/change 计算(快速返回单期,供编辑模式等场景用) |
+
+#### 出参
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "reportId": 12345,
+    "reportType": "gut_flora",
+    "subjectId": 88,
+    "reportDate": "2026-10-08",
+    "dateSource": "real",
+    "olderReportCount": 4,
+    "summary": {
+      "totalCount": 42,
+      "abnormalCount": 5,
+      "abnormalDelta": 2,
+      "changedCount": 18,
+      "comparableCount": 40,
+      "firstRecordCount": 2,
+      "pendingCount": 6,
+      "dataIncomplete": true
+    },
+    "items": [
+      {
+        "indicatorId": 9001,
+        "definitionId": 512,
+        "category": "血脂",
+        "indicatorName": "低密度脂蛋白 LDL-C",
+        "indicatorValue": "3.62",
+        "numericValue": 3.62,
+        "unit": "mmol/L",
+        "refRange": "0.00–3.37",
+        "status": "偏高",
+        "goodDirection": "lower_is_better",
+        "valueType": "numeric",
+        "pointsCount": 4,
+        "previous": {
+          "reportId": 11800,
+          "reportDate": "2026-08-12",
+          "dateSource": "real",
+          "value": "4.10",
+          "numericValue": 4.10,
+          "unit": "mmol/L",
+          "status": "偏高"
+        },
+        "change": {
+          "delta": -0.48,
+          "deltaPct": -11.7,
+          "movement": "down",
+          "trendLabel": "改善",
+          "trendCode": "improved",
+          "skipGaps": 0,
+          "unitMismatch": false,
+          "comparable": true
+        }
+      },
+      {
+        "indicatorId": 9002,
+        "definitionId": 513,
+        "category": "血脂",
+        "indicatorName": "载脂蛋白 A1",
+        "indicatorValue": "1.45",
+        "numericValue": 1.45,
+        "unit": "g/L",
+        "refRange": "1.00–1.60",
+        "status": "正常",
+        "goodDirection": "in_range_is_better",
+        "valueType": "numeric",
+        "pointsCount": 1,
+        "olderReportCount": 0,
+        "previous": null,
+        "change": {
+          "delta": null,
+          "deltaPct": null,
+          "movement": "none",
+          "trendLabel": "首次记录 · 暂无对比",
+          "trendCode": "first_record",
+          "skipGaps": 0,
+          "unitMismatch": false,
+          "comparable": false
+        }
+      },
+      {
+        "indicatorId": 9004,
+        "definitionId": 515,
+        "category": "维生素",
+        "indicatorName": "25-羟维生素D",
+        "indicatorValue": "32.5",
+        "numericValue": 32.5,
+        "unit": "ng/mL",
+        "refRange": "30.00–100.00",
+        "status": "正常",
+        "goodDirection": "in_range_is_better",
+        "valueType": "numeric",
+        "pointsCount": 1,
+        "olderReportCount": 3,
+        "previous": null,
+        "change": {
+          "delta": null,
+          "deltaPct": null,
+          "movement": "none",
+          "trendLabel": "历史数据整理中,暂无法对比",
+          "trendCode": "pending_backfill",
+          "skipGaps": 0,
+          "unitMismatch": false,
+          "comparable": false
+        }
+      },
+      {
+        "indicatorId": 9003,
+        "definitionId": 514,
+        "category": "氨基酸",
+        "indicatorName": "同型半胱氨酸",
+        "indicatorValue": "9.8",
+        "numericValue": 9.8,
+        "unit": "μmol/L",
+        "refRange": "<15.0",
+        "status": "正常",
+        "goodDirection": "lower_is_better",
+        "valueType": "numeric",
+        "pointsCount": 3,
+        "previous": {
+          "reportId": 11000,
+          "reportDate": "2026-06-01",
+          "dateSource": "upload",
+          "value": "8.2",
+          "numericValue": 8.2,
+          "unit": "μmol/L",
+          "status": "正常"
+        },
+        "change": {
+          "delta": 1.6,
+          "deltaPct": 19.5,
+          "movement": "up",
+          "trendLabel": "恶化",
+          "trendCode": "worsened",
+          "skipGaps": 2,
+          "unitMismatch": false,
+          "comparable": true
+        }
+      }
+    ]
+  }
+}
+```
+
+#### 字段说明
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| **`items[].olderReportCount`** | **Integer** | **每个指标各自计算**。同 `(subjectId, sourceType)`、`status='active'`、`report_date` 非空且严格早于本期 `report_date` 的 `health_reports` **份数**。**只 COUNT,不做跨报告指标名匹配** |
+| **`change.trendCode`** | **String(五值枚举)** | `improved` \| `worsened` \| `flat` \| **`first_record`** \| **`pending_backfill`**。**前端只用这个字段决定文案与颜色,不得自行用 `previous == null` 判断** |
+| **`change.movement`** | **String(六值枚举)** | `up` \| `down` \| `flat` \| `changed` \| `unchanged` \| **`none`**(无对比对象时)。`none` 时前端不渲染箭头 |
+| **`change.trendLabel`** | **String** | 后端下发的**完整文案**,前端直接渲染:`首次记录 · 暂无对比` / `历史数据整理中,暂无法对比` / `改善` / `恶化` / `基本持平` / `有变化` / `无变化`。**文案由后端统一,页面不得改写**(合规要求) |
+| `dateSource` | String | `real`/`upload`/`confirm`/`unknown`;本期日期来源。非 `real` 时前端在日期后加 `*` |
+| `summary.abnormalDelta` | Integer | 本期异常数 − 上一期异常数。`null` 表示无上一期可算(不显示"较上次 +2") |
+| `summary.changedCount` | Integer | `trendCode != 'flat'` 且 `comparable == true` 的项数(供"有变化 N"筛选) |
+| `summary.comparableCount` | Integer | 有 `previous` 且两期都有效的项数 |
+| `summary.firstRecordCount` | Integer | `previous == null` **且** `olderReportCount == 0` 的项数(真首次) |
+| `summary.pendingCount` | Integer | `previous == null` **且** `olderReportCount > 0` 的项数(数据整理中) |
+| `summary.dataIncomplete` | Boolean | `pendingCount > 0`。供卡片头显示整体提示条 |
+| **`olderReportCount`** | **Integer** | **本报告的"更早报告数"**:同 `(subjectId, sourceType)`、`status='active'`、`report_date` 非空且严格早于本期 `report_date` 的 `health_reports` 条数。**只 COUNT,不做跨报告指标名匹配**(见下方"档 B2")。本期 `reportDate` 为 null 时该值为 0(无法确定"更早") |
+| `items[].definitionId` | Long \| null | **未命中指标字典时为 `null`** → `pointsCount` 按 `indicatorName` 口径另算(见 §9-Q6) |
+| `items[].pointsCount` | Integer | 该成员该 `sourceType` 下该指标有有效值的期数(`≥2` 才显示「N 期 ›」入口) |
+| `items[].previous` | Object \| null | 严格按 PRD §6.5 定义查找;`null` → 前端走档 B「首次记录 · 暂无对比」 |
+| `change.trendCode` | String | `improved` / `worsened` / `flat`。**前端只用这个 + `movement` 决定颜色与文案,不重新判定** |
+| `change.skipGaps` | Integer | 跨期跳比时中间未检测该项的期数;`>0` → 前端走档 C |
+| `change.unitMismatch` | Boolean | 两期单位不一致 → 前端 `deltaPct` 显示为 `—` 并加 `*` |
+| `change.comparable` | Boolean | `false` 表示无法比较(如跨类型、值为空),前端显示「无法比较」 |
+
+#### 五档形态映射(**由 `change.trendCode` 驱动**,D12 分拆上线的核心)
+
+| 档 | `trendCode` | 触发条件 | `previous` | `change` | 前端渲染(`trendLabel` 由后端下发) |
+|---|---|---|---|---|---|
+| **A 有上一期** | `improved` / `worsened` / `flat` | 找到上一期 | 有值 | 有值 | 第二行「较 MM-DD:旧值 → 新值 ↑/↓ Δ (Δ%) 趋势词」 |
+| **B1 首次记录** | **`first_record`** | `previous == null` **且** `olderReportCount == 0` | `null` | **非 null** | 第二行「首次记录 · 暂无对比」(灰) |
+| **B2 数据整理中** | **`pending_backfill`** | `previous == null` **且** `olderReportCount > 0` | `null` | **非 null** | 第二行「历史数据整理中,暂无法对比」(灰) |
+| **C 跨期跳比** | 同 A | 有上一期且 `skipGaps > 0` | 有值 | 有值 | 档 A + 第三行「ⓘ 中间 N 期未检测该项」(橙) |
+| **D 不可比** | `flat` + `comparable=false` | 非数值 / 单位不可比 | 有值 | 有值 | 第二行「无法比较」(灰),不渲染箭头 |
+
+> **B1 与 B2 必须区分的原因**:用户手里明明有 3 份报告却被告知「首次记录」是**错误的断言**,不是保守表达。B2 是诚实降级,**是 D12 第二段上线的价值前提**。
+>
+> **为什么 `change` 对象在 B1/B2 档也要存在(不能是 `null`)**:
+> 若 `previous == null` 时 `change` 也置 `null`,前端就必须自己写 `if (previous == null)` 再判断 `olderReportCount` —— 等于把判定逻辑复制到前端,违反 §1.4 原则 2(单一事实来源)。
+> **`change` 恒为非空对象,前端只消费 `trendCode` 一个字段即可完成全部五档渲染**,不持有任何判定规则。
+>
+> **为什么只 COUNT 不做跨报告指标名匹配**:`report-detail.vue:207` 的 `indicatorName` 是用户可编辑的自由文本 input,跨报告同名匹配会匹配错对象,在医疗语境下产生错误的 Δ,**比"整理中"危险得多**。
+>
+> **`olderReportCount` 的成本**:1 次 COUNT 查询(走 `health_reports` 的 `subject_id` 索引),**全报告共享一次结果**(同报告内所有 `olderReportCount` 相同),不是每指标一次。
+>
+> **`summary.pendingCount > 0` 时**,卡片头显示提示条「部分历史数据正在整理,对比结果可能不全」。
+>
+> **`olderReportCount` 为 0 的特殊情况**:本期 `reportDate == null` 时无法确定"更早",恒为 0 → 走 B1 档。此时若用户其实有历史报告,会误判为"首次记录"。**这是"诚实"口径下的已知残余误差**:日期未知的报告本就无法参与时序排序,与回填缺口是两种不同的问题。若实测该情况占比高,可考虑把"本期日期未知"单独表达(P2)。
+
+#### 错误码
+
+| code | message | 场景 |
+|---|---|---|
+| 500 | `reportId不能为空` | 缺参 |
+| 500 | `报告不存在` | reportId 查不到 |
+| 500 | `无权查看该报告` | 沿用 `getReportDetail` 的权限校验逻辑(`HealthReportController:594-608`) |
+
+#### 幂等性 / 分页
+
+- **只读接口,天然幂等**。
+- **不分页**:单报告指标数实测上限约 60,全量返回。若未来超过 200,再按 `category` 分块。
+
+---
+
+### 4.2 【改造】`POST /api/indicator/trend` — 指标时间轴
+
+**路径不变**:`/api/indicator/trend`
+**向后兼容**:老字段(`items[].definitionId/name/unit/points`)全部保留,只加字段。
+
+#### 入参
+
+```json
+{
+  "subjectId": 88,
+  "definitionIds": [512],
+  "sourceType": "gut_flora",
+  "fromDate": "2025-01-01",
+  "toDate": "2026-12-31",
+  "granularity": "raw",
+  "sourceKind": "all"
+}
+```
+
+| 字段 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---|---|---|
+| `subjectId` | Long | 是 | — | |
+| `definitionIds` | Long[] | 是 | — | |
+| `sourceType` | String | 否 | `null` | **新增**。报告类型过滤;`null` = 全部来源 |
+| `fromDate` / `toDate` | String(`yyyy-MM-dd`) | 否 | `null` | **新增**。闭区间过滤 |
+| `granularity` | String | 否 | `raw` | **新增**。`raw` \| `month`;`n ≥ 20` 时前端可请求 `month`(按月取组内末值) |
+| `sourceKind` | String | 否 | `all` | **新增**。`all` \| `report`(排除 `body_fat_scale`)\| `device`(只要 `body_fat_scale`)。P1-7 |
+
+#### 出参
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "items": [
+      {
+        "definitionId": 512,
+        "name": "低密度脂蛋白 LDL-C",
+        "unit": "mmol/L",
+        "refRange": "0.00–3.37",
+        "refLow": 0.0,
+        "refHigh": 3.37,
+        "goodDirection": "lower_is_better",
+        "valueType": "numeric",
+        "pointsCount": 5,
+        "totalReportCount": 5,
+        "olderReportCount": 4,
+        "chartMode": "line",
+        "unitMismatch": false,
+        "latest": {
+          "reportId": 12345,
+          "reportDate": "2026-10-08",
+          "dateSource": "real",
+          "value": "3.62",
+          "numericValue": 3.62,
+          "unit": "mmol/L",
+          "status": "偏高",
+          "sourceType": "gut_flora"
+        },
+        "change": {
+          "previous": {
+            "reportId": 11800,
+            "reportDate": "2026-08-12",
+            "dateSource": "real",
+            "value": "4.10",
+            "numericValue": 4.10,
+            "unit": "mmol/L",
+            "status": "偏高"
+          },
+          "delta": -0.48,
+          "deltaPct": -11.7,
+          "movement": "down",
+          "trendLabel": "改善",
+          "trendCode": "improved",
+          "skipGaps": 0,
+          "unitMismatch": false,
+          "comparable": true
+        },
+        "points": [
+          { "reportId": 10001, "reportDate": "2026-03-14", "dateSource": "real",   "value": "4.10", "numericValue": 4.10, "unit": "mmol/L", "status": "偏高", "sourceType": "gut_flora" },
+          { "reportId": 10200, "reportDate": "2026-05-02", "dateSource": "real",   "value": "3.90", "numericValue": 3.90, "unit": "mmol/L", "status": "偏高", "sourceType": "gut_flora" },
+          { "reportId": 11000, "reportDate": "2026-07-15", "dateSource": "upload", "value": "4.25", "numericValue": 4.25, "unit": "mmol/L", "status": "偏高", "sourceType": "gut_flora" },
+          { "reportId": 11800, "reportDate": "2026-08-12", "dateSource": "real",   "value": "4.10", "numericValue": 4.10, "unit": "mmol/L", "status": "偏高", "sourceType": "gut_flora" },
+          { "reportId": 12345, "reportDate": "2026-10-08", "dateSource": "real",   "value": "3.62", "numericValue": 3.62, "unit": "mmol/L", "status": "偏高", "sourceType": "gut_flora" }
+        ],
+        "missingPeriods": [
+          { "reportDate": "2026-06-20", "reportId": 10500, "dateSource": "real" }
+        ]
+      }
+    ]
+  }
+}
+```
+
+#### 字段说明
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `refLow` / `refHigh` | Double \| null | **新增**。解析后的参考范围数值边界,供 TrendChart 画背景带。无法解析时为 `null` → 前端不画带 |
+| `chartMode` | String | **新增**。后端算好的图表形态建议值:`empty` / `single` / `pair` / `line` / `line_aggregated`。**前端照做,不自己判断**(见 §5.5) |
+| `latest` | Object \| null | **新增**。最新一期(等价于 `points` 末元素,单独提出免前端取) |
+| `change` | Object \| null | **新增**。最新一期 vs 上一期。`pointsCount < 2` 时为 `null` |
+| `points[].reportId` | Long \| null | **新增**。供明细表反查报告 |
+| `points[].dateSource` | String | **新增**。`upload`/`confirm`/`unknown` 时前端在日期后加 `*` |
+| `points[].sourceType` | String | **新增**。P1-7 多来源时用不同线型/颜色区分 |
+| `missingPeriods` | Array | **新增**。该 `(subjectId, sourceType)` 下有报告日期、但本指标无有效值的期次。前端把该日期放进 X 轴但**不画点、不连线**(断线) |
+| `unitMismatch` | Boolean | **新增**。`points` 中存在与 `latest.unit` 不同的单位 → 前端给该点加 `*` |
+| **`totalReportCount`** | **Integer** | **新增**。该 `(subjectId, sourceType)` 下的 `health_reports` 总数。**与 `pointsCount` 对比即可判定曲线是否残缺**:`pointsCount < totalReportCount` → 图表区上方显示橙色横幅「该成员共 N 份报告,本指标目前 M 期数据(历史数据整理中)」。**这是 D12 第一段上线的红线功能** |
+| **`olderReportCount`** | **Integer** | **新增**。同 `(subjectId, sourceType)` 且 `report_date` 严格早于**最新一期**的报告份数。与 `totalReportCount` 由**同一次 COUNT 查询**得出(见下方 SQL),不额外开销。恒满足 `olderReportCount ≤ totalReportCount - 1` |
+| **`change.trendCode`** | **String(五值)** | **新增**。`improved`/`worsened`/`flat`/`first_record`/`pending_backfill`。时间轴页顶部与期次表用同一枚举,与报告详情页完全一致 |
+
+> **一次查询同时取两个计数**(避免两次 COUNT):
+> ```sql
+> SELECT COUNT(*) AS total,
+>        SUM(CASE WHEN report_date < :latestReportDate THEN 1 ELSE 0 END) AS older
+> FROM health_reports
+> WHERE subject_id = :subjectId AND report_type = :sourceType
+>   AND status = 'active' AND report_date IS NOT NULL;
+> ```
+
+#### `chartMode` 判定表(后端单点定义)
+
+| 期数 n | `chartMode` | 前端渲染 |
+|---|---|---|
+| `n = 0` | `empty` | 空态「暂无该指标数据」 |
+| `n = 1` | `single` | **不画图**,单值卡 + 「首次记录 · 暂无对比」 |
+| `n = 2` | `pair` | **不画折线**,双期对比条 |
+| `3 ≤ n ≤ 19` | `line` | 折线;`n ≤ 8` 每点标日期+值;`n ≥ 9` 抽稀轴标签 + 默认不标值 |
+| `n ≥ 20` | `line_aggregated` | 折线 + 按月聚合取末值;提供「按月/按原始点」切换 |
+
+> `granularity=month` 显式请求时,`n ≥ 20` 也返回 `line_aggregated`;`n < 20` 时忽略 `granularity`。
+
+#### 错误码
+
+| code | message | 场景 |
+|---|---|---|
+| 500 | `subjectId不能为空` / `成员不属于当前家庭` | 沿用现有校验 |
+| 500 | `granularity 取值非法` | 非 `raw`/`month` |
+
+#### 幂等性 / 分页
+
+- **只读,幂等**。
+- **不分页**:`definitionIds` 由调用方控制在 ≤ 3 个(P2-4 多指标同图上限);单个指标的 points 上限由 `fromDate/toDate` 约束。极端场景(体脂秤 n>500)由 `granularity=month` 降采样。
+
+---
+
+### 4.3 【改造】`POST /api/indicator/change-ranking` — 变化排名(新增 `scope=pair`)
+
+**路径不变**:`/api/indicator/change-ranking`
+**向后兼容**:`scope` 默认仍是 `adjacent`;老字段全部保留。
+
+#### 入参
+
+```json
+{
+  "subjectId": 88,
+  "reportType": "gut_flora",
+  "scope": "pair",
+  "fromReportDate": "2026-03-14",
+  "toReportDate": "2026-10-08",
+  "category": "",
+  "orderBy": "magnitude",
+  "filter": "all",
+  "page": 1,
+  "pageSize": 50
+}
+```
+
+| 字段 | 类型 | 必填 | 默认 | 说明 |
+|---|---|---|---|---|
+| `scope` | String | 否 | `adjacent` | `adjacent` \| `first-vs-latest` \| **`pair`(新增)** |
+| `fromReportDate` | String | `scope=pair` 时必填 | — | **新增**。起始期 report_date |
+| `toReportDate` | String | `scope=pair` 时必填 | — | **新增**。结束期 report_date |
+| `filter` | String | 否 | `all` | **新增**。`all` \| `improved` \| `worsened` \| `flat` \| `incomparable` |
+| `page` / `pageSize` | Integer | 否 | `1` / `50` | **新增**。`limit` 存在时优先走 `limit` 模式(兼容老调用) |
+
+> **兼容老调用**:`gut-flora-detail.vue:1051-1053` 传 `{ scope: 'adjacent', orderBy: 'magnitude', limit: 6 }`,无 `page/pageSize` → 走 `limit` 分支,行为完全不变。
+
+#### 出参
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "from": { "reportDate": "2026-03-14", "reportId": 10001, "dateSource": "real", "indicatorCount": 38 },
+    "to":   { "reportDate": "2026-10-08", "reportId": 12345, "dateSource": "real", "indicatorCount": 42 },
+    "sameDateWarning": false,
+    "overall": {
+      "comparableCount": 38,
+      "improvedCount": 21,
+      "worsenedCount": 9,
+      "flatCount": 8,
+      "incomparableCount": 4
+    },
+    "items": [
+      {
+        "definitionId": 512,
+        "name": "低密度脂蛋白 LDL-C",
+        "category": "血脂",
+        "sourceKind": "report_indicator",
+        "unit": "mmol/L",
+        "refRange": "0.00–3.37",
+        "goodDirection": "lower_is_better",
+        "valueType": "numeric",
+        "before": { "reportId": 10001, "reportDate": "2026-03-14", "dateSource": "real", "value": "4.10", "numericValue": 4.10, "unit": "mmol/L", "status": "偏高" },
+        "after":  { "reportId": 12345, "reportDate": "2026-10-08", "dateSource": "real", "value": "3.62", "numericValue": 3.62, "unit": "mmol/L", "status": "偏高" },
+        "delta": -0.48,
+        "deltaPct": -11.7,
+        "movement": "down",
+        "trendLabel": "改善",
+        "trendCode": "improved",
+        "skipGaps": 0,
+        "unitMismatch": false,
+        "comparable": true
+      },
+      {
+        "definitionId": 777,
+        "name": "舌苔厚薄",
+        "category": "舌象",
+        "unit": "",
+        "goodDirection": "neutral",
+        "valueType": "categorical",
+        "before": { "reportDate": "2026-03-14", "value": "薄白", "numericValue": null },
+        "after":  { "reportDate": "2026-10-08", "value": "厚腻", "numericValue": null },
+        "delta": null,
+        "deltaPct": null,
+        "movement": "changed",
+        "trendLabel": "有变化",
+        "trendCode": "flat",
+        "comparable": false,
+        "unitMismatch": false
+      }
+    ],
+    "page": 1,
+    "pageSize": 50,
+    "total": 38
+  }
+}
+```
+
+#### 新增/变更字段
+
+| 字段 | 说明 |
+|---|---|
+| `from` / `to` | **新增**。`scope=pair` 时回显所选两期;`adjacent`/`first-vs-latest` 时也**填充实际用到的两期**(前端可统一消费) |
+| `sameDateWarning` | **新增**。`from.reportDate == to.reportDate` → `true`,前端提示「两期日期相同,可能为同一次检测的重复上传」 |
+| `overall.incomparableCount` | **新增**。仅一期有值 / 非数值 / 单位不可比 的项数。PRD §5.4 要求「不可比项显示 N 项无法比较」 |
+| `items[].trendCode` | **新增**(与 §4.1 一致) |
+| `items[].movement` | **扩展**。数值型为 `up`/`down`/`flat`;非数值型为 `changed`/`unchanged` |
+| `items[].unitMismatch` | **新增** |
+| `items[].valueType` | **新增**。来自 `indicator_definitions.value_type` |
+
+#### 排序规则变更(**重要**)
+
+| 场景 | 现状 | 改造后 |
+|---|---|---|
+| `orderBy=magnitude` | 按 `\|Δ\|` **绝对值**降序(`IndicatorTrendService:206-207`) | **改为按 `\|Δ%\|` 降序**。**原因**:不同指标单位不同(mmol/L vs μmol/L vs 个/μL),绝对 Δ 跨指标不可比,当前排序在科学上是错的 |
+| `orderBy=improved/worsened` | 不支持 | **新增**。`improved` 按 `deltaPct` 降序(改善幅度大的在前);`worsened` 按 `deltaPct` 升序 |
+| 不可比项 | — | 恒定排在最后,不参与 `improved`/`worsened` 筛选 |
+
+#### 微小基数护栏(PM 提出,`|Δ%|` 的失效模式)
+
+`|Δ%|` 有自己的失效模式:**微小基数会炸出天文数字百分比**(菌群相对丰度 0.01 → 0.05 就是 +400%,会稳居 Top1 但临床上无意义)。
+
+**判定规则**:
+
+```
+FLOOR = refHigh != null      ? max(refHigh × 0.01, ε)     // 满量程的 1%
+      : refLow  != null      ? max(refLow  × 0.1,  ε)
+      :                        0.1                        // 无任何参考范围
+
+if (|before| < FLOOR || before == 0) {
+    deltaPct = null          // 不计百分比,前端显示 '—'
+    排序排最后                // 不参与 magnitude 排序竞争
+}
+```
+
+#### 阈值取多少,是否复用 D5 的 2% 口径?
+
+**结论:不复用。D5 的 2% 与 FLOOR 是两个完全不同的东西,混用会造成语义错误。**
+
+| | D5 的 `FLAT_THRESHOLD_PCT` | 本处的 `FLOOR` |
+|---|---|---|
+| **作用对象** | **变化量**(`\|Δ%\|`) | **基数值**(`\|before\|`) |
+| **量纲** | 相对量(百分比的百分比) | **绝对值**(与指标同单位) |
+| **判定内容** | 变化够不够大到值得说"改善/恶化" | 基数够不够大到**百分比本身有意义** |
+| **触发后果** | `trendCode = 'flat'`(基本持平),**仍参与排序** | `deltaPct = null`,**不参与排序、排最后** |
+| **取值** | 固定 2% | 随指标满量程动态变化 |
+
+> **若强行复用 2% 会发生什么**:`FLOOR = refHigh × 2%` 只是把阈值放大一倍,看似能用,但它把"绝对值阈值"伪装成了"相对量口径",后续任何人看到 2% 都会误以为是 D5 的持平判定,维护时极易改错位置。
+> **两者的分工应该是**:`FLOOR` 先决定"这个百分比能不能算",算出来的 `deltaPct` 再由 D5 的 2% 决定"算出来后够不够大到值得说"。**两道独立的闸,顺序是先 FLOOR 后 D5。**
+
+#### ⚠️ 精确化:FLOOR 护栏**只管百分比与排序,不管 Δ 绝对值**
+
+> **这一条必须写进契约**,否则极易被实现成"基数小就整个变化都不显示"——那用户连"涨了还是跌了"都看不到,比排序错更糟。
+
+| 输出项 | `\|before\| < FLOOR` 时 | 说明 |
+|---|---|---|
+| `delta`(Δ 绝对值) | **照常计算并下发** | 不受 FLOOR 影响,是真实数据 |
+| `deltaPct`(Δ%) | **`null`**,前端显示 `—` | 微小基数上的百分比无临床意义 |
+| 排序权重 | **排最后** | 不参与 `\|Δ%\|` 排序竞争 |
+| `movement`(箭头) | **照常**(`up`/`down`/`flat`) | 方向仍真实 |
+| `trendCode` | **照常按 `goodDirection` 判定** | 不受 FLOOR 影响 |
+
+**前端渲染示例**:`↑ +0.04 (—)` —— 箭头在、Δ 在、百分比位置是 `—`,**不是整行消失**。
+
+**判定式(写在 `computeChange` 内,单一实现点)**:
+
+```java
+// 第一道闸:基数是否小到百分比无意义
+BigDecimal floor = percentageFloor(ref);
+boolean tinyBase = bv.abs().compareTo(floor) < 0;
+
+// 第二道闸:算出来的百分比是否小到不值得说(D5 口径)
+r.deltaPct = (bv.compareTo(BigDecimal.ZERO) != 0 && !r.unitMismatch && !tinyBase)
+    ? r.delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
+    : null;
+// 注意:tinyBase 时 deltaPct = null,但 delta 照常保留(Δ 本身仍有意义)
+//       前端显示 "+0.04 (—)",不显示百分比
+```
+
+**排序权重**:`rankKeyOf()` 返回 `|deltaPct|`,为 `null` 时由外层排到最后。
+**因此"基数为 0 / 单位不一致 / 微小基数 / 非数值"四类统一走"排最后",不需要各自的特判分支。**
+
+#### 为什么用 `refHigh × 1%` 而非"参考范围下界的 10%"
+
+`refLow` 在绝大多数参考范围里是 **0**(`0.00–3.37`、`0–100`、`0.00~1.60`),此时 `refLow × 10% = 0`,护栏完全失效。以满量程 `refHigh` 为基数则恒为正数。
+
+验证三个场景:
+
+| 场景 | 参考范围 | FLOOR | before | 结果 |
+|---|---|---|---|---|
+| 菌群相对丰度 | `0–100(%)` | 1.0 | 0.01 | ✅ 排除(+400% 不再霸榜) |
+| 菌群相对丰度(无范围) | — | 0.1 | 0.01 | ✅ 排除 |
+| LDL-C | `0.00–3.37` | 0.0337 | 3.62 | ✅ 保留正常参与排序 |
+
+#### 排序口径必须 UI 明示(修 S5 的第③个现象:用户不知道这 6 条按什么选出)
+
+- 后端:本接口文档即契约,排序口径写在 §4.3 与 `API_REFERENCE.md`。
+- 前端(**T04 必做**):`gut-flora-detail.vue` 的变化排名模块底部加一行 20rpx 灰字「按变化百分比排序」。光改排序不解决"用户不知道排序依据"的问题。
+- 前端(P1):速查页/对比页的排序按钮旁加口径说明。
+
+#### 错误码
+
+| code | message | 场景 |
+|---|---|---|
+| 500 | `scope=pair 时必须传 fromReportDate 与 toReportDate` | 缺参 |
+| 500 | `指定的期次不存在` | `fromReportDate`/`toReportDate` 在该 `(subjectId, reportType)` 下查不到 |
+| 500 | `fromReportDate 必须早于或等于 toReportDate` | 参数校验 |
+
+#### 幂等性 / 分页
+
+- **只读,幂等**。
+- **分页**:`limit` 模式(老调用)不分页;传了 `page/pageSize` 走分页,`total` 返回筛选后的总数。
+
+---
+
+### 4.4 【改造】`POST /api/indicator/quick-lookup` — 指标速查
+
+**路径不变**。**核心修复**:`reportDate == null` 排序 bug(`IndicatorTrendService:298-301`)。
+
+#### 缺陷代码(现状)
+
+```java
+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);
+```
+
+`a.getReportDate() == null → 返回 -1` 表示 "a 排在 b 前",而这是**降序**比较器 → **null 日期被排到最前,被当成最新值**。
+
+#### 修复后
+
+```java
+/**
+ * 观测序列排序:report_date 降序,NULL 恒定排最后(NULL 不参与"最新"竞争)。
+ * 提取为 IndicatorChangeService.NULLS_LAST_DESC 公共比较器,全局唯一实现。
+ */
+.sorted((a, b) -> {
+    java.sql.Date da = a.getReportDate(), db = b.getReportDate();
+    if (da == null && db == null) return 0;
+    if (da == null) return 1;    // a 在后
+    if (db == null) return -1;   // b 在后
+    return db.compareTo(da);     // 降序
+})
+```
+
+#### 入参(新增 2 个可选字段)
+
+```json
+{ "subjectId": 88, "category": "血脂", "page": 1, "pageSize": 30, "withSparkline": false, "sparkLimit": 12 }
+```
+
+| 字段 | 类型 | 默认 | 说明 |
+|---|---|---|---|
+| `withSparkline` | Boolean | `false` | **新增**。`true` 时每项附带 `sparkline` 点数组(P1-2) |
+| `sparkLimit` | Integer | `8` | **新增**。sparkline 最多返回的点数(取最近 N 期,仍按时间升序)。**取 8 而非 12 的原因见 §6.2 `SparkLine` 的点阵密度约束** |
+
+> **为什么默认 `false`**:速查页一页 30 项,全量带 sparkline 会让响应体膨胀 3–5 倍。默认关,前端开启 P1-2 时显式传 `true`。
+
+#### 出参(每项新增字段)
+
+```json
+{
+  "definitionId": 512,
+  "name": "低密度脂蛋白 LDL-C",
+  "category": "血脂",
+  "value": "3.62",
+  "numericValue": 3.62,
+  "unit": "mmol/L",
+  "refRange": "0.00–3.37",
+  "status": "偏高",
+  "goodDirection": "lower_is_better",
+  "valueType": "numeric",
+  "latestReportDate": "2026-10-08",
+  "latestDateSource": "real",
+  "latestReportId": 12345,
+  "pointsCount": 4,
+  "olderReportCount": 3,
+  "previous": {
+    "reportId": 11800,
+    "reportDate": "2026-08-12",
+    "dateSource": "real",
+    "value": "4.10",
+    "numericValue": 4.10,
+    "unit": "mmol/L",
+    "status": "偏高"
+  },
+  "change": {
+    "delta": -0.48, "deltaPct": -11.7, "movement": "down",
+    "trendLabel": "改善", "trendCode": "improved",
+    "skipGaps": 0, "unitMismatch": false, "comparable": true
+  },
+  "sparkline": [
+    { "reportDate": "2026-03-14", "numericValue": 4.10 },
+    { "reportDate": "2026-05-02", "numericValue": 3.90 },
+    { "reportDate": "2026-07-15", "numericValue": 4.25 },
+    { "reportDate": "2026-08-12", "numericValue": 4.10 }
+  ]
+}
+```
+
+| 新增字段 | 说明 |
+|---|---|
+| `latestDateSource` / `latestReportId` / `goodDirection` / `valueType` | 与 §4.1 一致 |
+| `previous` | **新增**。修掉 PRD S4「只给箭头不给上下文」——速查页现在能看到"上次是哪天" |
+| **`olderReportCount`** | **新增**。同 `(subjectId, sourceType)` 且早于最新一期的报告份数,语义与 §4.1/§4.2 完全一致 |
+| `change` | **新增(恒非空)**。复用 `IndicatorChangeService`。`trendCode` 五值枚举同 §4.1,**含 `first_record` 与 `pending_backfill`** |
+| `sparkline` | **新增**。`withSparkline=true` 才有;`pointsCount < 3` 时为 `null`(前端不渲染) |
+
+---
+
+### 4.5 【新增】`POST /api/indicator/periods` — 期次列表
+
+**用途**:供 P1-1 双期对比页的两个 picker 取期次选项。
+
+**路径**:`/api/indicator/periods`
+**Controller**:`IndicatorController`(新增方法)
+**Service**:`IndicatorTimelineService.listPeriods(subjectId, sourceType)`
+
+#### 入参
+
+```json
+{ "subjectId": 88, "sourceType": "gut_flora" }
+```
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `subjectId` | Long | 是 | |
+| `sourceType` | String | 否 | `null` = 全部来源(跨类型期次,仅 P2 用) |
+
+#### 出参
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "subjectId": 88,
+    "sourceType": "gut_flora",
+    "items": [
+      { "reportDate": "2026-03-14", "reportId": 10001, "dateSource": "real",    "indicatorCount": 38 },
+      { "reportDate": "2026-05-02", "reportId": 10200, "dateSource": "real",    "indicatorCount": 40 },
+      { "reportDate": "2026-07-15", "reportId": 11000, "dateSource": "upload",  "indicatorCount": 39 },
+      { "reportDate": "2026-08-12", "reportId": 11800, "dateSource": "real",    "indicatorCount": 41 },
+      { "reportDate": "2026-10-08", "reportId": 12345, "dateSource": "real",    "indicatorCount": 42 },
+      { "reportDate": null,         "reportId": 12900, "dateSource": "unknown", "indicatorCount": 12 }
+    ]
+  }
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `dateSource` | 该期所有观测中**最差**的一个(`unknown < confirm < upload < real`)。诚实口径 |
+| `indicatorCount` | 该期有有效值的指标数,供 picker 显示「2026-10-08(42 项)」 |
+| `reportDate: null` | 日期未知期,恒定排最后,picker 显示「日期未知」 |
+
+#### 错误码 / 幂等 / 分页
+
+- 错误码同 `IndicatorController` 现有校验。
+- 只读,幂等。**不分页**(单人单类型期次数实测 < 30)。
+
+---
+
+### 4.6 【新增 admin】`POST /api/admin/indicator/backfill` — 存量时序回填
+
+**用途**:把 confirm 链路补写之前的历史报告补进 `indicator_values`(P0-3 的数据面补全)。
+
+**路径**:`/api/admin/indicator/backfill`
+**权限**:`@RequestAttribute("role") == 'admin'`(沿用 `/api/health/indicators/rebuild` 的写法)
+
+#### 入参
+
+```json
+{ "subjectId": 88, "reportId": null, "limit": 200, "dryRun": true, "fixReportDates": false }
+```
+
+| 字段 | 说明 |
+|---|---|
+| `subjectId` | 可选。指定成员;不传则扫全部 |
+| `reportId` | 可选。只回填指定报告(灰度验证用) |
+| `limit` | 单批次处理上限,默认 200,防长事务 |
+| `dryRun` | `true` 时只统计不写入(**首次执行必须 dryRun**) |
+| **`fixReportDates`** | **可选,默认 `false`。为 `true` 时顺带重跑 `ReportDateExtractor` 修复历史报告日期**(见下方「日期修复」) |
+
+#### 日期修复(PM 的 Q7 追问 → 采纳,本接口的高价值副产物)
+
+回填时能拿到 `health_reports.payload_json`,因此**可直接复用已有的 `ReportDateExtractor.extractReportDate(payloadJson)`**(`service/ReportDateExtractor.java:41`)重跑日期提取,把历史被 `new Date()` 污染的 `report_date` 修回真实采样日。
+
+**判定"疑似被污染"的精确条件**(满足才替换,避免误伤本来正确的日期):
+
+```java
+java.sql.Date extracted = reportDateExtractor.extractReportDate(report.getPayloadJson());
+if (extracted != null
+        && !extracted.equals(report.getReportDate())
+        && !report.getReportDate().before(truncToDay(report.getCreatedAt()))) {
+    // 存储的 report_date >= created_at 当天 → 这是入库时 new Date() fallback 写入的特征
+    report.setReportDate(extracted);
+    healthReportMapper.updateById(report);
+    healthReportService.refreshMemberIndicatorCurrent(subjectId, report.getId());
+}
+```
+
+| 要点 | 说明 |
+|---|---|
+| **为什么这个条件成立** | 真实采样日期必然早于上传入库时间;而 `new Date()` fallback 写入的日期与 `created_at` 是**同一天**。所以「`report_date` ≥ 当天 `created_at`」就是污染的特征签名 |
+| **幂等性** | 修复后 `report_date` 变为早于 `created_at` 的真实日期 → 条件不再成立 → 天然幂等,可反复执行 |
+| **必须连带处理** | 日期修好后需重新 `refreshMemberIndicatorCurrent`(该方法按 `report_date` 排序取最近 2 份,日期变了结果会变,且变更后的才是对的) |
+| **安全约束** | `fixReportDates` 默认 `false`,**必须先 `dryRun:true` 看 `dateFixable` 数量**,且与 `indicator_values` 回填分开两步执行 |
+| **下游影响(需业务知悉)** | 修复后**报告列表排序会变化**,用户会看到报告顺序调整。属预期修正,须写进发布说明 |
+
+#### 出参
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "scanned": 156,
+    "eligible": 98,
+    "written": 0,
+    "skippedNoSubject": 42,
+    "skippedNoPayload": 16,
+    "failed": 0,
+    "dryRun": true,
+    "dateFixable": 23,
+    "dateFixed": 0,
+    "details": [
+      { "reportId": 10001, "status": "would_write", "indicatorCount": 38 }
+    ]
+  }
+}
+```
+
+| 新增字段 | 说明 |
+|---|---|
+| `dateFixable` | 满足"疑似污染且能提取到真实日期"条件的报告数(**dryRun 时也能算出**) |
+| `dateFixed` | 实际修复的报告数(`fixReportDates=true && dryRun=false` 时才 > 0) |
+
+#### 存量"日期未知"比例的下降幅度 —— 估算方法
+
+**不能直接给固定百分比**(取决于历史报告的 PDF 里是否本来就有可提取的日期),但可以用下面的方法在**跑之前 5 分钟内算出准确值**:
+
+```sql
+-- 第 1 步:算出存量里"疑似被污染"的报告基数(分子候选池)
+SELECT COUNT(*) AS suspicious_total
+FROM health_reports
+WHERE status = 'active'
+  AND report_date IS NOT NULL
+  AND DATE(created_at) IS NOT NULL
+  AND report_date >= DATE(created_at);      -- ← 污染特征:report_date 与 created_at 同天或更晚
+
+-- 第 2 步:算出全部活跃报告数(分母)
+SELECT COUNT(*) AS active_total FROM health_reports WHERE status = 'active';
+```
+
+**预期收益的三档判断**(拿第 1 步结果对比第 2 步):
+
+| `suspicious_total / active_total` | 说明 | 修完后"日期未知"的下降幅度 |
+|---|---|---|
+| **< 5%** | 历史报告的日期绝大多数本来就是真实的(说明 PDF 里 dates 提取成功率高) | 收益有限,**建议跳过第 0 步**,直接回填 |
+| **5% ~ 30%** | 有相当比例的上传日冒充采样日 | **显著**。这些报告修好后 `date_source` 从 `upload` 变 `real`,时间轴横轴回归真实,且**不再需要在 UI 上打 `*`** |
+| **> 30%** | 历史日期大面积失真 | **极高**。这是 P1-5 的根因修复,顺带把 PRD S6(历史报告日期失真)一并解决 |
+
+**精确值只能靠 `dryRun` 拿到**:`dateFixable` 就是第 1 步结果中**还能提取到真实日期**的那部分。
+所以完整公式是:
+
+```
+dateFixable = |{r : r 疑似污染 ∧ extractReportDate(r.payload_json) != null}|
+预期下降幅度 = dateFixable / active_total
+```
+
+> **一个重要的上限认知**:`dateFixable` 永远 **≤** 第 1 步的 `suspicious_total`,且永远 **≤** `payload_json` 非空的报告数。
+> 若 `payload_json` 大量为空(老数据未存快照),那这部分报告**既无法回填时序、也无法修日期**,只能标 `unknown`。
+> §4.6 的 `skippedNoPayload` 出参就是用来量化这一部分的 —— **如果这个数很大,说明存量数据本身就不完整,覆盖率天花板天然受限,此时应及时调低预期而不是反复回填。**
+
+#### 幂等性
+
+**完全幂等**:底层走 `upsertMapping` 的 `ON DUPLICATE KEY UPDATE`,重复执行只覆盖不新增。`reportId` 已有时,**跳过不写**(由 §3.1 的 `date_source` 判定:存量已写入的行 `date_source='unknown'`,回填时跳过,避免把已有正确数据覆盖成 `unknown`)。
+
+> **回填判定条件**:`health_reports.status='active'` ∧ `subject_id IS NOT NULL` ∧ `payload_json IS NOT NULL` ∧ `NOT EXISTS (SELECT 1 FROM indicator_values WHERE source_type = report_type AND source_id = health_reports.id)`。
+> 然后从 `health_reports.payload_json` 反序列化 `ParsedReportPayload.Payload`,调 `normalizeIndicatorPipeline.processReport(report, payload)`。
+
+---
+
+### 4.7 接口变更汇总与 API_REFERENCE 同步要求
+
+| 接口 | 类型 | 破坏性 | 老调用方 | 处置 |
+|---|---|---|---|---|
+| `/api/health/report/indicators` | 新增 | — | — | 在 `API_REFERENCE.md` §4.48 后新增小节 |
+| `/api/indicator/trend` | 改造 | **否**(只加字段 + 新增可选入参) | `indicator-curve.vue` | 兼容 |
+| `/api/indicator/change-ranking` | 改造 | **否**(只加 scope/入参;`magnitude` 排序口径变更,见下) | `gut-flora-detail.vue`、`indicator-curve.vue` | **排序口径变更需告知**:`magnitude` 从 `\|Δ\|` 改为 `\|Δ%\|`。`gut-flora-detail` 只显示 6 条,视觉上会有变化,属预期修正 |
+| `/api/indicator/quick-lookup` | 改造 | **否**(排序修复属 bugfix) | `indicator-quicklook.vue` | 兼容 |
+| `/api/indicator/periods` | 新增 | — | — | 需同步 |
+| `/api/admin/indicator/backfill` | 新增 | — | — | 需同步 |
+| `/api/health/indicator/list` | **不动** | — | cfc-web `HealthIndicators.vue` | 保留,标注"已被 `/api/health/report/indicators` 取代" |
+
+---
+
+## 5. 服务层设计
+
+### 5.1 核心类图
+
+```mermaid
+classDiagram
+    class IndicatorChangeService {
+        <<新建 @Service>>
+        -IndicatorValueMapper valueMapper
+        -IndicatorDefinitionMapper definitionMapper
+        +NULLS_LAST_DESC Comparator~IndicatorValue~
+        +FLAT_THRESHOLD_PCT double
+        +resolvePrevious(subjectId, definitionId, sourceType, currentDate) IndicatorValue
+        +computeChange(def, before, after) ChangeResult
+        +computeSkipGaps(subjectId, sourceType, defId, fromDate, toDate) long
+        +parseRefRange(refRange) RefRange
+        +judgeInRange(before, after, ref) String
+        +movementOf(delta) String
+        +batchResolve(subjectId, sourceType, defIds, currentDate) Map~Long,IndicatorValue~
+        +chartModeOf(pointsCount, granularity) String
+    }
+
+    class ChangeResult {
+        <<新建 内部类>>
+        +BigDecimal delta
+        +BigDecimal deltaPct
+        +String movement
+        +String trendLabel
+        +String trendCode
+        +long skipGaps
+        +boolean unitMismatch
+        +boolean comparable
+    }
+
+    class RefRange {
+        <<新建 内部类>>
+        +BigDecimal low
+        +BigDecimal high
+        +boolean parsed
+        +boolean inRange(BigDecimal v) boolean
+        +BigDecimal distance(BigDecimal v) BigDecimal
+    }
+
+    class IndicatorTimelineService {
+        <<新建 @Service>>
+        -IndicatorValueMapper valueMapper
+        -IndicatorChangeService changeService
+        +timeline(subjectId, defIds, sourceType, from, to, granularity, sourceKind) Map
+        +listPeriods(subjectId, sourceType) Map
+        +pairCompare(subjectId, sourceType, fromDate, toDate, category, orderBy, filter, page, pageSize) Map
+        +aggregateByMonth(points) List~IndicatorValue~
+    }
+
+    class IndicatorTrendService {
+        <<改造>>
+        +trend(subjectId, defIds, familyId, sourceType, from, to, granularity, sourceKind) Map
+        +changeRanking(subjectId, reportType, category, scope, orderBy, limit, familyId, fromDate, toDate, filter, page, pageSize) Map
+        +quickLookup(subjectId, category, page, pageSize, familyId, withSparkline, sparkLimit) Map
+        +listSourceTypes(subjectId) List~Map~
+        -trendLabel(def, movement, delta) String
+    }
+
+    class HealthReportService {
+        <<改造>>
+        +createReport(report, indicators, gutFlora, diseaseRisks) HealthReport
+        +getReportIndicatorsWithPrevious(reportId, withPrevious) Map
+        +refreshMemberIndicatorCurrent(subjectId, latestReportId) void
+    }
+
+    class NormalizeIndicatorPipeline {
+        <<改造>>
+        +processReport(report, payload) void
+        -resolveDateSource(report) String
+        -writeObservation(sourceType, sourceId, defId, subjectId, familyId, reportDate, dateSource) void
+    }
+
+    class IndicatorValue {
+        <<改造 +2 字段>>
+        +Long id
+        +String sourceType
+        +Long sourceId
+        +Long definitionId
+        +String value
+        +BigDecimal numericValue
+        +Long subjectId
+        +Long reportId
+        +Date reportDate
+        +String unit
+        +String status
+        +String dateSource
+        +String refRangeSnapshot
+    }
+
+    class IndicatorDefinition {
+        <<只读>>
+        +Long id
+        +String refRange
+        +String goodDirection
+        +String valueType
+        +String unit
+        +String category
+    }
+
+    class IndicatorValueMapper {
+        <<改造>>
+        +upsertMapping(v) int
+        +selectBatchBySubjectSource(subjectId, sourceType, defIds) List~IndicatorValue~
+    }
+
+    IndicatorChangeService ..> ChangeResult : 产出
+    IndicatorChangeService ..> RefRange : 产出
+    IndicatorChangeService --> IndicatorValueMapper : 读
+    IndicatorChangeService --> IndicatorDefinition : 读口径
+    IndicatorTimelineService --> IndicatorChangeService : 依赖(变化计算唯一入口)
+    IndicatorTrendService --> IndicatorChangeService : 依赖(替代本地 trendLabel)
+    HealthReportService --> IndicatorChangeService : 依赖(previous 批量解析)
+    HealthReportService ..> NormalizeIndicatorPipeline : 调用(confirm 补写)
+    NormalizeIndicatorPipeline --> IndicatorValueMapper : 写
+    NormalizeIndicatorPipeline --> IndicatorValue : 产出
+    IndicatorValueMapper --> IndicatorValue : ORM
+```
+
+---
+
+### 5.2 上一期查找算法(核心)
+
+#### 定义(严格对齐 PRD §6.5)
+
+```
+上一期 = 该成员(subjectId)
+       ∧ 同一 sourceType
+       ∧ 该指标有有效数值 (numeric_value IS NOT NULL)
+       ∧ report_date 严格 < 本期 report_date
+       ∧ 取其中 report_date 最大的一期
+```
+
+- 跨 `sourceType` 不对比;
+- 上一期该指标缺失 → 继续往前找,找到后标注 `skipGaps`;
+- `report_date IS NULL` 的期**不参与**计算。
+
+#### Java 实现
+
+```java
+/**
+ * 上一期查找。走 idx_subject_def_date(subject_id, definition_id, report_date) 索引。
+ * @return 上一期观测;无则 null(前端走「首次记录 · 暂无对比」)
+ */
+public IndicatorValue resolvePrevious(Long subjectId, Long definitionId, String sourceType,
+                                      java.sql.Date currentReportDate) {
+    if (subjectId == null || definitionId == null || currentReportDate == null) {
+        return null;   // 本期日期未知 → 无法确定"更早",直接判首次记录(诚实,不猜)
+    }
+    List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+        .eq(IndicatorValue::getSubjectId, subjectId)
+        .eq(IndicatorValue::getDefinitionId, definitionId)
+        .eq(sourceType != null && !sourceType.trim().isEmpty(),
+            IndicatorValue::getSourceType, sourceType)
+        .isNotNull(IndicatorValue::getReportDate)      // null 日期不参与
+        .isNotNull(IndicatorValue::getNumericValue)    // 有有效数值
+        .lt(IndicatorValue::getReportDate, currentReportDate)  // 严格小于
+        .orderByDesc(IndicatorValue::getReportDate)
+        .last("LIMIT 1"));
+    return rows.isEmpty() ? null : rows.get(0);
+}
+
+/**
+ * 批量版本(报告详情页 / 双期对比页的主力入口,避免 N+1)。
+ * 一次性拉该 (subjectId, sourceType) 全部观测,内存分组后逐指标二分。
+ */
+public Map<Long, IndicatorValue> batchResolve(Long subjectId, String sourceType,
+                                              java.sql.Date currentReportDate) {
+    List<IndicatorValue> all = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+        .eq(IndicatorValue::getSubjectId, subjectId)
+        .eq(sourceType != null && !sourceType.trim().isEmpty(),
+            IndicatorValue::getSourceType, sourceType)
+        .isNotNull(IndicatorValue::getReportDate)
+        .isNotNull(IndicatorValue::getNumericValue)
+        .lt(IndicatorValue::getReportDate, currentReportDate)
+        .orderByAsc(IndicatorValue::getReportDate));   // 升序,便于取最后一个
+    Map<Long, List<IndicatorValue>> byDef = all.stream()
+        .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
+    Map<Long, IndicatorValue> previous = new HashMap<>();
+    for (Map.Entry<Long, List<IndicatorValue>> e : byDef.entrySet()) {
+        List<IndicatorValue> sorted = e.getValue();   // 已按 report_date 升序
+        previous.put(e.getKey(), sorted.get(sorted.size() - 1));
+    }
+    return previous;
+}
+```
+
+> **性能**:报告详情页 42 个指标 → **1 次查询**(而非 42 次)。单人单类型观测行数实测 < 2000,一次查全量在内存分组是可接受的。
+
+---
+
+### 5.3 `skipGaps` 计算(跨期跳比)
+
+```
+skipGaps = |{ d ∈ allPeriodDates(subjectId, sourceType) : prevDate < d < currDate }|
+         − |{ d ∈ thisIndicatorDates : prevDate < d < currDate }|
+```
+
+即:**两期之间,该成员有报告、但该指标无有效值的期数。**
+
+```java
+public long computeSkipGaps(Long subjectId, String sourceType, Long definitionId,
+                            java.sql.Date fromDate, java.sql.Date toDate) {
+    if (fromDate == null || toDate == null) return 0L;
+    // 该成员该类型下的全部报告期(去重 report_date)
+    List<java.sql.Date> allPeriods = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+            .eq(IndicatorValue::getSubjectId, subjectId)
+            .eq(IndicatorValue::getSourceType, sourceType)
+            .isNotNull(IndicatorValue::getReportDate)
+            .select(IndicatorValue::getReportDate))
+        .stream().map(IndicatorValue::getReportDate).distinct().sorted()
+        .collect(Collectors.toList());
+    long reportsBetween = allPeriods.stream()
+        .filter(d -> d.after(fromDate) && d.before(toDate)).count();
+    long obsBetween = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+            .eq(IndicatorValue::getSubjectId, subjectId)
+            .eq(IndicatorValue::getSourceType, sourceType)
+            .eq(IndicatorValue::getDefinitionId, definitionId)
+            .isNotNull(IndicatorValue::getNumericValue)
+            .gt(IndicatorValue::getReportDate, fromDate)
+            .lt(IndicatorValue::getReportDate, toDate)
+            .select(IndicatorValue::getReportDate))
+        .stream().map(IndicatorValue::getReportDate).distinct().count();
+    return Math.max(0L, reportsBetween - obsBetween);
+}
+```
+
+> **现状代码的问题**:`IndicatorTrendService:137-151` 已经实现了同样的逻辑,但**只在 `scope=adjacent` 分支生效**(`first-vs-latest` 分支 `skipGaps` 恒为 0)。本轮抽到 `IndicatorChangeService` 后,`adjacent` / `first-vs-latest` / `pair` 三个 scope 统一计算。
+
+---
+
+### 5.4 `refRange` 解析与 `in_range_is_better` 判定(修 PRD S10)
+
+#### 5.4.1 refRange 解析器
+
+支持 5 种形式(全角/半角统一归一化):
+
+| 输入样例 | 解析结果 |
+|---|---|
+| `0.00–3.37`(en dash)、`0.00-3.37`、`0.00~3.37`、`0.00—3.37`(em dash) | `low=0.00, high=3.37` |
+| `<5.0`、`<5.0`、`≤5.0` | `low=null, high=5.0` |
+| `>1.0`、`>1.0`、`≥1.0` | `low=1.0, high=null` |
+| `3.37`(单值,如 LDL-C 只给上界) | `low=null, high=3.37` |
+| `阴性`、空串、`见报告`、其他 | `parsed=false` → 回落 `neutral` |
+
+```java
+private static final Pattern RANGE_PATTERN =
+    Pattern.compile("^(-?\\d+(?:\\.\\d+)?)?\\s*[-~-—–]\\s*(-?\\d+(?:\\.\\d+)?)?$");
+private static final Pattern LT_PATTERN = Pattern.compile("^[<<≤]\\s*(-?\\d+(?:\\.\\d+)?)$");
+private static final Pattern GT_PATTERN = Pattern.compile("^[>>≥]\\s*(-?\\d+(?:\\.\\d+)?)$");
+private static final Pattern SINGLE_PATTERN = Pattern.compile("^(-?\\d+(?:\\.\\d+)?)$");
+
+/**
+ * 解析参考范围。解析失败返回 parsed=false(调用方回落 neutral),
+ * 并在管理端指标字典提示"参考范围格式无法解析"。
+ */
+public RefRange parseRefRange(String refRange) {
+    if (refRange == null || refRange.trim().isEmpty()) return RefRange.unparsed();
+    String s = normalize(refRange);   // 全角转半角、去空格、统一分隔符
+    Matcher m;
+    if ((m = RANGE_PATTERN.matcher(s)).matches()) {
+        BigDecimal low  = m.group(1) != null ? new BigDecimal(m.group(1)) : null;
+        BigDecimal high = m.group(2) != null ? new BigDecimal(m.group(2)) : null;
+        if (low == null && high == null) return RefRange.unparsed();
+        if (low != null && high != null && low.compareTo(high) > 0) return RefRange.unparsed(); // 倒挂=脏数据
+        return RefRange.of(low, high);
+    }
+    if ((m = LT_PATTERN.matcher(s)).matches()) return RefRange.of(null, new BigDecimal(m.group(1)));
+    if ((m = GT_PATTERN.matcher(s)).matches()) return RefRange.of(new BigDecimal(m.group(1)), null);
+    if ((m = SINGLE_PATTERN.matcher(s)).matches()) return RefRange.of(null, new BigDecimal(m.group(1)));
+    return RefRange.unparsed();
+}
+
+private String normalize(String s) {
+    return s.replace('-', '-').replace('—', '-').replace('–', '-')
+            .replace('〜', '~').replace('~', '~')
+            .replace("-", "-").replaceAll("\\s+", "").trim();
+}
+```
+
+#### 5.4.2 `in_range_is_better` 判定
+
+```java
+/**
+ * @return "improved" | "worsened" | "flat"
+ */
+private String judgeInRange(BigDecimal before, BigDecimal after, RefRange ref) {
+    if (!ref.isParsed()) {
+        return "flat";   // 解析失败 → 回落中性(并在管理端字典提示)
+    }
+    boolean b = ref.inRange(before);
+    boolean a = ref.inRange(after);
+    if (!b && a) return "improved";   // 区间外 → 区间内
+    if (b && !a) return "worsened";   // 区间内 → 区间外
+    if (b && a) return "flat";        // 始终在区间内
+    // 始终在区间外:按"与区间边界的距离变化"判定
+    BigDecimal db = ref.distance(before);   // ≥0,0 表示在区内
+    BigDecimal da = ref.distance(after);
+    BigDecimal eps = db.abs().multiply(BigDecimal.valueOf(FLAT_THRESHOLD_PCT / 100.0));
+    if (eps.compareTo(BigDecimal.valueOf(0.0001)) < 0) {
+        eps = BigDecimal.valueOf(0.0001);   // 防止 eps 为 0 导致恒定判"持平"
+    }
+    if (da.subtract(db).abs().compareTo(eps) <= 0) return "flat";      // 无位移
+    return da.compareTo(db) < 0 ? "improved" : "worsened";             // 靠近=改善,远离=恶化
+}
+
+// RefRange 内部
+public boolean inRange(BigDecimal v) {
+    if (v == null) return false;
+    return (low == null || v.compareTo(low) >= 0)
+        && (high == null || v.compareTo(high) <= 0);
+}
+/** 到区间边界的距离(区内为 0,区外为正) */
+public BigDecimal distance(BigDecimal v) {
+    if (v == null) return BigDecimal.ZERO;
+    if (inRange(v)) return BigDecimal.ZERO;
+    if (low != null && v.compareTo(low) < 0) return low.subtract(v);
+    if (high != null && v.compareTo(high) > 0) return v.subtract(high);
+    return BigDecimal.ZERO;
+}
+```
+
+---
+
+### 5.5 完整的 `computeChange` 主流程(伪代码)
+
+```java
+/**
+ * 变化计算唯一入口。所有接口(报告详情/时间轴/排名/速查/双期对比)都走这里。
+ */
+public ChangeResult computeChange(IndicatorDefinition def,
+                                  IndicatorValue before, IndicatorValue after) {
+    ChangeResult r = new ChangeResult();
+
+    // ---- 0. 可比性前置 ----
+    r.unitMismatch = before != null && after != null
+        && !Objects.equals(normUnit(before.getUnit()), normUnit(after.getUnit()));
+    String valueType = def != null ? def.getValueType() : null;
+
+    // 非数值指标:不算 Δ,只判有无变化
+    if (!"numeric".equals(valueType) || before == null || after == null
+        || before.getNumericValue() == null || after.getNumericValue() == null) {
+        r.comparable = false;
+        r.delta = null; r.deltaPct = null;
+        if (before != null && after != null) {
+            boolean same = Objects.equals(before.getValue(), after.getValue());
+            r.movement   = same ? "unchanged" : "changed";
+            r.trendLabel = same ? "无变化" : "有变化";
+        } else {
+            r.movement = "changed"; r.trendLabel = "有变化";
+        }
+        r.trendCode = "flat";      // 非数值永不标改善/恶化(合规红线)
+        r.skipGaps = 0;
+        return r;
+    }
+
+    // ---- 1. Δ 与 movement ----
+    BigDecimal bv = before.getNumericValue(), av = after.getNumericValue();
+    r.delta = av.subtract(bv).setScale(4, RoundingMode.HALF_UP);
+    r.movement = r.delta.compareTo(BigDecimal.ZERO) > 0 ? "up"
+               : (r.delta.compareTo(BigDecimal.ZERO) < 0 ? "down" : "flat");
+    // 微小基数护栏(PRD §6.4):|before| < FLOOR 时不计百分比,避免 0.01→0.05 炸出 +400%
+    RefRange ref = parseRefRange(firstNonEmpty(after.getRefRangeSnapshot(),
+                                               def != null ? def.getRefRange() : null));
+    BigDecimal floor = percentageFloor(ref);
+    boolean tinyBase = bv.abs().compareTo(floor) < 0;
+
+    r.deltaPct = (bv.compareTo(BigDecimal.ZERO) != 0 && !r.unitMismatch && !tinyBase)
+        ? r.delta.multiply(BigDecimal.valueOf(100))
+                 .divide(bv, 1, RoundingMode.HALF_UP)
+        : null;                            // 基数为 0 / 单位不一致 / 微小基数 → 百分比不适用
+    r.comparable = true;
+
+    // ---- 2. 基本持平阈值(PRD §6.3:|Δ%| < 2% 且 状态未变)----
+    boolean statusChanged = !Objects.equals(norm(before.getStatus()), norm(after.getStatus()));
+    boolean belowThreshold = r.deltaPct == null
+        || r.deltaPct.abs().compareTo(BigDecimal.valueOf(FLAT_THRESHOLD_PCT)) < 0;
+    if (belowThreshold && !statusChanged) {
+        r.trendCode  = "flat";
+        r.trendLabel = "基本持平";
+        return r;                          // 箭头 movement 保留 ↑↓,词判"基本持平"
+    }
+
+    // ---- 3. 按 goodDirection 判定 ----
+    String gd = def != null ? def.getGoodDirection() : "neutral";
+    String code;
+    if ("higher_is_better".equals(gd)) {
+        code = "up".equals(r.movement) ? "improved" : ("down".equals(r.movement) ? "worsened" : "flat");
+    } else if ("lower_is_better".equals(gd)) {
+        code = "down".equals(r.movement) ? "improved" : ("up".equals(r.movement) ? "worsened" : "flat");
+    } else if ("in_range_is_better".equals(gd)) {
+        code = judgeInRange(bv, av, ref);   // ref 已在步骤 1 解析,不重复解析
+    } else {
+        code = "flat";                     // neutral / 未知:保留箭头,不判好坏
+    }
+    r.trendCode  = code;
+    r.trendLabel = "improved".equals(code) ? "改善"
+                 : ("worsened".equals(code) ? "恶化" : "基本持平");
+    return r;
+}
+```
+
+#### `FLAT_THRESHOLD_PCT`
+
+```java
+/** PRD §6.3 / D5:默认 2%。可配置(见 §9-Q10) */
+private static final double FLAT_THRESHOLD_PCT = 2.0;
+```
+
+#### 微小基数护栏 `percentageFloor`(PRD §6.4)
+
+```java
+/**
+ * 百分比计算的基数下限。低于此值时 Δ% 不具临床意义(0.01→0.05 = +400% 会稳居 Top1)。
+ * 以满量程 refHigh 为基准,避免 refLow=0 导致护栏失效。
+ */
+private BigDecimal percentageFloor(RefRange ref) {
+    if (ref != null && ref.isParsed()) {
+        if (ref.getHigh() != null) {
+            return max(ref.getHigh().multiply(BigDecimal.valueOf(0.01)), MIN_FLOOR);
+        }
+        if (ref.getLow() != null) {
+            return max(ref.getLow().multiply(BigDecimal.valueOf(0.1)), MIN_FLOOR);
+        }
+    }
+    return DEFAULT_FLOOR;   // 0.1
+}
+private static final BigDecimal DEFAULT_FLOOR = new BigDecimal("0.1");
+private static final BigDecimal MIN_FLOOR     = new BigDecimal("0.0001");
+
+/** 排序键:|Δ%| 降序;deltaPct 为 null(基数为0/单位不一致/微小基数/非数值)恒定排最后 */
+public BigDecimal rankKeyOf(ChangeResult r) {
+    return r.deltaPct != null ? r.deltaPct.abs() : null;   // null 由外层排到最后
+}
+```
+
+> **为什么用 `refHigh × 1%` 而非"参考范围下界的 10%"**:绝大多数参考范围的 `low` 是 0(`0.00–3.37`、`0–100`),此时下界的 10% 仍为 0,护栏彻底失效。以满量程 `refHigh` 为基准则恒为正数。
+
+---
+
+### 5.5.1 【Q17 强制】读路径必须过滤 `status=0` 的自动创建 definition
+
+**背景**:P1-6(Q6 选 A)会在 mapping 未命中时**自动创建 `indicator_definitions`(`status=0` 待审核)**。若不拦截,舌诊类报告一上传就会在管理端字典里凭空冒出几十条未审核脏数据,且会流入 `change-ranking` / `quick-lookup` / `trend` 的返回,运营会误判为 bug。
+
+**已核实的现状**:
+
+| 位置 | 现状 | 处置 |
+|---|---|---|
+| `HealthReportService.getLatestIndicatorsByDomain:2149` | **已有** `.eq(IndicatorDefinition::getStatus, 1)` | ✅ 保持 |
+| `IndicatorTrendService.changeRanking` | 经 `indicator_values` 反查,无 status 过滤 | ❌ **必须加** |
+| `IndicatorTrendService.quickLookup` | 同上 | ❌ **必须加** |
+| `IndicatorTrendService.trend` | 同上 | ❌ **必须加** |
+| `IndicatorAdminController:28` → `IndicatorService.listDefinitions` | `status == null` 时**不加过滤**(`IndicatorService.java:26`) | ❌ **必须改为默认 `status=1`** |
+
+**强制实现**(Q17 是 Q6 的**必要前置**,不是可选优化):
+
+```java
+// 1. 读路径统一:按 definitionId 批量查 definition 时,只取 status=1
+private Map<Long, IndicatorDefinition> loadActiveDefinitions(Collection<Long> defIds) {
+    if (defIds == null || defIds.isEmpty()) return Collections.emptyMap();
+    return indicatorDefinitionMapper.selectList(new LambdaQueryWrapper<IndicatorDefinition>()
+            .in(IndicatorDefinition::getId, defIds)
+            .eq(IndicatorDefinition::getStatus, 1))     // ← 强制
+        .stream().collect(Collectors.toMap(IndicatorDefinition::getId, d -> d));
+}
+// 调用方:凡拿不到 definition(status=0 或不存在)的观测,一律从 items 中剔除,
+//        不降级为"指标"+id 的假名称。
+
+// 2. 管理端字典列表:status 入参为空时默认 1
+public List<IndicatorDefinition> listDefinitions(String domain, String category, Integer status) {
+    LambdaQueryWrapper<IndicatorDefinition> qw = new LambdaQueryWrapper<>();
+    if (domain != null) qw.eq(IndicatorDefinition::getDomain, domain);
+    if (category != null) qw.eq(IndicatorDefinition::getCategory, category);
+    qw.eq(IndicatorDefinition::getStatus, status != null ? status : 1);   // ← 默认 1
+    ...
+}
+// 管理端需另开「待审核」tab(status=0)供运营治理,不混在默认列表里。
+```
+
+> **例外**:显式按 `definitionId` 查询的接口(如时间轴页从报告详情页点进来的 `trend`)**不受此限**——用户是主动要看这一个指标。此例外仅用于 `trend`,`change-ranking` / `quick-lookup` 无例外。
+
+**前端管理端(Q17 的第 3 个落点,前两个是后端读路径 + 字典列表)**:
+
+| 文件 | 动作 | 改动点 | 任务 |
+|---|---|---|---|
+| `cfc-web/src/views/admin/indicators/index.vue` | 改 | ① 列表查询**默认带 `status=1`**(或后端默认即为 1,前端显式传更稳);② 新增「待审核(status=0)」tab,与默认列表隔离;③ 待审核 tab 内提供「批量启用 / 批量丢弃」操作入口 | T05-5c |
+
+> **为什么三个落点都不可省**:
+> 1. 后端读路径(`loadActiveDefinitions`)—— 挡住 API 污染
+> 2. 后端字典列表(`listDefinitions` 默认 `status=1`)—— 挡住即使前端忘了传参也会漏
+> 3. 前端管理端视图 —— 挡住 UI 层的 tab 混排,并给运营治理入口
+>
+> **只做 1+2 不做 3 的后果**:管理端默认列表干净了,但运营**看不到**那些自动创建的条目,无法治理 → 脏数据只增不减,最终 `status=0` 的条目堆到几千条。所以 3 不是 UI 美化,是治理闭环的必要一环。
+
+### 5.6 聚合 / 抽稀策略
+
+#### 按月聚合(`granularity=month`,`n ≥ 20` 触发)
+
+```java
+/**
+ * 按 yyyy-MM 分组,取组内 report_date 最大的一条(末值)。
+ * 保留原始 reportDate 与 dateSource(不伪造月首/月末日期)。
+ */
+public List<IndicatorValue> aggregateByMonth(List<IndicatorValue> points) {
+    Map<String, IndicatorValue> byMonth = new LinkedHashMap<>();
+    for (IndicatorValue p : points) {
+        if (p.getReportDate() == null) continue;      // 无日期不入聚合,单独保留在末段
+        String ym = new SimpleDateFormat("yyyy-MM").format(p.getReportDate());
+        IndicatorValue exist = byMonth.get(ym);
+        if (exist == null || p.getReportDate().after(exist.getReportDate())) {
+            byMonth.put(ym, p);
+        }
+    }
+    return new ArrayList<>(byMonth.values());
+}
+```
+
+#### X 轴标签抽稀(**前端实现**,后端只给全量点 + `chartMode`)
+
+| 期数 n | 标签策略 |
+|---|---|
+| `n ≤ 8` | 全部标 `MM-DD` |
+| `9 ≤ n ≤ 19` | 取首、末 + 中间等距抽到共 4–5 个:`idx ∈ {0, ⌊(n-1)/3⌋, ⌊2(n-1)/3⌋, n-1}`;值标签默认关闭 |
+| `n ≥ 20`(聚合后) | 按聚合粒度标 `YYYY-MM`;值标签关闭 |
+
+```js
+// utils/indicator-timeline.js
+function axisLabelIndices(n) {
+  if (n <= 8) return range(0, n)
+  if (n <= 19) return unique([0, Math.floor((n - 1) / 3), Math.floor(2 * (n - 1) / 3), n - 1])
+  return unique([0, Math.floor(n / 2), n - 1])
+}
+```
+
+#### 缺失期与断线
+
+- 后端返回 `missingPeriods`(该成员有报告、但本指标无值的日期)。
+- 前端 X 轴日期全集 = `points ∪ missingPeriods`(排序后等距)。
+- 渲染时:缺失位置**不画点、不连线**(`ctx.moveTo` 重新起笔),明细表该行显示「该期未检测」。
+
+---
+
+### 5.7 关键调用时序
+
+#### 时序 1:confirm 链路补写 `indicator_values`(P0-3)
+
+```mermaid
+sequenceDiagram
+    participant U as 用户(小程序)
+    participant HRC as HealthReportController
+    participant HRS as HealthReportService
+    participant NIP as NormalizeIndicatorPipeline
+    participant MS as IndicatorMappingService
+    participant DM as IndicatorDefinitionMapper
+    participant VM as IndicatorValueMapper
+    participant DB as MySQL
+
+    U->>HRC: POST /api/health/report/confirm {draftId, subjectId}
+    HRC->>HRC: buildReportFromPayload / buildIndicatorsFromPayload
+    HRC->>HRS: createReport(report, indicators, gutFlora, diseaseRisks)
+
+    rect rgb(255, 243, 224)
+    Note over HRS,NIP: 【P0-3 新增】confirm 链路补写时序
+    HRS->>DB: healthReportMapper.insert(report)
+    HRS->>DB: healthIndicatorMapper.insert 逐条指标
+    HRS->>NIP: processReport(report, payload)
+    NIP->>NIP: resolveDateSource(report) → real/upload/confirm/unknown
+    loop payload.indicators
+        NIP->>MS: resolve(sourceType, indicatorName) → defId
+        alt defId 命中
+            MS-->>NIP: defId
+            NIP->>DM: selectById(defId) → def(取 refRange/valueType 快照)
+            NIP->>VM: upsertMapping(v) 含 dateSource + refRangeSnapshot
+            VM->>DB: INSERT ... ON DUPLICATE KEY UPDATE
+        else 未命中
+            MS-->>NIP: null
+            Note over NIP: 【P0】丢弃(保持现状)<br/>【P1-6】自动建 definition 后写入
+        end
+    end
+    loop payload.gutFlora
+        NIP->>VM: upsertMapping(v) 同上
+    end
+    end
+
+    HRS-->>HRC: created
+    HRC->>HRC: savePayloadSnapshot / bindReportToMember
+    HRC->>HRS: refreshMemberIndicatorCurrent(subjectId, created.id)
+    HRC->>HRC: markConfirmed / saveBlocks
+    HRC-->>U: Result{reportDetail}
+```
+
+#### 时序 2:报告详情页加载(带上一期对照,P0-1)
+
+```mermaid
+sequenceDiagram
+    participant P as report-detail.vue
+    participant API as utils/api.js
+    participant HRC as HealthReportController
+    participant HRS as HealthReportService
+    participant ICS as IndicatorChangeService
+    participant VM as IndicatorValueMapper
+    participant DB as MySQL
+
+    P->>API: getReportIndicators({reportId, withPrevious:true})
+    API->>HRC: POST /api/health/report/indicators
+    HRC->>HRC: 权限校验(沿用 getReportDetail 逻辑)
+    HRC->>HRS: getReportIndicatorsWithPrevious(reportId, true)
+
+    HRS->>DB: SELECT * FROM health_indicators WHERE report_id=?
+    DB-->>HRS: 本期指标 42 条
+    HRS->>DB: SELECT report_type, report_date, subject_id FROM health_reports WHERE id=?
+    DB-->>HRS: 报告元信息
+
+    rect rgb(232, 245, 233)
+    Note over HRS,ICS: 【关键】批量解析上一期,1 次查询,非 42 次
+    HRS->>ICS: batchResolve(subjectId, sourceType, currentReportDate)
+    ICS->>VM: selectList(subject_id=? AND source_type=?<br/>AND report_date < ? AND numeric_value IS NOT NULL<br/>ORDER BY report_date ASC)
+    VM->>DB: 走 idx_subject_def_date,1 次查询
+    DB-->>ICS: 历史观测 ~800 行
+    ICS->>ICS: 按 definitionId 分组 → 各取末条 = 上一期
+    ICS-->>HRS: Map<defId, previousValue>
+    end
+
+    loop 每个本期指标
+        HRS->>ICS: computeChange(def, previous, current)
+        ICS->>ICS: delta/deltaPct/movement
+        ICS->>ICS: parseRefRange(after.refRangeSnapshot ?? def.refRange)
+        ICS->>ICS: judgeInRange / 基本持平阈值判定
+        ICS-->>HRS: ChangeResult{trendCode, trendLabel, skipGaps...}
+        HRS->>ICS: computeSkipGaps(...) 仅当 previous 命中
+    end
+
+    HRS-->>HRC: {summary, items[]}
+    HRC-->>API: Result{data}
+    API-->>P: res.data
+
+    P->>P: 按 previous/change 判定三档形态 A/B/C
+    P->>P: IndicatorChangeRow 渲染第二/三行
+    P->>P: pointsCount ≥ 2 的行显示「N 期 ›」入口
+```
+
+#### 时序 3:时间轴页加载(P0-4)+ 双期对比(P1-1)
+
+```mermaid
+sequenceDiagram
+    participant TL as indicator-timeline.vue
+    participant IC as IndicatorController
+    participant ITS as IndicatorTimelineService
+    participant ICS as IndicatorChangeService
+    participant VM as IndicatorValueMapper
+
+    Note over TL: onLoad(options) 同时解析<br/>subjectId / definitionId / reportType 【修 S7】
+
+    TL->>IC: POST /api/indicator/source-types {subjectId}
+    IC-->>TL: [{sourceType:'gut_flora', label:'肠道菌群', periodCount:5}, ...]
+    TL->>IC: POST /api/indicator/trend {subjectId, definitionIds, sourceType, granularity}
+    IC->>ITS: timeline(...)
+    ITS->>VM: selectList(subject_id + source_type + definitionIds + 日期区间)
+    VM-->>ITS: points
+    ITS->>ICS: resolvePrevious(...) 取上一期
+    ITS->>ICS: computeChange(def, prev, latest)
+    ITS->>ICS: chartModeOf(pointsCount, granularity)
+    ICS-->>ITS: 'empty'|'single'|'pair'|'line'|'line_aggregated'
+    ITS->>ITS: 计算 missingPeriods(全部期次 − 本指标期次)
+    ITS-->>IC: {items:[{chartMode, refLow, refHigh, points, missingPeriods, change...}]}
+    IC-->>TL: Result{data}
+
+    alt chartMode = 'empty'
+        TL->>TL: 空态「暂无该指标数据」
+    else chartMode = 'single'
+        TL->>TL: 不画图 + 「首次记录 · 暂无对比」
+    else chartMode = 'pair'
+        TL->>TL: 双期对比条(不画折线)
+    else chartMode = 'line' / 'line_aggregated'
+        TL->>TL: TrendChart(mode='line', refLow, refHigh,<br/>allPeriodDates 含缺失期 → 断线)
+    end
+
+    Note over TL: 【P1-1】点「对比两期 ›」
+    TL->>IC: POST /api/indicator/periods {subjectId, sourceType}
+    IC->>ITS: listPeriods(...)
+    ITS->>VM: SELECT report_date, MIN(report_id), COUNT(DISTINCT definition_id)<br/>GROUP BY report_date ORDER BY report_date IS NULL, report_date
+    VM-->>ITS: 期次列表
+    ITS-->>IC: {items:[{reportDate, reportId, dateSource, indicatorCount}]}
+    IC-->>TL: 期次列表 → 两个 picker
+    TL->>IC: POST /api/indicator/change-ranking<br/>{scope:'pair', fromReportDate, toReportDate, filter, page, pageSize}
+    IC->>ITS: pairCompare(...)
+    ITS->>ICS: 逐指标 computeChange(批量)
+    ITS-->>IC: {from, to, sameDateWarning, overall, items}
+    IC-->>TL: Result{data} → 跳 indicator-compare.vue
+```
+
+---
+
+## 6. 前端增量设计
+
+### 6.1 页面 / 组件清单
+
+| # | 文件 | 类型 | 批次 | 职责 |
+|---|---|---|---|---|
+| 1 | `pages/health/indicator-timeline.vue` | **新建** | P0-4 | 多期主视图(成员/类型 picker + 指标头卡 + 图表 + 期次表 + chip 切换 + footer) |
+| 2 | `pages/health/indicator-compare.vue` | **新建** | P1-1 | 双期对比视图 |
+| 3 | `components/indicator/IndicatorChangeRow.vue` | **新建** | P0-1 | 「较 MM-DD:旧值 → 新值 ↑ Δ (Δ%) 趋势词」行,跨页面复用 |
+| 4 | `components/indicator/IndicatorDegrade.vue` | **新建** | P0-5 | 降级态文案组件(PRD §5.5 九个场景 + 新增 `incomplete` 场景) |
+| **4b** | **`components/indicator/IndicatorIncompleteBanner.vue`** | **新建** | **P0-1/P0-4 共用** | **「历史数据整理中」残缺横幅(§1.2.2)。组件化的原因:三处复用(新时间轴页 / 旧曲线页 / 报告详情页),且上线顺序一变就需要在不同页面使用,内嵌会导致重写** |
+| 5 | `components/indicator/SparkLine.vue` | **新建** | P1-2 | **view 点阵**迷你图(禁用 canvas,见 §8.3) |
+| 6 | `utils/indicator-timeline.js` | **新建** | P0-4 | 纯函数:chartMode 消费、X 轴抽稀、日期格式、缺失期合并 |
+| 7 | `pages/health/components/TrendChart.vue` | **改造** | P0-4 | 增加 `mode` / `refLow` / `refHigh` / `allPeriodDates` / 断线 / 抽稀 / `pointTap` |
+| 8 | `pages/health/report-detail.vue` | **改造** | P0-1/P0-2 | 指标行三档形态 + 卡片头筛选 + 「N 期 ›」入口 |
+| 9 | `pages/health/indicator-quicklook.vue` | **改造** | P1 | 显示上次日期 + sparkline + 跳时间轴 |
+| 10 | `pages/body-detail/index.vue` | **改造** | P1-4 | 卡片第二行带日期;点卡片跳时间轴(不再跳报告列表) |
+| 11 | `pages/health/gut-flora-detail.vue` | **改造** | P0-5 | limit 6 → 20 + 「还有 N 项」提示 + 走 `IndicatorDegrade` |
+| 12 | `pages/health/report-list.vue` | **改造** | P1-3 | 多选态 + 底部「对比这两期」浮条 |
+| 13 | `utils/api.js` | **改造** | P0-7 | 补 `getIndicatorSourceTypes` 导出 + 新增 4 个接口 |
+| 14 | `utils/indicator-change.js` | **改造** | P0-6 | 扩展为单一事实来源(消费 `trendCode`,不再自行判定) |
+| 15 | `pages.json` | **改造** | P0-4 | 注册 `indicator-timeline` / `indicator-compare` |
+| 16 | `cfc-web/src/views/nutritionist/MemberIndicatorTimeline.vue` | **新建** | P2-1 | 管理端最小版(成员+指标+表格,无图) |
+| 17 | `cfc-web/src/views/nutritionist/HealthIndicators.vue` | **改造** | P2-1 | 补 `reportId` 参数(修 S13 空页) |
+
+> **`pages/health/components/TrendChart.vue` vs `components/TrendChart.vue`**:存在两份副本(已核实,10912 / 11040 字节,内容有差异)。本轮**只改 `pages/health/components/TrendChart.vue`**(`indicator-curve.vue:75` 引的就是这一份)。另一份 `components/TrendChart.vue` 需确认无引用后删除或同步,列为 §9-Q9。
+
+### 6.2 组件 Props 契约
+
+#### `TrendChart.vue`(改造后)
+
+```js
+props: {
+  /** 序列 [{ name, color, points:[{reportDate, dateSource, numericValue, unit, status}] }] */
+  series:        { type: Array,   default: function() { return [] } },
+  /** 'line' 折线 | 'spark' 迷你(本组件不用,见 SparkLine)| 'compare' 双期对比条(由 IndicatorChangeRow 承担) */
+  mode:          { type: String,  default: 'line' },
+  /** 参考范围下界/上界,非 null 时画浅色横条背景带 */
+  refLow:        { type: Number,  default: null },
+  refHigh:       { type: Number,  default: null },
+  /** X 轴日期全集(含缺失期),用于等距布局与断线。不传则从 series 推导 */
+  allPeriodDates:{ type: Array,   default: function() { return [] } },
+  /** n ≤ 8 时每点标值;n ≥ 9 默认关闭 */
+  showValues:    { type: Boolean, default: true },
+  /** X 轴日期标签,抽稀由组件内部按 allPeriodDates.length 决定 */
+  showAxisLabels:{ type: Boolean, default: true },
+  width:         { type: Number,  default: 690 },
+  height:        { type: Number,  default: 420 }
+},
+// 新增 emit
+// this.$emit('pointTap', { index, point, x, y })   —— P2-2 长按读数的基础
+```
+
+**关键渲染规则**(改动点):
+
+| 规则 | 实现 |
+|---|---|
+| 参考范围背景带 | `refLow`/`refHigh` 非 null 时,`ctx.fillRect` 画浅色横条 + 上下界虚线。**Y 轴范围必须包含 refLow/refHigh**,否则带会跑到图外 |
+| 缺失期断线 | 遍历 `allPeriodDates`,当前日期在 `points` 中无对应点 → 不 `lineTo`,下一个有效点 `moveTo` 重新起笔 |
+| 上传日标记 | `dateSource !== 'real'` 的点,日期标签后加 `*`(点本身不加,避免糊) |
+| 单位不一致标记 | 点的 `unit !== series.unit` → 值标签后加 `*` |
+| X 轴抽稀 | 按 §5.6 `axisLabelIndices(n)` |
+
+#### `IndicatorChangeRow.vue`(新建)
+
+```js
+props: {
+  /** 上一期 { reportDate, dateSource, value, numericValue, unit, status } | null */
+  previous:     { type: Object,  default: null },
+  /** 本期 { value, numericValue, unit } */
+  current:      { type: Object,  required: true },
+  /** { delta, deltaPct, movement, trendLabel, trendCode, skipGaps, unitMismatch, comparable } | null */
+  change:       { type: Object,  default: null },
+  /** 'normal' 报告详情/时间轴 | 'compact' 列表窄位(省略单位) */
+  size:         { type: String,  default: 'normal' },
+  /** 是否显示跨期跳比提示行 */
+  showSkipHint: { type: Boolean, default: true },
+  /** 跳比提示的具体缺失日期列表(可选,不传则显示「中间 N 期未检测该项」) */
+  skipDates:    { type: Array,   default: function() { return [] } }
+}
+```
+
+**渲染分支**:
+
+```
+change == null || previous == null   →  「首次记录 · 暂无对比」(灰 22rpx)
+change.comparable == false           →  「无法比较」(灰)
+change.unitMismatch == true          →  Δ 正常显示,百分比位置显示「—」,值后加 *
+其余                                  →  「较 MM-DD:{旧值} → {新值} {箭头} {Δ} ({Δ%}) {趋势词}」
+change.skipGaps > 0 && showSkipHint  →  第三行「ⓘ {skipDates 或 '中间 N 期未检测该项'}」(橙 20rpx)
+```
+
+#### `IndicatorDegrade.vue`(新建)
+
+```js
+props: {
+  /**
+   * 'n0'            暂无该指标数据
+   * 'n1'            首次记录 · 暂无对比
+   * 'missing'       该期未检测
+   * 'invalid'       该期无有效数值
+   * 'upload-date'   * 未识别到采样日期,按上传日期记录
+   * 'skip'          ⓘ {dates} 未检测该项,对比的是 {date}
+   * 'unit-mismatch' * 该期单位与其他期不同,未做换算
+   * 'other-type'    其他报告类型有 N 期数据,暂不支持跨类型对比
+   * 'error'         趋势数据加载失败,请下拉重试
+   */
+  scene:  { type: String, required: true },
+  dates:  { type: Array,  default: function() { return [] } },
+  date:   { type: String, default: '' },
+  count:  { type: Number, default: 0 },
+  retry:  { type: Function, default: null }
+}
+```
+
+> 文案**硬编码在此组件内**,严格照抄 PRD §5.5 表格,任何页面不得自行改写(合规要求)。
+
+#### `SparkLine.vue`(新建,**禁用 canvas**)
+
+```js
+props: {
+  /** [{ reportDate, numericValue }],已按时间升序,长度 ≤ 12 */
+  points: { type: Array,   default: function() { return [] } },
+  width:  { type: Number,  default: 60 },    // rpx
+  height: { type: Number,  default: 24 },    // rpx
+  /** 趋势色,来自 utils/indicator-change.js 的 colorClassFor */
+  colorClass: { type: String, default: 'gray-text' }
+}
+```
+
+**实现要点(给 UI 稿的明确约束)**:
+
+| 项 | 约束 | 原因 |
+|---|---|---|
+| 渲染方式 | 纯 `view` 点阵,**禁用 canvas** | 小程序同屏 40 个 canvas 原生组件会掉帧/白屏 |
+| 元素 | 每个点一个 **4rpx 圆点** `<view>`,`position:absolute; left: Px%; top: Py%` | 60rpx 宽容器下,8 个点间距约 6rpx、点径 4rpx |
+| 连线 | **不画连线**(含 rotate border 技巧也不画) | 60×24rpx 空间内旋转 border 的渲染在小程序里会错位、糊成一片 |
+| 视觉结果 | 形成一条**宽度约 4rpx、高度起伏的"点带"**,其上边缘即趋势走向 | 供 UI 稿按此设计,不要期待平滑曲线或连线 |
+| 点数 | **8 个**(§4.4 `sparkLimit` 默认 8) | 12 个点在 60rpx 内会挤成实心块,8 个可读 |
+| 容器 | 外层 `position:relative`,`overflow:hidden` | 防止边缘点溢出 |
+| **末点标识** | **末点(最新值)6rpx 主色实心,其余 4rpx 次级灰** | **必须**。8 个点挤在 60rpx 内,没有终点标识用户**分不清哪端是最新**,走向会读反 |
+| **抽样规则** | **n > 8 等距抽样时,首点与末点强制保留** | **必须**。若实现成"取前 8 个",**最新值会被抽掉**,sparkline 直接失去意义 |
+
+> **抽样实现(后端 `sparkLimit` 或前端截断共用)**:
+> ```js
+> // 保留首末点的等距抽样
+> function sampleWithEndpoints(points, limit) {
+>   var n = points.length
+>   if (n <= limit) return points
+>   var step = (n - 1) / (limit - 1)
+>   var out = []
+>   for (var i = 0; i < limit - 1; i++) out.push(points[Math.round(i * step)])
+>   out.push(points[n - 1])          // ← 末点强制保留
+>   return out
+> }
+> ```
+> **末点必须放在数组最后一位**,前端按索引渲染末点样式,不依赖排序。
+
+> **给 PM 的 UI 稿提示**:sparkline 的视觉产出是「一条起伏的点带」,不是线,也不是平滑曲线。若这个视觉效果不可接受,替代方案是退到 **A2 极简方案(仅箭头)**,不做 sparkline。
+
+### 6.3 页面状态机(`indicator-timeline.vue`)
+
+```mermaid
+stateDiagram-v2
+    [*] --> idle: onLoad(options)
+    idle --> loading: 解析 subjectId/definitionId/reportType 后触发
+    loading --> error: 接口失败
+    loading --> empty: chartMode = 'empty' (n = 0)
+    loading --> single: chartMode = 'single' (n = 1)
+    loading --> pair: chartMode = 'pair' (n = 2)
+    loading --> multi: chartMode = 'line' (3 ≤ n ≤ 19)
+    loading --> aggregated: chartMode = 'line_aggregated' (n ≥ 20)
+    note right of empty
+      n=0 时若 totalReportCount > 0
+      显示「共 N 份报告,本指标
+      暂无可显示的历史数据」
+      —— 不显示「暂无趋势数据」
+    end note
+
+    error --> loading: 点「重试」
+    empty --> [*]
+    single --> [*]
+    pair --> loading: 切换成员/类型/指标
+    multi --> loading: 切换成员/类型/指标
+    aggregated --> multi: 切「按原始点」
+    multi --> aggregated: 切「按月聚合」(n ≥ 20 时)
+    pair --> compare: 点「对比两期 ›」(P1-1)
+    multi --> compare: 点「对比两期 ›」(P1-1)
+    compare --> pair: 返回
+```
+
+| 状态 | 渲染内容 | PRD 依据 |
+|---|---|---|
+| `loading` | 骨架屏(指标头卡 + 图表占位) | — |
+| `error` | `IndicatorDegrade scene='error'` + 重试按钮 | §5.5 |
+| `empty` (n=0) | 「暂无该指标数据」(灰 26rpx 居中)+ footer | §5.2 选型表 |
+| `single` (n=1) | **不画图**。单值卡 + 「首次记录 · 暂无对比」+ 期次表 1 行 + footer | §5.2 选型表 |
+| `pair` (n=2) | **不画折线**。`IndicatorChangeRow` 双期对比条 + 期次表 2 行 | §5.2 选型表 |
+| `multi` (3≤n≤19) | `TrendChart mode='line'` + 参考带 + 期次表(倒序,含 Δ 列)+ 「对比两期」按钮 | §5.2 |
+| `aggregated` (n≥20) | 同上 + 「按月/按原始点」切换 | §5.2 |
+| `compare` | 跳 `indicator-compare.vue`(P1-1) | §5.4 |
+
+> **跨状态的通用横幅(Q16 的时间轴页版本)**:
+> `pointsCount < totalReportCount` 时,图表区上方显示橙色提示条
+> 「该成员共 **N** 份报告,本指标目前 **M** 期数据(历史数据整理中)」。
+> **时间轴页的"残缺"是跨状态问题**——n=1 时也要显示(否则 `single` 状态会说"首次记录",而用户明明有 5 份报告)。
+> 判定依据是 §4.2 新增的 `totalReportCount`,与报告详情页的 `olderReportCount` 是同一数据源的两种投影。
+
+### 6.3.1 报告详情页指标行的状态机(**D12 第二段接入**)
+
+```mermaid
+stateDiagram-v2
+    [*] --> rowLoading: 进入报告详情页
+    rowLoading --> rowError: /api/health/report/indicators 失败
+    rowLoading --> hasPrev: previous != null
+    rowLoading --> firstRecord: trendCode = 'first_record'
+    rowLoading --> pendingBackfill: trendCode = 'pending_backfill'
+
+    hasPrev --> skipGaps: skipGaps > 0
+    hasPrev --> comparable: skipGaps = 0
+    skipGaps --> [*]: 文件 A + 第三行橙色提示
+    comparable --> improved: trendCode = 'improved'
+    comparable --> worsened: trendCode = 'worsened'
+    comparable --> flat: trendCode = 'flat'
+
+    firstRecord --> [*]: 第二行「首次记录 · 暂无对比」灰
+    pendingBackfill --> [*]: 第二行「历史数据整理中,暂无法对比」灰
+    improved --> [*]: 绿 + ↑↓
+    worsened --> [*]: 橙红 + ↑↓
+    flat --> [*]: 灰 + ↑↓ 或 →
+    rowError --> [*]: 指标行退化为现状单期渲染(不阻断页面)
+```
+
+| 状态 | 判定依据(**唯一**) | 第二行渲染 | 颜色 |
+|---|---|---|---|
+| `loading` | 接口未返回 | 骨架 / 不渲染第二行 | — |
+| `error` | 接口失败 | **不渲染第二行**,指标行退化为现状单期渲染 | — |
+| `hasPrev` + `improved` | `trendCode='improved'` | 后端下发的 `trendLabel` | 绿 |
+| `hasPrev` + `worsened` | `trendCode='worsened'` | 后端下发的 `trendLabel` | 橙红 |
+| `hasPrev` + `flat` | `trendCode='flat'` | 后端下发的 `trendLabel` | 灰 |
+| **`firstRecord`** | **`trendCode='first_record'`** | 「首次记录 · 暂无对比」 | 灰 |
+| **`pendingBackfill`** | **`trendCode='pending_backfill'`** | 「历史数据整理中,暂无法对比」 | 灰 |
+
+> **`error` 态的容错设计(D12 第二段的关键保护)**:报告详情页是用户看报告的**默认入口**,绝不能因为对照接口挂了就整页不可用。
+> 因此 `/api/health/report/indicators` 失败时**只静默降级为现状单期渲染**(payload 数据源照常工作),不弹错、不阻断。**这是第二段上线必备的兜底,否则把最关键的页面绑在一个新接口上。**
+
+### 6.4 入口布点矩阵
+
+| 来源页面 | 触发元素 | 目标页 | 传参(**必须全传**) | 批次 |
+|---|---|---|---|---|
+| `report-detail.vue` 指标行右侧 | 「N 期 ›」(`pointsCount ≥ 2` 才显示) | `indicator-timeline` | `subjectId` + `definitionId` + `reportType` + `reportId` | P0-2 |
+| `body-detail/index.vue` 指标卡片 | 点卡片 | `indicator-timeline` | `subjectId=memberId` + `definitionId`(需前端能从 `/api/health/indicators` 响应拿到)+ `reportType`(默认留空=全部) | P1-4 |
+| `indicator-quicklook.vue` 行 | 点行 | `indicator-timeline` | `subjectId` + `definitionId` + `reportType`(留空=全部) | P0-7 |
+| `gut-flora-detail.vue` | 「查看完整变化曲线」 | `indicator-timeline` | `subjectId` + `definitionId` + `reportType='gut_flora'` | P0-7 |
+| `indicator-timeline.vue` | 「对比两期 ›」(`n ≥ 2` 才显示) | `indicator-compare` | `subjectId` + `reportType` + `fromReportDate`(默认上一期)+ `toReportDate`(默认本期) | P1-1 |
+| `report-list.vue` | 「对比这两期」(选中 2 份后浮出) | `indicator-compare` | `subjectId` + `reportType` + `fromReportDate` + `toReportDate` | P1-3 |
+
+**`onLoad` 参数解析(修 S7,所有入口共用)**:
+
+```js
+onLoad: function(options) {
+  var o = options || {}
+  // 【修复】只解析 definitionId → 三个参数全解析
+  if (o.subjectId)   this.subjectId   = Number(o.subjectId)
+  if (o.definitionId) this.definitionId = Number(o.definitionId)
+  if (o.reportType)  this.reportType  = String(o.reportType)
+  if (o.reportId)    this.reportId    = Number(o.reportId)
+  this._pendingEntry = true   // 标记:成员列表加载完后用入口参数覆盖默认值
+  this.loadMembers()
+}
+// loadMembers 成功后:若 _pendingEntry 为 true,用 this.subjectId 覆盖默认的 members[0].id
+```
+
+### 6.5 `utils/api.js` 需补的导出
+
+```js
+// ===== 指标多期展示(DS-2026-10-25-01)=====
+/** 【修复缺失导出,S7】成员已有指标时序的报告类型列表 */
+export const getIndicatorSourceTypes = (params) => request('/api/indicator/source-types', 'POST', params || {})
+/** 报告指标(含上一期对照):{ reportId, withPrevious } */
+export const getReportIndicators = (params) => request('/api/health/report/indicators', 'POST', params || {})
+/** 指标时间轴:{ subjectId, definitionIds, sourceType?, fromDate?, toDate?, granularity?, sourceKind? } */
+export const getIndicatorTimeline = (params) => request('/api/indicator/trend', 'POST', params || {})
+/** 期次列表:{ subjectId, sourceType? } */
+export const getIndicatorPeriods = (params) => request('/api/indicator/periods', 'POST', params || {})
+/** [admin] 存量时序回填:{ subjectId?, reportId?, limit?, dryRun? } */
+export const backfillIndicatorValues = (params) => request('/api/admin/indicator/backfill', 'POST', params || {})
+```
+
+### 6.6 `utils/indicator-change.js` 如何复用为单一事实来源
+
+#### 职责重新划分(关键设计决策)
+
+| 层 | 职责 | 产物 |
+|---|---|---|
+| **后端 `IndicatorChangeService`** | **判定**:Δ / Δ% / movement / trendCode / skipGaps / unitMismatch / comparable | `change` 对象 |
+| **前端 `utils/indicator-change.js`** | **格式化与映射**:trendCode+movement → 颜色类 + 箭头 + 文案;Δ → 展示文本;日期 → 展示文本 | 展示字段 |
+
+> **红线**:前端**不得**根据 `delta` 的正负自行推导"改善/恶化",只能消费 `trendCode`。这是修 S10 与保证 P0-6「全系统同一个指标同一个变化显示一致」的唯一可靠方式。
+
+#### 改造后的完整 API
+
+```js
+// ---------- 判定结果的消费(新增,主力入口)----------
+
+/**
+ * 【单一事实来源】由后端 trendCode + movement 推导展示样式。
+ * 所有展示变化的位置(报告详情/身维度/速查/菌群/时间轴/管理端)必须走这里。
+ *
+ * trendCode 五值枚举(含 D12 的 pending_backfill):
+ *   'improved' | 'worsened' | 'flat' | 'first_record' | 'pending_backfill'
+ *
+ * 关键约定:
+ *   1. trendLabel 优先取后端下发的值,前端不改写(合规:文案统一)
+ *   2. 前端【禁止】用 previous == null 自行判断是"首次"还是"整理中" —— 只能看 trendCode
+ *   3. movement === 'none' 时不渲染箭头
+ *
+ * @param {string} trendCode
+ * @param {string} movement 'up'|'down'|'flat'|'changed'|'unchanged'|'none'
+ * @param {string} backendLabel 后端下发的 trendLabel(优先使用,为空时才用本地兜底)
+ * @returns {{trendLabel:string, cssClass:string, arrow:string, colorClass:string, showArrow:boolean}}
+ */
+export function resolveChange(trendCode, movement, backendLabel) {
+  var mv = movement || 'flat'
+  var arrow = mv === 'up' ? '↑' : (mv === 'down' ? '↓' : '→')
+  var label = backendLabel || ''
+
+  // D12 新增两档:无对比对象。灰色、无箭头。
+  if (trendCode === 'first_record') {
+    return { trendLabel: label || '首次记录 · 暂无对比', cssClass: 'degrade', arrow: '', colorClass: 'gray-text', showArrow: false }
+  }
+  if (trendCode === 'pending_backfill') {
+    return { trendLabel: label || '历史数据整理中,暂无法对比', cssClass: 'degrade', arrow: '', colorClass: 'gray-text', showArrow: false }
+  }
+
+  if (trendCode === 'improved') {
+    return { trendLabel: label || '改善', cssClass: 'impr', arrow: arrow, colorClass: 'green-text', showArrow: true }
+  }
+  if (trendCode === 'worsened') {
+    return { trendLabel: label || '恶化', cssClass: 'worst', arrow: arrow, colorClass: 'red-text', showArrow: true }
+  }
+  return { trendLabel: label || '基本持平', cssClass: 'flat', arrow: mv === 'none' ? '' : arrow, colorClass: 'gray-text', showArrow: mv !== 'none' }
+}
+
+/**
+ * 非数值指标:有变化 / 无变化,不给颜色(合规红线)
+ */
+export function resolveTextChange(movement) {
+  return movement === 'unchanged'
+    ? { trendLabel: '无变化', cssClass: 'flat', arrow: '→', colorClass: 'gray-text' }
+    : { trendLabel: '有变化', cssClass: 'flat', arrow: '→', colorClass: 'gray-text' }
+}
+
+// ---------- 格式化 ----------
+
+/**
+ * Δ 展示文本(PRD §6.4)。列表窄位省略单位由调用方传 unit='' 实现。
+ * @param {number|null} delta
+ * @param {number|null} deltaPct
+ * @param {boolean} unitMismatch 单位跨期不一致 → 百分比显示 '—'
+ */
+export function formatDelta(delta, deltaPct, unitMismatch) {
+  if (delta === null || delta === undefined || isNaN(delta)) return '--'
+  var sign = delta > 0 ? '+' : ''
+  var d = roundSig(delta)                       // PRD §6.4 有效数字规则
+  if (unitMismatch || deltaPct === null || deltaPct === undefined || isNaN(deltaPct)) {
+    return sign + d + ' (—)'
+  }
+  return sign + d + ' (' + (deltaPct > 0 ? '+' : '') + deltaPct + '%)'
+}
+
+/** PRD §6.4 有效数字:≥100 整数;10–100 一位小数;<10 两位小数 */
+export function roundSig(v) {
+  var a = Math.abs(v)
+  if (a >= 100) return String(Math.round(v))
+  if (a >= 10)  return v.toFixed(1)
+  return v.toFixed(2)
+}
+
+/**
+ * 「较 MM-DD」日期文案(PRD §6.6)
+ * @param {string} prevDate 'yyyy-MM-dd'
+ * @param {string} currDate 'yyyy-MM-dd' 用于判断是否同年
+ * @param {string} dateSource 'real'|'upload'|'confirm'|'unknown'
+ */
+export function formatCompareDate(prevDate, currDate, dateSource) {
+  if (!prevDate) return '日期未知'
+  var star = (dateSource && dateSource !== 'real' && dateSource !== 'unknown') ? '*' : ''
+  if (!currDate) return prevDate + star
+  var sameYear = String(prevDate).slice(0, 4) === String(currDate).slice(0, 4)
+  return (sameYear ? String(prevDate).slice(5) : prevDate) + star
+}
+
+/**
+ * 图表形态 → 是否画图 / 是否标值(消费后端 chartMode,不自行判断)
+ * @param {string} chartMode 'empty'|'single'|'pair'|'line'|'line_aggregated'
+ * @param {number} n 期数
+ */
+export function chartRenderConfig(chartMode, n) {
+  if (chartMode === 'empty' || chartMode === 'single') return { draw: false, showValues: false, labelMode: 'none' }
+  if (chartMode === 'pair')                            return { draw: false, showValues: true,  labelMode: 'all' }
+  if (chartMode === 'line_aggregated')                 return { draw: true,  showValues: false, labelMode: 'sparse' }
+  return { draw: true, showValues: n <= 8, labelMode: n <= 8 ? 'all' : 'sparse' }
+}
+
+/** X 轴标签下标抽稀(PRD §5.2 选型表) */
+export function axisLabelIndices(n) {
+  if (n <= 8) { var all = []; for (var i = 0; i < n; i++) all.push(i); return all }
+  if (n <= 19) return unique([0, Math.floor((n - 1) / 3), Math.floor(2 * (n - 1) / 3), n - 1])
+  return unique([0, Math.floor(n / 2), n - 1])
+}
+
+// ---------- 兼容保留 ----------
+
+/** @deprecated 仅供无 trendCode 的老数据兜底(如 member_indicator_current.trend)。
+ *  新代码一律用 resolveChange(trendCode, movement)。 */
+export function resolveChangeStyle(goodDirection, movement) { /* 保留原实现 */ }
+
+export function colorClassFor(cssClass) { /* 保留原实现 */ }
+```
+
+#### 迁移路径
+
+| 调用方 | 现状 | 改造后 |
+|---|---|---|
+| `gut-flora-detail.vue:1058-1060` | `resolveChangeStyle(it.goodDirection, it.movement)` | `resolveChange(it.trendCode, it.movement)` |
+| `indicator-curve.vue` 排名区 | 同上 | 同上 |
+| `report-detail.vue`(新增) | — | `resolveChange(item.change.trendCode, item.change.movement)` |
+| `body-detail/index.vue` | 只显示 `trendDesc`(无日期) | 改用 `/api/health/indicators` 新增的 `previousReportDate` + `resolveChange` |
+| `indicator-quicklook.vue` | 只显示 `trend` | 同上 |
+
+> `resolveChangeStyle` 保留但标 `@deprecated`,**不删**(`member_indicator_current` 链路仍只有 `trend` 字段,无 `trendCode`)。
+
+---
+
+## 7. 任务分解
+
+> **批次说明**:T01–T04 为 **P0 必做**(按依赖顺序串行/并行推进),T05 为 **P1 + P2**。
+> **并行提示**:T02 与 T03 在 T01 完成后**可并行**(后端接口 vs 前端底座,无文件冲突)。
+
+### T01 · 数据地基 + 变化计算底座(P0,无上游依赖)
+
+| 项 | 内容 |
+|---|---|
+| **批次** | P0(对应 P0-3、P1-5 数据侧、P0-6 后端侧) |
+| **上线段** | **两段共用**(数据地基,先于一切上线动作。含 `pending_backfill` 所需的 `olderReportCount` 统计能力) |
+| **依赖** | 无(**第一个任务**) |
+| **可并行** | 否(T02/T03/T04 都依赖它) |
+
+**文件清单**
+
+| 文件 | 动作 | 改动点 |
+|---|---|---|
+| `cfc-backend/.../config/DatabaseInitializer.java` | 改 | ① 新增 `migrateIndicatorValuesTimelineColumns()`(**迁移 422**):`indicator_values` 加 `date_source`、`ref_range_snapshot`;<br>② 同批加 `columnExists("health_indicators","collection_date")` 检查 + 兜底建列(**§3.2 的前置条件,必须最先提交**);<br>③ 在 `runMigrations()` 内调用 |
+| `cfc-backend/src/main/resources/schema.sql` | 改 | `indicator_values` 定义同步 2 列(2581-2599 行区间) |
+| `cfc-backend/.../entity/IndicatorValue.java` | 改 | 加 `dateSource` / `refRangeSnapshot` 字段 + getter/setter |
+| `cfc-backend/.../mapper/IndicatorValueMapper.java` | 改 | `upsertMapping` 的 INSERT 列与 ON DUPLICATE 子句各补 2 列 |
+| `cfc-backend/.../service/IndicatorChangeService.java` | **新建** | §5.2 `resolvePrevious` / `batchResolve`;§5.3 `computeSkipGaps`;§5.4 `parseRefRange` + `judgeInRange`;§5.5 `computeChange`;§5.6 `aggregateByMonth`;`NULLS_LAST_DESC` 比较器;内部类 `ChangeResult` / `RefRange` |
+| `cfc-backend/.../service/NormalizeIndicatorPipeline.java` | 改 | ① `processReport` 内写 `dateSource` + `refRangeSnapshot`(`resolveDateSource(report)` 见 §3.1);<br>② `definitionMapper.selectById(defId)` 拿 definition 用于快照;<br>③ `baseValue()` 签名扩展 |
+| `cfc-backend/.../service/HealthReportService.java` | 改 | **①(P0-3 核心)`createReport(report, indicators)` 内、指标 insert 之后、return 之前,补 `normalizeIndicatorPipeline.processReport(report, payload)`——注意 `createReport(report, indicators)` 这个两参重载**没有** payload 入参,需改为:在 `HealthReportController:1434` 的 `createReport(report, indicators, gutFlora, diseaseRisks)` 之后单独调 `processReport(created, payload)`(payload 在该方法内已存在),并用 try/catch 包裹(失败不阻断 confirm);<br>② `getLatestIndicatorsByDomain` 的 `orderByDesc(recordedAt)`(2162 行)**本轮不动**(P1-4 前端改造时再评估);<br>**③(Q18 新缺口)`bindReportToMember:1282-1302` 尾部补 `normalizeIndicatorPipeline.processReport(rep, payload)`**(修 G3:上传时不选成员 → pipeline 因 subjectId==null 跳过 → 事后绑定成员也补不回时序)。整段 try/catch,失败不阻断绑定 |
+| `cfc-backend/.../controller/HealthReportController.java` | 改 | `/api/health/report/confirm`(1434 行后)补 `normalizeIndicatorPipeline.processReport(created, payload)` 调用,try/catch 包裹 |
+
+**验收标准**
+
+1. `date_source` / `ref_range_snapshot` 列在所有环境存在(启动日志含"迁移422")。
+2. 新 confirm 一份报告 → `indicator_values` 出现该报告全部可 mapping 的数值指标,且 `date_source` 有值。
+3. 同一份报告 confirm 两次 → `indicator_values` 行数不变(`ON DUPLICATE KEY UPDATE` 幂等)。
+4. `IndicatorChangeService` 单测覆盖:refRange 五种格式解析、in_range 四种分支、基数为 0、单位不一致、非数值。
+5. 灰度:随机抽 20 份历史 confirm 报告,`indicator_values` 覆盖率 ≥ 95%(需先跑 T02 的 backfill 端点,可延后验证)。
+
+---
+
+### T02 · 后端接口层(P0,依赖 T01)
+
+| 项 | 内容 |
+|---|---|
+| **批次** | P0(对应 P0-1、P0-4、P0-5、P0-6 接口面、P0-7 后端侧) |
+| **上线段** | **两段共用 —— 后端接口一次性全量部署**(含 `/api/health/report/indicators`)。**理由**:后端接口上线 ≠ 功能上线,`/api/health/report/indicators` 即便先部署,只要前端不接就不可见。拆两次部署后端不经济且徒增风险。控制点在**前端 T04 的接线时机**(见 T04) |
+| **依赖** | T01 |
+| **可并行** | **与 T03 并行** |
+
+**文件清单**
+
+| 文件 | 动作 | 改动点 |
+|---|---|---|
+| `cfc-backend/.../service/IndicatorTimelineService.java` | **新建** | §5.1:`timeline()`(trend 增强:sourceType/区间/granularity/sourceKind + refLow/refHigh + chartMode + latest + change + missingPeriods);`listPeriods()`;`pairCompare()` |
+| `cfc-backend/.../service/IndicatorTrendService.java` | 改 | ① `trend()` 签名扩展(委托 `IndicatorTimelineService.timeline`);<br>② `changeRanking()` 增加 `scope=pair` + `fromReportDate/toReportDate` + `filter` + 分页;`skipGaps` 三 scope 统一;**`magnitude` 排序从 `\|Δ\|` 改为 `\|Δ%\|`**;本地 `trendLabel()`(426-445)**替换**为委托 `IndicatorChangeService.computeChange`;<br>③ `quickLookup()` **修 null 排序 bug(298-301)** + 增加 `previous`/`change`/`latestDateSource`/`latestReportId`/`goodDirection`/`valueType`/`sparkline`;<br>④ `listSourceTypes()` 增加 `periodCount`/`latestReportDate` 出参;<br>**⑤【Q17 强制】三个方法统一改为经 `IndicatorChangeService.loadActiveDefinitions()` 取 definition**(内部 `.eq(status, 1)`),拿不到的观测直接剔除,**不降级为"指标"+id 的假名称** |
+| `cfc-backend/.../controller/IndicatorController.java` | 改 | ① `/trend` 传新参;② `/change-ranking` 传新参 + 参数校验;③ `/quick-lookup` 传新参;④ **新增 `/periods`** |
+| `cfc-backend/.../controller/HealthReportController.java` | 改 | **新增 `/api/health/report/indicators`**(§4.1,含 `olderReportCount`);新增 `/api/admin/indicator/backfill`(§4.6,admin 权限,含 `fixReportDates`) |
+| `cfc-backend/.../service/IndicatorService.java` | 改 | **【Q17】`listDefinitions:26` 的 `status==null` 分支改为默认 `status=1`**(否则 P1-6 自动创建的 `status=0` definition 会冒进管理端字典列表) |
+| `cfc-backend/.../service/HealthReportService.java` | 改 | 新增 `getReportIndicatorsWithPrevious(reportId, withPrevious)`:批量调 `IndicatorChangeService.batchResolve`(**严禁 N+1**)+ 组装 `summary` |
+| `cfc-backend/.../entity/HealthIndicator.java` | 改 | 删掉 `collectionDate` 上的 `@TableField(exist = false)`(**依赖 T01 的兜底建列**) |
+| `docs/superpowers/api/API_REFERENCE.md` | 改 | §4.48 后新增 `/api/health/report/indicators`、`/api/indicator/periods`、`/api/admin/indicator/backfill`;`/api/health/indicator/list` 标注"已被 `/api/health/report/indicators` 取代" |
+
+**验收标准**
+
+1. `POST /api/health/report/indicators` 返回三档形态数据(有上期/首次记录/跨期跳比)齐全。
+2. `POST /api/indicator/trend` 的 `chartMode` 在 n=0/1/2/5/25 五种构造数据下分别返回 `empty`/`single`/`pair`/`line`/`line_aggregated`。
+3. `POST /api/indicator/change-ranking` 传 `scope=pair` 能比任意两期;缺参返回明确错误码;`magnitude` 排序按 `|Δ%|` 降序。
+4. `POST /api/indicator/quick-lookup` 中 `reportDate=null` 的观测**不再**被当作最新值。
+5. `in_range_is_better` 指标(如 BMI、白细胞)不再恒为「中性」(构造 4 种 refRange 分支验证)。
+6. 单份报告指标 42 项时,`/api/health/report/indicators` 的 SQL 查询次数 ≤ 5(开启 MyBatis SQL 日志核对,重点验证无 N+1)。
+7. **【语义方向回归,上线前置】§1.2.3 的 20 条语义用例全部通过,且固化为 `IndicatorChangeService` 的单元测试**(不是一次性人工核验)。含:全部 5 个 `trendCode` 分支 + `in_range` 四分支 + 微小基数 + 基数为 0 + 单位不一致 + 非数值 + 跨期跳比。
+8. **【QP17】`trend`/`changeRanking`/`quickLookup` 三个接口对 `status=0` 的 definition 一律剔除**,不降级为「指标」+id 假名称(构造含 `status=0` 观测的用例验证)。
+9. **【Q14】`changeRanking` 的 `magnitude` 排序中,`deltaPct=null` 的项(微小基数 / 基数为 0 / 单位不一致 / 非数值)恒定排最后**,且不参与 `improved`/`worsened` 筛选。
+
+> **第 7 条是 T02 的阻塞项**:`goodDirection` / `refRange` 是**字典配置驱动**的,后续运营改字典会静默改变判定结果。
+> 一次性人工核验挡不住未来的回归,**必须固化成可重复执行的测试**。上线前这 20 条全绿才放行。
+
+---
+
+### T03 · 前端公共底座(P0,依赖 T01)
+
+| 项 | 内容 |
+|---|---|
+| **批次** | P0(对应 P0-5、P0-6、P0-7 前端侧、P1-2 组件) |
+| **上线段** | **第一段**(全部上线。`IndicatorChangeRow` 内置 `pending_backfill` 档,不依赖接线时机) |
+| **依赖** | T01(只依赖接口契约,可在 T02 联调前先写)|
+| **可并行** | **与 T02 并行** |
+
+**文件清单**
+
+| 文件 | 动作 | 改动点 |
+|---|---|---|
+| `cfc-frontend/utils/api.js` | 改 | ① **补 `getIndicatorSourceTypes` 导出(修 S7)**;② 新增 `getReportIndicators` / `getIndicatorTimeline` / `getIndicatorPeriods` / `backfillIndicatorValues` |
+| `cfc-frontend/utils/indicator-change.js` | 改 | §6.6:新增 `resolveChange` / `resolveTextChange` / `formatDelta`(含 unitMismatch)/ `roundSig` / `formatCompareDate` / `chartRenderConfig` / `axisLabelIndices`;`resolveChangeStyle` 标 `@deprecated` 保留 |
+| `cfc-frontend/utils/indicator-timeline.js` | **新建** | X 轴抽稀、缺失期合并(`points ∪ missingPeriods`)、日期格式化(走 `utils/format.js` 的 `parseDate`,**禁 `new Date(string)`**)、`chartMode` 消费 |
+| `cfc-frontend/components/indicator/IndicatorChangeRow.vue` | **新建** | §6.2 props 契约 + 三档分支 + 跨期跳比提示 |
+| `cfc-frontend/components/indicator/IndicatorDegrade.vue` | **新建** | §6.2 九个降级场景 + **新增 `incomplete` 场景**(`{ scene:'incomplete', totalReportCount, pointsCount }`),文案硬编码(合规) |
+| **`cfc-frontend/components/indicator/IndicatorIncompleteBanner.vue`** | **新建** | **§1.2.2 残缺横幅**:`pointsCount < totalReportCount` 时显示橙条「该成员共 N 份报告,本指标目前 M 期数据(历史数据整理中)」;n=0 时显示「共 N 份报告,本指标暂无可显示的历史数据」。**n=0 且 totalReportCount>0 时禁止显示「暂无趋势数据」**。**必须组件化**(三处复用 + 上线顺序未定) |
+| `cfc-frontend/components/indicator/SparkLine.vue` | **新建** | §6.2 **view 点阵**实现,禁用 canvas |
+| `cfc-frontend/pages/health/components/TrendChart.vue` | 改 | §6.2:新增 `mode`/`refLow`/`refHigh`/`allPeriodDates`/`showAxisLabels` props;画参考范围背景带(**Y 轴范围需包含 refLow/refHigh**);缺失期断线;X 轴抽稀;上传日/单位不一致 `*` 标记;新增 `pointTap` emit |
+
+**验收标准**
+
+1. `getIndicatorSourceTypes` 在 `utils/api.js` 有导出,页面调用不再抛 TypeError。
+2. `IndicatorChangeRow` 用 mock 数据能正确渲染三档形态;`skipGaps>0` 时第三行显示。
+3. `TrendChart` 传入 `refLow=0, refHigh=3.37` 时背景带可见且不越界;`allPeriodDates` 含缺失期时折线断开(视觉检查截图)。
+4. `resolveChange('improved','down')` 返回 `{ trendLabel:'改善', arrow:'↓', colorClass:'green-text' }`(双通道解耦正确)。
+5. 所有日期格式化走 `utils/format.js` 的 `parseDate`,全局搜索确认无 `new Date(` + 字符串字面量的用法。
+
+---
+
+### T04 · 小程序页面改造 + 新建时间轴页(P0,依赖 T02 + T03)
+
+| 项 | 内容 |
+|---|---|
+| **批次** | P0(对应 P0-1、P0-2、P0-4、P0-5) |
+| **上线段** | **本任务是 D12 分拆的落点:文件级划分为「第一段」与「第二段」,两段分两次发版** |
+| **依赖** | T02、T03 |
+| **可并行** | 否(最后一个 P0 任务) |
+
+> **D12 分拆上线的落点说明**:T01/T02/T03 全部一次性上线;**只有 T04 按文件拆成两段发版**。
+> 后端接口(T02)全量部署但前端不接 `/api/health/report/indicators` 时,该接口不可见 —— 这是分拆能做到"后端只发一次"的关键。
+
+#### 文件清单(按上线段划分)
+
+**第一段**(回填完成前即可上线)
+
+| 文件 | 动作 | 改动点 |
+|---|---|---|
+| `cfc-frontend/pages.json` | 改 | 注册 `pages/health/indicator-timeline` |
+| `cfc-frontend/pages/health/indicator-timeline.vue` | **新建** | §5.3 wireframe:① 成员/报告类型 picker(**默认选中入口传入值**);② 指标头部卡(当前值/参考范围/较上次/共 N 期);③ 图表区(按 `chartMode` 分支);④ 期次明细表(倒序,含 Δ 列,缺失期行);⑤ 指标 chip 横滑切换;⑥ 合规 footer(常显不可关);**`onLoad` 同时解析 `subjectId`/`definitionId`/`reportType`/`reportId`(修 S7)**;状态机见 §6.3。**⑦【D12 第一段的诚实性前提】必须实现 `totalReportCount` / `olderReportCount` 残缺横幅**(见下方红线) |
+| `cfc-frontend/pages/health/indicator-quicklook.vue` | 改 | 显示 `previous.reportDate`(修 S4)+ `IndicatorChangeRow`(支持 `pending_backfill` 档);跳时间轴传 `subjectId`。**本页属第一段**:速查页本就是"指标列表 + 最新值",缺历史时退化到现状即可 |
+| `cfc-frontend/pages/body-detail/index.vue` | 改 | 卡片第二行「较 MM-DD ↑ +2.3」带日期;**点卡片跳 `indicator-timeline`(不再跳报告列表,修 S7)**(578-581 行 `goIndicatorDetail`) |
+| `cfc-frontend/pages/health/gut-flora-detail.vue` | 改 | ① `resolveChangeStyle` → `resolveChange(it.trendCode, it.movement)`;② `limit: 6` → `20` 并加「还有 N 项」提示(修 S5);③ 走 `IndicatorDegrade` 统一降级文案;<br>**④【排序口径 UI 明示】变化排名模块底部加一行 20rpx 灰字「按变化百分比排序」**(修 S5 第③个现象)<br>*(sparkline 相关改动已按 PM 要求**整体挪至 T05**,不进 P0 批次)* |
+
+> **🔴 第一段上线的硬性红线(否则 D12 的"诚实降级"前提不成立)**
+> 主理人对第一段的论证是「时间轴页缺数据时显示『暂无趋势数据』属诚实降级」。
+> **但这句话只有在用户真的只有 1 份报告时才成立。**若用户有 5 份报告而时间轴页显示「暂无趋势数据」,那是**比「首次记录」更严重的错误陈述**——它直接声称数据不存在。
+> **因此 `indicator-timeline.vue` 必须随第一段一起上线「残缺横幅」**:
+> `pointsCount < totalReportCount` → 图表区上方橙色提示条
+> 「该成员共 **N** 份报告,本指标目前 **M** 期数据(历史数据整理中)」
+> 且 `chartMode='empty'`(n=0)时也必须显示,文案为「共 **N** 份报告,本指标暂无可显示的历史数据」。
+> **没有这个横幅,第一段不能上线。**
+
+**第二段**(须先通过下方验收门槛)
+
+| 文件 | 动作 | 改动点 |
+|---|---|---|
+| `cfc-frontend/pages/health/report-detail.vue` | 改 | ① 卡片头:总数/异常数 + 「较上次 ±N」+ 三档筛选(全部/有变化/仅异常)+ 「显示变化对照」总开关(默认开,D1);<br>② 指标行替换 204-218 行:行首状态色条 + 第一行(名称/值+单位/状态标签/`N 期 ›` 入口)+ 第二行 `IndicatorChangeRow`(**须按 §4.1 的 `trendCode` 五值渲染**)+ 第三行跳比提示;<br>③ 分组头增加「较上次 +N」;<br>④ 数据源额外调 `getReportIndicators`(payload 仍用于编辑模式);<br>⑤ 卡片底部加合规话术「变化对比仅供参考,不构成医学诊断。」;<br>**⑥ `summary.pendingCount > 0` 时,卡片头显示提示条「部分历史数据正在整理,对比结果可能不全」** |
+
+#### 第二段验收门槛(**未达标则第二段不得上线**)
+
+门槛分三级,**全部满足**才放行:
+
+| 级别 | 指标 | 门槛 | 验算 SQL |
+|---|---|---|---|
+| **L1 报告级** | **报告级时序覆盖率** = 有 ≥1 条 `indicator_values` 观测的 active 报告数 ÷ 有 `subject_id` 的 active 报告总数 | **≥ 85%** | 见下方 Q1 |
+| **L2 成员级** | **成员可用率** = 「至少有 1 个指标有 ≥2 期数据」的成员数 ÷ 「有 ≥2 份 active 报告」的成员总数 | **≥ 90%** | 见下方 Q2 |
+| **L3 抽样** | 抽最近 6 个月的 20 份 `gut_flora` 报告逐份人工核对 | **每份 ≥ 90% 的可 mapping 数值指标都有 ≥1 期观测**,且**无一份被误判为全部「首次记录」** | 见下方 Q3 |
+
+```sql
+-- Q1:报告级时序覆盖率
+SELECT
+  SUM(CASE WHEN iv_cnt > 0 THEN 1 ELSE 0 END) / COUNT(*) AS report_coverage
+FROM (
+  SELECT r.id,
+         (SELECT COUNT(*) FROM indicator_values v
+           WHERE v.source_type = r.report_type AND v.source_id = r.id) AS iv_cnt
+  FROM health_reports r
+  WHERE r.status = 'active' AND r.subject_id IS NOT NULL
+) t;
+
+-- Q2:成员可用率(分母:有 >=2 份报告的成员)
+SELECT
+  SUM(CASE WHEN multi_period_defs > 0 THEN 1 ELSE 0 END) / COUNT(*) AS member_usable_rate
+FROM (
+  SELECT m.subject_id,
+         COUNT(DISTINCT d.definition_id) AS multi_period_defs
+  FROM (
+    SELECT r.subject_id, r.report_type
+    FROM health_reports r
+    WHERE r.status='active' AND r.subject_id IS NOT NULL AND r.report_date IS NOT NULL
+    GROUP BY r.subject_id, r.report_type
+    HAVING COUNT(*) >= 2
+  ) m
+  JOIN indicator_values v ON v.subject_id = m.subject_id AND v.source_type = m.report_type
+  JOIN (SELECT definition_id FROM indicator_definitions WHERE status = 1) d
+       ON d.definition_id = v.definition_id
+  WHERE v.report_date IS NOT NULL AND v.numeric_value IS NOT NULL
+  GROUP BY m.subject_id, v.definition_id
+  HAVING COUNT(DISTINCT v.report_date) >= 2
+) x;
+
+-- Q3:单报告逐项核对(替换 :report_id)
+SELECT d.name,
+       (SELECT COUNT(*) FROM indicator_values v
+         WHERE v.source_type = r.report_type AND v.source_id = r.id
+           AND v.definition_id = d.id) AS has_obs
+FROM health_reports r
+JOIN health_indicators hi ON hi.report_id = r.id
+JOIN indicator_definitions d ON d.name = hi.indicator_name AND d.status = 1
+WHERE r.id = :report_id;
+```
+
+> **为什么 L2 比 L1 更能反映真实体验**:L1 高不代表能用——若每份报告只有 1 期数据,覆盖率可以 100% 但没有任何指标能形成对照。**L2 直接测"有多少成员能真正看到一条变化"**,这才是 P0-1 的价值载体。
+> **若不达标怎么办**:回到 §8.1 继续跑回填;仍不达标则检查 G3(`bindReportToMember` 补写)是否已上线生效 —— G3 补完后补跑一次 `bindReportToMember` 触发的 pipeline 即可。
+
+**验收标准(全 T04)**
+
+1. 报告详情页每个数值指标在有上一期时显示「较 MM-DD:旧值 → 新值 ↑ Δ (Δ%) 趋势词」;无上一期且 `olderReportCount=0` 显示「首次记录 · 暂无对比」;无上一期且 `olderReportCount>0` 显示「历史数据整理中,暂无法对比」;跨期跳比显示橙色提示行。
+2. 报告详情页指标行 `pointsCount ≥ 2` 时显示「N 期 ›」,点击跳时间轴页且**成员/类型/指标三个参数都正确**(构造 4 个入口逐一验证)。
+3. 时间轴页在 n=0/1/2/5/25 五种数据下渲染符合 §5.2 选型表(重点:n=1 不画图、n=2 不画折线)。
+4. **🔴【阻塞项 · 不通过则整个 T04 不得上线】残缺横幅四条件全部正确**:
+   - `0 < pointsCount < totalReportCount` → 「共 N 份报告,本指标目前 M 期数据(历史数据整理中)」
+   - `pointsCount == 0 && totalReportCount > 0` → 「共 N 份报告,本指标暂无可显示的历史数据」
+   - `pointsCount == 0 && totalReportCount == 0` → 「该成员暂无报告数据」
+   - `pointsCount == totalReportCount` → **横幅隐藏**
+   - **任何路径下均不得出现孤立的「暂无趋势数据」**
+   *本条为 PM 与架构共同认定的 **P0 红线、不可裁剪**。它不是 UI 细节,是"不谎报数据不存在"的合规底线。*
+5. 缺失期在图上断开、在明细表显示「该期未检测」。
+6. `dateSource != 'real'` 的日期后显示 `*`,底部有说明文字。
+7. 4 个入口(报告详情/身维度/速查/菌群)进入时间轴页均正确渲染,无 TypeError。
+8. 小程序真机:报告详情页 40+ 指标首屏渲染耗时 ≤ 800ms(低端机基准需实测,见 §8.3)。
+
+---
+
+### T05 · P1 + P2 增量(依赖 T04)
+
+| 项 | 内容 |
+|---|---|
+| **批次** | P1(P1-1 ~ P1-7)+ P2(P2-1) |
+| **上线段** | **第一段**(非数值通道 P1-6、体脂秤 P1-7、日期来源标记 P1-5)+ **第二段之后**(P1-1 双期对比、P1-2 sparkline、P1-3、P1-4、P2-1) |
+| **依赖** | T04 |
+| **可并行** | 内部可拆并行(后端 5a / 前端 5b / 管理端 5c) |
+
+**文件清单**
+
+| 文件 | 动作 | 子任务 | 批次 |
+|---|---|---|---|
+| `cfc-backend/.../entity/BodyFatScaleRecord.java` | **新建** | 5a | P1-7 |
+| `cfc-backend/.../mapper/BodyFatScaleRecordMapper.java` | **新建** | 5a | P1-7 |
+| `cfc-backend/.../config/DatabaseInitializer.java` | 改 | 5a | 迁移 **423**(`body_fat_scale_records` 表)+ 迁移 **424**(存量 source_id 回填) |
+| `cfc-backend/src/main/resources/schema.sql` | 改 | 5a | 同步 `body_fat_scale_records` |
+| `cfc-backend/.../service/BodyFatScaleService.java` | 改 | 5a | `sourceId = recordId`;`insert` → `upsertMapping`(**修第二次测量整体报错**);per-metric try/catch;`reportDate = measuredAt ?? today`;`dateSource` |
+| `cfc-backend/.../service/ReportCollectService.java` | 改 | 5a | 删除 `768-770` 的 `new Date()` fallback(P1-5) |
+| `cfc-backend/.../controller/HealthReportController.java` | 改 | 5a | 删除 `2390/2393/2396` 的 `new Date()` fallback;DAN confirm 打 `dateSource='confirm'` |
+| `cfc-backend/.../service/NormalizeIndicatorPipeline.java` | 改 | 5a | **非数值通道(P1-6)**:未命中 mapping 时自动创建 `indicator_definitions`(`status=0, valueType='categorical'`)后写入,而非丢弃(见 §9-Q6) |
+| `cfc-backend/.../service/IndicatorTimelineService.java` | 改 | 5a | `pairCompare` 完善(P1-1 后端侧已在 T02 建骨架) |
+| `cfc-frontend/pages/health/indicator-compare.vue` | **新建** | 5b | P1-1 双期对比页(§5.4 wireframe):双 picker + 快捷选项 + 汇总条 + 对照表 + footer |
+| `cfc-frontend/pages.json` | 改 | 5b | 注册 `indicator-compare` |
+| `cfc-frontend/pages/health/report-list.vue` | 改 | 5b | P1-3 多选态 + 「对比这两期」浮条 |
+| `cfc-frontend/pages/body-detail/index.vue` | 改 | 5b | P1-4 卡片升级收尾(`/api/health/indicators` 增加 `previousReportDate` 出参配合) |
+| `cfc-frontend/pages/health/report-detail.vue` | 改 | 5b | **P1-2 sparkline 接入**(原在 T04,**按 PM 要求挪至 T05**,避免成为 P0 批次的阻塞项)。含两项硬约束:① **分组折叠时 `v-if` 不渲染 sparkline**,只对展开的分组实例化;② 接入时须满足 §6.2 的视觉约束(末点 6rpx 主色、首末点强制保留) |
+| `cfc-web/src/views/nutritionist/MemberIndicatorTimeline.vue` | **新建** | 5c | P2-1 最小版:成员选择 + 指标选择 + **表格**(无图) |
+| `cfc-web/src/views/nutritionist/HealthIndicators.vue` | 改 | 5c | 补 `reportId` 参数(修 S13 空页) |
+| **`cfc-web/src/views/admin/indicators/index.vue`** | **改** | **5c** | **【Q17 必要前置】默认带 `status=1` 过滤 + 新增「待审核 `status=0`」tab + 批量启用/丢弃入口。必须与 5a 的非数值通道(P1-6)同批上线** |
+
+**验收标准**
+
+1. 体脂秤连续测两次不再报错,时间轴上出现两个点。
+2. 双期对比页能选任意两期,对照表按 `|Δ%|` 降序,可按「仅改善/仅恶化」筛选,不可比项显示「N 项无法比较」。
+3. 报告列表能选两份并发起对比。
+4. 非数值指标(如舌象描述)在时间轴页显示文字版历史,显示「有变化/无变化」,**不判改善恶化、不给颜色**。
+5. `dateSource='upload'` 的日期全系统显示 `*`。
+6. 管理端成员指标档案页能看到该成员某指标的全量期次表格。
+
+---
+
+### 任务依赖图
+
+```mermaid
+graph LR
+    T01["T01 数据地基 + 变化计算底座<br/>(P0) 无依赖"]
+    T02["T02 后端接口层<br/>(P0)"]
+    T03["T03 前端公共底座<br/>(P0)"]
+    T04["T04 页面改造 + 时间轴页<br/>(P0)"]
+    T05["T05 P1 + P2 增量<br/>5a后端 / 5b前端 / 5c管理端"]
+
+    T01 --> T02
+    T01 --> T03
+    T02 --> T04
+    T03 --> T04
+    T04 --> T05
+
+    T02 -.可并行.-> T03
+
+    style T01 fill:#ffe6cc
+    style T02 fill:#d5e8d4
+    style T03 fill:#d5e8d4
+    style T04 fill:#dae8fc
+    style T05 fill:#f8cecc
+```
+
+**关键路径**:`T01 → T02 → T04`(或 `T01 → T03 → T04`)。
+**并行窗口**:T02 与 T03 可完全并行(文件零交集),建议同时开工,联调点落在 T04。
+
+### D12 上线段切分图
+
+```mermaid
+graph LR
+    subgraph shared["两段共用 · 一次后端部署(先行)"]
+        T01b["T01 数据地基<br/>+ olderReportCount 统计能力"]
+        T02b["T02 后端接口全量<br/>含 /api/health/report/indicators<br/>(先部署,第二段才被前端接)"]
+        T03b["T03 前端公共底座<br/>含 pending_backfill 档"]
+    end
+
+    subgraph p1["第一段 · 无需等回填"]
+        T04a["T04 第一段文件<br/>时间轴页 + 趋势入口<br/>+ 3 个 bugfix + 残缺横幅🔴"]
+        T05a["T05-5a/5c 部分<br/>非数值通道 + 体脂秤<br/>+ 管理端 status 过滤"]
+    end
+
+    subgraph gate["第二段门槛"]
+        G["L1 ≥ 85% ∧ L2 ≥ 90%<br/>∧ L3 抽样通过"]
+    end
+
+    subgraph p2["第二段 · 须先过门槛"]
+        T04b["T04 第二段文件<br/>报告详情页<br/>本期 vs 上一期对照"]
+    end
+
+    T01b --> T02b
+    T02b --> T03b
+    T03b --> T04a
+    T03b --> T05a
+    T04a --> G
+    G -->|"达标"| T04b
+    G -->|"不达标,回炉回填"| T04a
+
+    style T01b fill:#ffe6cc
+    style T02b fill:#ffe6cc
+    style T03b fill:#ffe6cc
+    style T04a fill:#d5e8d4
+    style T05a fill:#d5e8d4
+    style G fill:#fff2cc
+    style T04b fill:#dae8fc
+```
+
+> **🔴 残缺横幅是第一段的红线**:没有它,用户有 5 份报告时时间轴页会显示「暂无趋势数据」——那是**声称数据不存在**,比「首次记录」更严重的错误陈述。**缺它则第一段不得上线。**
+
+---
+
+## 8. 兼容与风险
+
+### 8.1 存量数据回填方案
+
+| 数据 | 回填策略 | 幂等性 | 批次 |
+|---|---|---|---|
+| `indicator_values` 缺失的 confirm 报告 | 走 `/api/admin/indicator/backfill`(§4.6)。**第一步必须 `dryRun: true`** 看 `eligible` 数量,确认后再实跑。分批 `limit=200` 防长事务 | 完全幂等(`upsertMapping`) | T02 接口就绪后执行 |
+| `date_source` | **存量一律标 `unknown`,不猜**。UI 上 `unknown` **不加 `*` 也不加"采样日"标记**,等价于旧行为,不误导。只有新增/重写的观测才有 `real`/`upload` | 无需回填(列有默认值 `'unknown'`) | T01 自动完成 |
+| `ref_range_snapshot` | **不回填存量**(历史参考范围可能已变,回填会造成假精确)。读时 `snapshot ?? def.refRange` | 无需回填 | T01 |
+| `health_indicators.collection_date` | 存量行的该列历史上一值为 NULL(`exist=false` 导致未写)。**不回填**,仅新报告生效 | — | T02 |
+| 体脂秤 `source_id` | 迁移 424:按 `memberId` 建 record 后批量改写 `source_id`。冲突时保留 `id` 最大的一行,其余删除 | 分步 try/catch,失败只 log | T05 |
+
+**执行顺序(D12 定稿版)**
+
+```
+【第 0 步 · 日期修复 —— 必须先于时序回填】
+0.1 部署含 T01/T02 的后端版本,确认启动日志含「迁移422」
+0.2 调 { dryRun: true, fixReportDates: true }  → 记录 dateFixable
+0.3 业务确认后调 { dryRun: false, fixReportDates: true }
+    ├─ 每份报告连带 refreshMemberIndicatorCurrent(已在 §4.6 内)
+    └─ 修完抽查报告列表顺序变化是否符合预期
+
+【第 1 步 · 时序回填】
+1.1 调 { dryRun: true }  → 记录 eligible
+1.2 调 { dryRun: false, limit: 200 } 循环直到 eligible = 0
+
+【第 2 步 · 验收(第二段的上线开关)】
+2.1 跑 §7 第二段验收门槛的 Q1/Q2 两条 SQL,拿到 L1/L2 两个覆盖率
+2.2 抽 20 份最近 6 个月 gut_flora 报告,逐份跑 Q3 核对
+2.3 L1 ≥ 85% ∧ L2 ≥ 90% ∧ L3 通过  → 放行;否则回到 1.2
+
+【第 3 步 · 前端发版(按 D12 分两段)】
+3.1 第一段:T03 + T04 第一段文件(时间轴页 + 趋势入口 + 3 个 bugfix + P0-5 降级)
+    —— 无需等待第 2 步,可与 1.2 并行
+3.2 第二段:T04 第二段文件(报告详情页对照)—— 须先过第 2 步门槛
+```
+
+> **为什么日期修复必须放在第 0 步(先于时序回填)**:
+> `processReport` 写 `indicator_values.report_date` 时直接取 `report.getReportDate()`。**若先回填再修日期,`indicator_values` 里就固化了被污染的日期**,再修 `health_reports.report_date` 也不会回写到 `indicator_values`(两者是冗余存储,不做级联更新)。
+> 顺序反了的后果:整个时间轴的横轴全错,且需要第二次全量回填才能修正。
+
+> **为什么"先测量"仍是硬前置**:§3.6 实测发现主流上传链路(`ReportCollectService:728`)**已经在写 `indicator_values`**,缺口被我原先的悲观假设高估了。覆盖率到底是 60% 还是 95%,只有 dryRun 能回答,这个数字直接决定 D12 第二段何时放行。
+
+> **为什么"先测量"现在是硬前置**:§3.6 实测发现主流上传链路(`ReportCollectService:728`)**已经在写 `indicator_values`**,缺口被我原先的悲观假设高估了。覆盖率到底是 60% 还是 95%,只有 dryRun 能回答,而这个数字直接决定要不要分拆上线。
+>
+> **为什么有了 `olderReportCount` 后仍建议先回填**:`olderReportCount` + 档 B2 把最坏情况从"谎称首次记录"降级为"历史数据整理中"(诚实),但**功能本身仍是残缺的**——用户点进时间轴页看到 2 期数据,明知自己有 5 份报告。诚实不等于有用,所以回填仍是推荐路径,只是从"硬性前置"改为"强烈建议"。
+
+### 8.2 老接口兼容矩阵
+
+| 接口 | 老调用方 | 兼容性 | 处置 |
+|---|---|---|---|
+| `/api/health/indicator/list` | cfc-web `views/nutritionist/HealthIndicators.vue:86-89`(实际因缺 reportId 是空页) | **不动** | 保留原样返回 `List<HealthIndicator>`;在 API_REFERENCE 标注"已被 `/api/health/report/indicators` 取代" |
+| `/api/health/indicators` | `body-detail/index.vue`、`indicator-quicklook.vue` | **不动**(只加字段) | P1-4 只用新增的 `previousReportDate` 字段 |
+| `/api/health/indicators/current` | `member_indicator_current` 消费方 | **不动** | 保留 |
+| `/api/indicator/trend` | `indicator-curve.vue` | 向后兼容(只加字段 + 新增可选入参) | 老页面不受影响 |
+| `/api/indicator/change-ranking` | `gut-flora-detail.vue`(`limit:6`)、`indicator-curve.vue` | 向后兼容,**但 `magnitude` 排序口径变更** | 老调用传 `limit` 走 limit 模式,行为不变;排序从 `\|Δ\|` 变 `\|Δ%\|` 属预期修正,需在发布说明中告知 |
+| `/api/indicator/quick-lookup` | `indicator-quicklook.vue` | 向后兼容(排序修复属 bugfix) | — |
+| `/api/indicator/source-types` | `indicator-curve.vue:156`(**当前调用抛错**) | 接口本就正常,是**前端未导出** | T03 补导出即可 |
+| `/api/health/report/detail` | `report-detail.vue` | **不动** | 详情页的 payload 仍走它;新指标数据额外调 `/api/health/report/indicators` |
+
+### 8.3 性能风险与对策
+
+| 风险 | 量化 | 对策 |
+|---|---|---|
+| **N+1 查询**(报告详情页 42 个指标逐个查上一期) | 42 次 → 单次查询 | **强制**走 `IndicatorChangeService.batchResolve`:一次拉该 `(subjectId, sourceType)` 全量观测后内存分组。T02 验收标准第 6 条用 SQL 日志核对 |
+| **40 个 canvas sparkline 渲染压力**(P1-2) | 微信小程序的 `<canvas type="2d">` 是**原生组件**,同屏 40 个会严重掉帧甚至白屏,且节点数可能触顶 | **决策:sparkline 禁用 canvas,用 `SparkLine.vue` 的 view 点阵实现**(§6.2)。40 个 × ≤12 点 = ≤480 个 `view` 节点,可接受。<br>**这是对 D4 的补充技术约束**:即便选 A(默认开),也必须走 view 实现 |
+| **时间轴页 n 很大**(体脂秤日均 1 次,一年 365 点) | 一次查 365 行 + 前端渲染 365 点 | 后端 `granularity=month` 按月聚合取末值(默认在 `n ≥ 20` 时由前端请求);`fromDate/toDate` 默认给最近 24 个月 |
+| **quick-lookup 响应体膨胀**(30 项 × 12 点 sparkline) | 响应体 3–5 倍 | `withSparkline` 默认 `false`,前端按需开启 |
+| **`trend` 的 `missingPeriods` 计算** | 需额外查该成员全部期次 | 与 `points` 查询合并为同一次全量查询后内存计算,**不单独查库** |
+| **confirm 链路增加 pipeline 写入耗时** | 单报告 40 个指标 = 40 次 `upsertMapping` | ① try/catch 包裹,失败不阻断 confirm;② 与现有 `refreshMemberIndicatorCurrent`(内部有 2 次 `getReportIndicators` + N 次 `indicatorDefinitionMapper.selectOne`)相比,增量可接受;③ 若实测 P95 增加 > 500ms,再改造为批量 `upsert` |
+
+### 8.4 回滚方案
+
+| 层 | 回滚动作 | 回滚耗时 | 可回滚性 |
+|---|---|---|---|
+| **数据层** | `ALTER TABLE ADD COLUMN` 是**加法**,新列有默认值且老代码不读不写。**只回滚代码即可,DDL 无需回滚** | 0 | ✅ 完全可回滚 |
+| **confirm 补写 pipeline** | 去掉 `HealthReportController` 里新增的 `processReport` 调用 | 5 min | ✅ |
+| **新接口** | 独立新增,删掉不影响任何老接口 | 5 min | ✅ |
+| **改造接口** | 全部是"加字段 + 改排序",老字段保留。回滚只需 revert 相关方法 | 10 min | ✅ |
+| **新页面 `indicator-timeline`** | 独立路由。出问题时把 4 个入口的跳转 URL 改回 `indicator-curve`(或直接隐藏入口) | 10 min | ✅ |
+| **`HealthIndicator.collectionDate` 放开映射**(§3.2,唯一高危) | 把 `@TableField(exist = false)` 加回去 | 5 min | ✅ **但这是"先炸后回滚",必须靠 T01 的 `columnExists` 兜底预防** |
+| **体脂秤 `source_id` 改造**(T05) | 独立模块,迁移 423/424 只影响体脂秤 | 30 min | ⚠️ 涉及数据迁移,需在灰度环境先验证 |
+
+> **无 feature flag 机制**:项目现状没有开关框架。回滚粒度是"接口/页面/注解"级别,而非运行时开关。若需要更细的灰度,建议在 T01 加一个常量开关 `IndicatorFeatureFlags.TIMELINE_ENABLED`(默认 `true`),前端 `/api/config/public` 未接入前先不做。**列为 §9-Q11 待定。**
+
+### 8.5 残留技术债(本轮不修,登记)
+
+| 债 | 位置 | 影响 | 建议 |
+|---|---|---|---|
+| `member_indicator_current` 只存 2 期 | `HealthReportService:2280-2363`(`LIMIT 2`) | 「较上次」语义在多期能力上线后应迁移到 `indicator_values` | 下轮:`/api/health/indicators/current` 改为委托 `IndicatorChangeService`,废弃 `member_indicator_current` 的 delta/trend 字段 |
+| `getLatestIndicatorsByDomain` 用 `orderByDesc(recordedAt)` 而非 `report_date` | `HealthReportService:2162` | 取"最新"口径错误:`recordedAt` 是入库时间,不是报告日期。同一天上传的历史报告会盖掉新报告 | 下轮:改为 `orderByDesc(report_date)`(需先确认无依赖 `recordedAt` 语义的消费方) |
+| `indicator-curve.vue` 与 `indicator-timeline.vue` 双页并存 | — | 维护两套 | 下轮:时间轴页稳定后废弃 `indicator-curve.vue`,入口全部改指 |
+| `components/TrendChart.vue` 与 `pages/health/components/TrendChart.vue` 双副本 | — | 改一处漏一处 | 下轮:确认无引用后删除其一 |
+| `indicator_values.definition_id` 是 `BIGINT NOT NULL` | schema | 非数值指标无 definition 时无法落库 | 见 §9-Q6(P1-6 用"自动建 definition"绕开,不改约束) |
+
+### 8.6 【登记】候选:项目级 UI 文案规范(PM 提议,架构认可,本轮不建)
+
+> PM 提议:本轮沉淀的文案原则「**不编造、不暗示不存在的数据**」不只属于本需求,**可以进项目文案规范**。
+> 已核实:项目**目前没有 UI 文案规范**(仅有 `docs/参考资料/脱敏交付/微信文案.md`,是营销稿不是 UI 文案)。**本轮不新建,登记为候选。**
+
+**沉淀的判定式(不是口号,是三个可逐条回答的问题)**:
+
+文案写完后逐条自查,**任一条答"否"则改写**:
+
+| # | 自查问句 | 本轮抓到的实例 |
+|---|---|---|
+| 1 | **这句话声称的东西,界面上此刻能看到吗?** | ❌「指标历史已同步」—— 多数场景是用户第 1 份报告,历史根本不存在 |
+| 2 | **这句话有没有把"数据缺失"包装成"情况正常"?** | ❌「暂无趋势数据」—— 用户有 5 份报告时,这是**声称数据不存在** |
+| 3 | **这句话有没有把"不确定"包装成"确定"?** | ❌「首次记录」—— 实际是历史尚未整理,属**错误断言** |
+
+**为什么建议做成判定式而不是原则条文**:原则条文("要诚实")无法评审,评审者只能凭感觉;
+判定式("这句话声称的东西界面上看得到吗?")**任何人都可以在 3 秒内回答**,能进 code review 清单。
+
+> **架构侧建议落地位置**:若后续建规范,建议附在 `AGENTS.md` 的开发约定章节(当前无文案相关条目),
+> 并在 PR 模板里加一条勾选。**本轮不做,等有第二个需求用到它时再建 —— 避免为单个需求建规范而被绕过。**
+
+> 对应 PRD §8 的 D1–D11 中属于**技术决策**的部分。产品侧决策(D1/D2/D5/D8/D9)已在 PRD 给出建议,此处只列需要技术判断或会影响技术方案的点。
+
+### ⚠️ 9.0 最高优先级:Q15 选项字母撞车 —— 拍板依据可能是旧稿
+
+**PM 与架构共同请求:落地前必须先核对拍板依据。**
+
+PM 指出其 PRD 中 D12 的选项标签**发生过变更**:
+
+| PM 的 PRD 版本 | 标签 B 的含义 | 标签 C 的含义 |
+|---|---|---|
+| **第 2 轮(旧稿)** | **先时间轴页** | — |
+| **第 3 轮(定稿)** | **先对照** | **先时间轴页(标注「已撤回」)** |
+
+**矛盾点**:主理人拍板原话是「**我采纳 PM 的 B 方案**」,但随后描述的落地是「**第一段 = 时间轴页**」。
+
+按 PM **定稿**的标签,B = 先对照 —— 那么"采纳 B"应落地为**先对照**,与主理人描述的实际内容**自相矛盾**。
+唯一能同时解释这两句话的情况是:**拍板时参照的是旧稿标签(旧 B = 先时间轴页)**。
+
+**三种可能,需主理人确认是哪一种**:
+
+| 可能 | 性质 | 处置 |
+|---|---|---|
+| ① 看到了改判,仍选先时间轴页 | **有效决策** | 按先时间轴页执行,PM 与我均完全接受,**本条永久结案** |
+| ② 未看到改判,按旧稿标签拍的 | **基于 PM 自己已否掉的论证** | 建议补确认一次 |
+| ③ 看到了定稿,但新旧标签读串 | 需澄清 | 建议补确认一次 |
+
+#### 各方立场沿革(**按 PM 建议如实记录,防止后人误读**)
+
+| 方 | 立场沿革 |
+|---|---|
+| **PM** | 第 2 轮主张**先时间轴页**(理由是"对照会谎称首次")→ **第 3 轮自行撤回该主张**,改为**先对照**。撤回理由:推 B 的原始风险已被其本人提出的 Q16/B2 化解,"理由没了就不该继续用它排序" |
+| **架构师** | **始终主张先对照**。三条理由:残缺代价不对称 / 数据依赖同源 / P0-1 才是 G1 载体。**曾表示"接受主理人的顺序决策",这是在"决策已下"前提下接受执行,不是改变主张** |
+| **主理人** | 拍板「先时间轴页」,但**选项字母与 PM 定稿标签撞车**,依据存疑 |
+
+**当前联合立场**:
+
+> **产品与架构师一致建议 B(先对照);并一致声明:不为此翻案,服从主理人在知情前提下的任何裁定。**
+
+#### 文档自查:我的文档曾存在与 PM 相同的缺陷
+
+PM 自查发现其 PRD 把存疑结论写成「✅ 已拍板 = C」,存在"工程师直接读 PRD 就会按错误顺序开工"的风险。
+**我的 §1.2.1 有完全相同的缺陷**:标题为「主理人已拍板」、内容直接写死"第一段 = 时间轴页"。
+**现已修正**:标题改为「⏸️ 待裁定」+ 顶部红标指向本节存疑、**两种情形并列**、并明示"本节不得作为 T04 开工依据"。
+**教训**:文档中的状态标签("已拍板"/"已确认")必须与存疑项显式关联,孤立的状态标签会把存疑结论伪装成既定事实。
+
+> **技术侧准备完毕**:两种顺序共用同一套组件与文案契约(§1.2.2),裁定后 T04 改动量很小。
+> **可立即开工**:T01 / T02 / T03 全部不受阻塞(横幅组件化已完成,见 §1.2.2)。**唯一被阻塞的是 T04 的两段文件划分。**
+
+---
+
+| # | 问题 | 选项 | 架构建议 | 影响 |
+|---|---|---|---|---|
+| **Q1** | **`value_type` 是否新增列到 `indicator_values`?** | A 不新增,join `indicator_definitions.value_type`<br>B 新增冗余列 | **A**。本轮所有读取点都要 join definition 拿 `refRange`/`goodDirection`,顺手就能拿到,冗余列只会带来同步负担 | 决定 §3.1 的 DDL;A 少一列 |
+| **Q2** | **`ref_range_snapshot` 是否回填存量?** | A 不回填(读时 `snapshot ?? def.refRange`)<br>B 回填 | **A**。历史参考范围可能已变更,回填会造成"假精确" | 影响 §8.1 回填方案 |
+| **Q3** | **体脂秤改造:新增 `body_fat_scale_records` 表 vs 复用现有?** | A 新增轻量记录表<br>B 用合成 `sourceId`(如 `memberId*10^8+yyyyMMdd`)<br>C 不动 | **A**。B 方案的合成 ID 会让 `source_id` 失去可追溯性,且同一天测两次仍冲突。A 方案还能顺便承载 `image_url`/`measured_at` | 决定迁移 423 是否新增表(P1-7) |
+| **Q4** | **发现体脂秤当前是"第二次测量整体报错"而非"覆盖"**(PRD S12 描述有偏差) | — | **已在 T05 一并修**(`insert`→`upsert` + per-metric try/catch) | 仅告知,无需决策 |
+| **Q5** | **「报告期次」是否引入物理表?** | A 不引入(三元组)<br>B 引入 `report_periods` | **A**(§3.5 有完整论证)。B 要改所有读取端 + 前端,投入产出比低 | 决定 §3.5 与 `/api/indicator/periods` 的实现方式 |
+| **Q6** | **非数值指标(P1-6)如何进时间轴?** | A mapping 未命中时**自动创建** `indicator_definitions`(`status=0, valueType='categorical'`)后写入<br>B 改 `indicator_values.definition_id` 为可空,写 `definition_id=NULL + raw_name`<br>C 不做 | **A**。B 要改 `NOT NULL` 约束 + 唯一键语义(`(source_type, source_id, NULL)` 在 MySQL 中不触发唯一冲突,会导致重复行),风险高。A 方案还能顺带沉淀指标字典 | 决定 P1-6 的落地路径(对应 D11) |
+| **Q7** | **`date_source` 存量回填为 `unknown` 时,UI 是否加 `*`?** | A 不加(等价于旧行为)<br>B 加 | **A**。存量数据无法判断真实来源,加 `*` 会让几乎所有历史日期都带星号,反而淹没真正需要提示的新数据 | 影响 §8.1 |
+| **Q8** | **新接口命名 `/api/health/report/indicators` 是否可接受?**(与现有 `/api/health/indicators` 仅差 `/report`) | A 可接受<br>B 改为 `/api/health/report/indicator/compare-previous`<br>C 改造老接口 `/api/health/indicator/list` | **A**。语义清晰(`/api/health/report/*` 是报告子域)。但**需团队确认不会与 `/api/health/indicators` 混淆**;若担心,选 B | 决定 §4.1 的路径 |
+| **Q9** | **`components/TrendChart.vue` 与 `pages/health/components/TrendChart.vue` 两份副本如何处理?** | A 只改 `pages/health/` 那份,另一份登记技术债<br>B 本轮就合并 | **A**。本轮只改被引用的那份(已核实 `indicator-curve.vue:75` 引的是 `pages/health/` 那份),另一份下轮清理 | 决定 T03 的改动范围 |
+| **Q10** | **「基本持平」阈值 2%(D5)是否做成可配置项?** | A 硬编码常量<br>B 落 `cfc_config` 表 / 环境变量 | **A(先硬编码)**。项目现状没有统一配置读取约定,为一个阈值引入配置机制不划算。若运营反馈需要调整,再抽为 `IndicatorChangeService` 的 `@Value` | 决定 §5.5 的 `FLAT_THRESHOLD_PCT` |
+| **Q11** | **是否需要 feature flag?** | A 不需要(回滚靠代码 revert)<br>B 加一个常量开关 | **A**。项目无开关框架,本轮改动都是"加字段 + 新页面",代码级回滚足够(§8.4)。若要求灰度发布,再评估 B | 影响 §8.4 |
+| **Q12** | **D4 sparkline 的实现约束(技术新增)** | A canvas 实现<br>B **view 点阵实现** | **B(强制)**。小程序同屏 40 个 canvas 原生组件会掉帧/白屏。这是技术硬约束,与"默认开/关"的产品决策正交 | 影响 T03 `SparkLine.vue` 的实现方式 |
+| **Q13** | **D6 单位跨期不一致是否做白名单换算?**(如 `mg/dL ↔ mmol/L`) | A 不做(PRD 建议)<br>B 做医学公认换算白名单 | **A**(同意 PRD)。但**建议下轮在指标字典里加"是否允许换算 + 换算系数"字段**,由医学/运营维护,本轮不做 | 确认即可 |
+| **Q14** | **`change-ranking` 的 `magnitude` 排序从 `\|Δ\|` 改为 `\|Δ%\|`** 是否可接受? | A 接受(跨单位可比)<br>B 保持现状 | **A**。现状按绝对 Δ 排序在科学上是错的(mmol/L 与 μmol/L 的 Δ 放一起比大小无意义)。`gut-flora-detail` 显示的 6 条顺序会变化,属预期修正 | **需业务方确认视觉变更可接受** |
+| **Q15** | **上线顺序(两段内部谁先)** | A 先时间轴页(P0-4)<br>B **先对照(P0-1)** | 🔴 **存在选项字母撞车,必须先核对再执行**。见下方专项说明。共同前提(残缺横幅组件化 + 四条件文案)已落地(§1.2.2),两种顺序技术上都已可行 | **影响 T04 两段文件划分** |
+| **Q21**(PM 提出,已采纳) | sparkline 视觉约束 | — | ✅ **已采纳**:末点 6rpx 主色实心 + 其余 4rpx 次级灰(无终点标识会读反走向);n>8 等距抽样时**首点与末点强制保留**(否则最新值被抽掉)。**并已按 PM 要求从 T04 挪至 T05**,不阻塞 P0 | 已落进 §6.2 + T05 |
+| **Q22**(PM 提出,已采纳) | 首周上线阀门 | A 按 `reportType` 限定 gut_flora(我的原案,**已撤回**)<br>B **D1 开关首周默认关** | ✅ **采纳 B 并撤回我的 A**。PM 指出菌群是主力报告类型、限定它等于把风险放在流量最大处,与保守目的相反——这个反驳成立 | 已落进 §1.2.3 |
+| **Q23**(PM 提出,已采纳) | 绑定成员后 toast 提示 | — | ✅ **采纳,但排期改为与 P0-1 同批**(PM 原提 P1)。理由:P0-1 之前报告详情页无任何可见"历史" UI,此时弹「指标历史已同步」用户看不到新东西反而更困惑。**且必须判 `indicatorsSynced > 0` 才弹**,否则是虚假告知 | 已落进 §3.6 |
+| **Q24**(PM 提出,已采纳) | FLOOR 护栏是否连 Δ 绝对值一起屏蔽? | A 一起屏蔽<br>B **只管百分比与排序** | ✅ **采纳 B**。PM 指出的实现风险很实在:极易被实现成"基数小就整个变化都不显示",用户连涨了还是跌了都看不到,**比排序错更糟**。已作为契约显式写死 | 已落进 §4.3 |
+| **Q16**(PM 提出,已采纳) | 报告详情页 `previous == null` 时如何区分"真首次"与"数据没回填"? | — | **已采纳**:加 `olderReportCount` 字段,四档形态拆为 A / B1 首次记录 / B2 数据整理中 / C 跨期跳比(§4.1)。**只 COUNT 不跨报告匹配指标名**(指标名是用户可编辑自由文本,匹配错会产生错误 Δ,医疗语境下比"整理中"危险) | 已落进 §4.1 契约 |
+| **Q17**(PM 提出,已确认) | 自动创建的 `status=0` definition 会污染用户可见列表吗? | — | **会,且是我的设计漏洞**(`IndicatorService.listDefinitions:26` 在 `status==null` 时不过滤;`changeRanking`/`quickLookup`/`trend` 也无 status 过滤,只有 `getLatestIndicatorsByDomain:2149` 有)。**已补为 §5.5.1 强制要求**,列为 Q6 的必要前置 | 已落进 §5.5.1 + T02 |
+| **Q18**(新发现) | **`bindReportToMember` 不重跑 pipeline → 事后绑成员的报告永远没有时序数据** | A 修<br>B 只靠 backfill 兜 | **A 修**(§3.6 G3)。用户上传时不选成员 → pipeline 因 `subjectId == null` 跳过 → 事后绑定只改 `subject_id`,时序永远补不上。修法是在 `bindReportToMember` 尾部补 `processReport`,成本 10 行。**B 也能兜住但要等回填跑完** | 已落进 T01 |
+| **Q19**(PM 提出 → 已改良) | `\|Δ%\|` 排序的微小基数护栏,FLOOR 怎么定? | A `refLow × 10%`<br>B **`refHigh × 1%`(改良)** | **B**。A 在 `refLow=0` 时(绝大多数参考范围都是 `0.00–x`)护栏恒为 0 彻底失效。B 以满量程为基准恒为正数。无参考范围时 fallback 绝对值 `0.1` | 已落进 §4.3 + §5.5 |
+| **Q20**(PM 提出,已采纳) | 存量回填时是否重跑 `ReportDateExtractor`? | A 不重跑,一律 `unknown`<br>B **重跑** | **B**。回填时本就能拿到 `payload_json`,复用已有工具类即可。**收益是整条历史时间轴的可信度 + 顺带修掉历史报告日期失真**。判定"疑似污染"的精确条件是 `report_date ≥ DATE(created_at)`(真实采样日必早于上传日,而 `new Date()` 与 `created_at` 同天),天然幂等 | 已落进 §4.6 |
+
+---
+
+## 10. 代码事实核验清单
+
+> 本文所有行号引用均基于 2026-10-25 工作区实际读取,逐条列出以便工程师复核。
+
+| # | 事实 | 位置 | 核验结果 |
+|---|---|---|---|
+| 1 | `indicator_values` UNIQUE `(source_type, source_id, definition_id)` + `idx_subject_def_date` | `schema.sql:2581-2599`;`DatabaseInitializer:12307-12346`(迁移 357/358) | ✅ |
+| 2 | `upsertMapping` 用 `ON DUPLICATE KEY UPDATE` | `mapper/IndicatorValueMapper.java:12-22` | ✅ |
+| 3 | `member_indicator_current` 刷新逻辑 `LIMIT 2` | `HealthReportService.java:2280-2363`(2284-2290 行) | ✅ |
+| 4 | `HealthIndicator.collectionDate` 标 `@TableField(exist=false)` 但物理列存在 | `entity/HealthIndicator.java:50-52`;`DatabaseInitializer:7651-7652` + `12405-12409` | ✅ |
+| 5 | `createReport()` 内部不调 `NormalizeIndicatorPipeline` | `HealthReportService.java:128-204`;pipeline 只在 `ReportCollectService:728` 与编辑后调用 | ✅ |
+| 6 | confirm 链路只调 `createReport()` + `refreshMemberIndicatorCurrent()` | `HealthReportController.java:1434-1457` | ✅ |
+| 7 | `NormalizeIndicatorPipeline` 未命中 mapping 整条丢弃 | `NormalizeIndicatorPipeline.java:59-61`(indicators)、`77-79`(gutFlora) | ✅ |
+| 8 | 体脂秤 `sourceType=body_fat_scale`、`sourceId=memberId`、**用 `insert`** | `BodyFatScaleService.java:253-264` | ✅ **且比 PRD 描述更严重:第二次测量整体报错,非"覆盖"** |
+| 9 | `trend()` 按 report_date 升序返回 points | `IndicatorTrendService.java:49-91` | ✅ |
+| 10 | `changeRanking()` 不足 2 期 skip;`skipGaps` 只在 adjacent 分支计算 | `IndicatorTrendService.java:127`、`137-151` | ✅ |
+| 11 | `trendLabel()` 的 `in_range_is_better` 回落中性 | `IndicatorTrendService.java:439-442` | ✅ |
+| 12 | `quickLookup()` 排序把 `reportDate==null` 当最新 | `IndicatorTrendService.java:298-301` | ✅ **确认为 bug** |
+| 13 | `getLatestIndicatorsByDomain` 用 `orderByDesc(recordedAt)` + `LIMIT 1` | `HealthReportService.java:2157-2164` | ✅ |
+| 14 | 时间口径污染:三处 `new Date()` fallback | `ReportCollectService.java:768-770`;`HealthReportController:2390/2393/2396` | ✅ |
+| 15 | `ReportDateExtractor` 政策是"取不到置 NULL" | `service/ReportDateExtractor.java:41-104` | ✅ |
+| 16 | `indicator-curve.vue` `onLoad` 只解析 `definitionId` | `pages/health/indicator-curve.vue:117-123` | ✅ |
+| 17 | `getIndicatorSourceTypes` 在 api.js 未导出 | `utils/api.js:3435-3444` 只有 trend/change-ranking/quick-lookup/correlation-list 四条;`indicator-curve.vue:76` 在 import、`:156` 在调用 | ✅ **确认必抛 TypeError** |
+| 18 | `gut-flora-detail.vue` 调 change-ranking 传 `limit: 6` | `pages/health/gut-flora-detail.vue:1051-1053` | ✅ |
+| 19 | `body-detail` 点卡片跳报告列表 | `pages/body-detail/index.vue:578-581`(`goIndicatorDetail`) | ✅ |
+| 20 | `utils/indicator-change.js` 已存在,`in_range_is_better` 回落中性 | `utils/indicator-change.js:13-37`(26-31 行) | ✅ |
+| 21 | 小程序侧无 echarts/uCharts 依赖 | 全仓搜索(TrendChart 自绘 Canvas 2D) | ✅ |
+| 22 | `DatabaseInitializer` 当前最大迁移编号 421;`ensureColumn` / `columnExists` / `tableExists` 工具方法存在 | `DatabaseInitializer:5218-5260`;`grep 迁移[0-9]{3}` 最大为 421 | ✅ **新迁移从 422 开始** |
+| 23 | `Result<T>` = `{code:200\|500, message, data}` | `common/Result.java` | ✅ |
+| 24 | 接口统一 `@PostMapping`、响应统一 `Result<T>`、新增 Controller 前查 API_REFERENCE | `docs/superpowers/api/API_REFERENCE.md:1-40` | ✅ |
+| 25 | cfc-web `HealthIndicators.vue` 调 `/api/health/indicator/list` 不带 reportId | `cfc-web/src/views/nutritionist/HealthIndicators.vue:86-89`;后端 `HealthReportController:458-467` 强制 reportId | ✅ |
+| 26 | `TrendChart.vue` 存在两份副本且有差异 | `cfc-frontend/components/TrendChart.vue`(10912B) vs `cfc-frontend/pages/health/components/TrendChart.vue`(11040B);`indicator-curve.vue:75` 引后者 | ✅ |
+| 27 | payload 快照存在 `health_reports.payload_json` | `HealthReportService.savePayloadSnapshot():2033-2053` | ✅(存量回填的数据源) |
+| 28 | `indicator_definitions.value_type` / `good_direction` 已存在 | 迁移 356(`DatabaseInitializer:12295-12310`) | ✅ **故 Q1 建议不新增 `value_type` 列** |
+| **29** | **主流上传链路已在写 `indicator_values`** —— `report-upload.vue:321` → `submitAsyncCollect` → `collect():147` → `processReport():728` → 前端轮询 `completed` → **直接跳详情页,不经 confirm** | `report-upload.vue:321-370`、`ReportCollectService:135-147`+`728`、`report-upload.vue:405-447` | ✅ **推翻了"全部 confirm 报告都缺时序"的假设,Q15 据此修订** |
+| **30** | `confirmReportPreview` 只有 3 个调用方,均为支线:手动预览确认(`report-confirm.vue:854`)、菌群页预览态确认(`gut-flora-detail.vue:966`)、舌诊(`report-detail.vue:1055` → `confirmTongue`) | `utils/api.js:2489/2775` | ✅ 佐证第 29 条 |
+| **31** | **`bindReportToMember` 只 UPDATE `subject_id`,不重跑 pipeline** → 上传时不选成员的报告事后绑定也补不回时序 | `HealthReportService:1282-1302`;pipeline 早退条件 `NormalizeIndicatorPipeline:43` | ✅ **新缺口 G3,已入 T01** |
+| **32** | **`IndicatorService.listDefinitions` 在 `status==null` 时不加过滤** → `status=0` 的自动创建 definition 会冒进管理端字典列表 | `service/IndicatorService.java:22-30`(26 行) | ✅ **Q17 确认为真漏洞,已入 T02** |
+
+---
+
+## 11. 交付范围小结
+
+- **本文交付**:增量系统设计(目标/**D12 上线节奏**/总体方案/数据层/接口契约/服务层算法/前端增量/任务分解/兼容风险/待明确事项)+ 代码事实核验清单
+- **未含**:代码实现、测试用例、SQL 变更脚本的可执行版本(由工程师在 T01 按 §3 的 DDL 落地)
+- **未改任何现有文件**,仅新建本文档一份
+
+### 本轮固化结论一览
+
+| 决策 | 结论 | 落点 |
+|---|---|---|
+| **D12 上线节奏** | ✅ **已裁定(用户 2026-10-25 拍板)**:按 dryRun 三档规则(≥90% 一起上 / 60–90% 分拆 / <60% 延后)。分拆时 = **B 先对照**:第一段 = 报告详情对照 + 趋势入口 + bugfix + 降级;第二段 = 新时间轴页。**后端只发一次版** | §1.2.1、§7、§8.1、D12 切分图 |
+| **D1 对照默认开关** | ✅ **已裁定:默认开**(兼作灰度阀门;保守放量时改为首周默认关、用户可自开,只改一个默认值) | §1.2.1、§6.1 |
+| **D8 管理端档案页** | ✅ **已裁定:做最小版**(表格展示成员历史指标,无图表) | T05-5c、§6.7 |
+| **Q14 业务确认** | ✅ **已裁定:接受** `|Δ|` → `|Δ%|` 排序口径变更(菌群页 Top6 顺序会变),发布时告知业务方 | §4.3、§5.5 |
+| **`pending_backfill`** | `trendCode` 五值枚举之一,`olderReportCount` 驱动。三个接口全部落地 | §4.1/§4.2/§4.4、§6.3.1、§6.6 |
+| **Q17 脏数据隔离** | 三个落点缺一不可:后端读路径 / 字典列表默认 status=1 / 管理端 tab | §5.5.1、T02、T05-5c |
+| **Q14 排序护栏** | **不复用 D5 的 2%**。FLOOR = `refHigh × 1%`,两道闸顺序是先 FLOOR 后 D5;**护栏只管百分比和排序,Δ 绝对值照常显示** | §4.3、§5.5 |
+| **ReportDateExtractor 重跑** | 可行,且是**回填第 0 步**(必须先于时序回填,否则 `indicator_values` 会固化污染日期) | §4.6、§8.1 |
+
+### 仍待拍板
+
+| # | 问题 | 状态 |
+|---|---|---|
+| Q12 | sparkline 实现方式 | ✅ **已定**:禁用 canvas,改用 view 点阵(8 点 4rpx 点带);**已从 P0 挪回 P1-2**(真机视觉有争议,不拿 P0 上线时间赌);真机读不出走向则退「仅箭头」。点带须**末点视觉可辨 + 抽样保留末点** |
+| Q6 | 非数值指标策略 | ✅ **已定**:自动建 definition(`status=0`),**Q17 的 status 过滤是强制前置,两者必须同批** |
+| Q8 | 接口命名 | ✅ **已定 A**(`/api/health/report/indicators`) |
+| Q14 | `\|Δ\|` → `\|Δ%\|` 排序口径变更 | ✅ **已定**:用户已接受菌群页 Top6 顺序变化,发布时告知业务方 |
+| Q15 | 上线节奏 | ✅ **已定**:dryRun 三档规则 + 分拆时先对照(B)。**第 0 步测量是硬前置** |
+
+**下一步**:工程师按 §8.1 执行顺序(第 0 步日期修复 → 第 1 步回填 → 第 2 步验收 → 第 3 步分两段发版),并按 §7 的 T01 → T05 顺序实施。
+**第一优先级动作**:T02 的 backfill 端点做完后立即跑 `dryRun`,拿到 `eligible` / `dateFixable` 两个数字 —— 它们决定第二段何时放行。

File diff suppressed because it is too large
+ 625 - 0
docs/superpowers/specs/2026-10-25-indicator-timeline-display-prd.md


+ 298 - 0
docs/superpowers/specs/2026-10-27-nutrition-product-tagging-prd.md

@@ -0,0 +1,298 @@
+# T19 营养商品标注 · 需求澄清与增量 PRD
+
+| 项 | 内容 |
+|---|---|
+| 文档编号 | PRD-2026-10-27-01 |
+| 创建日期 | 2026-10-27 |
+| 作者 | 许清楚(产品经理) |
+| 状态 | **待廖总拍板 Q1–Q6 后方可派发** |
+| 涉及模块 | `cfc-backend`(Java 8 / Spring Boot 2.7.18 / MyBatis-Plus)、`cfc-web`(Vue2 + Element UI)、`cfc-frontend`(uni-app 微信小程序) |
+| 任务来源 | 任务池 **T19 营养商品标注**(`tools/xaf_agent_hub/tasks_backlog.json`,状态 dispatched → pending,远端无分支,**从未开工**) |
+| 交付性质 | **只做研究与方案,未改任何业务代码,未 commit/push** |
+
+---
+
+## 0. 摘要(先看这一段)
+
+核查结论推翻了 A、B 两份文档的**部分前提**,并发现一件两份都没说的事:
+
+| # | 结论 |
+|---|---|
+| 1 | **A、B 是两件事,应拆成 T19-A(营养成分)与 T19-B(年龄段+UL 剂量校验)**。数据模型、消费方、风险等级均不同 |
+| 2 | **B 不是"从零开发"**——`validateProductForAgeAndDosage()` 已实现,但**零调用方、无接口、依赖的两张表在 DatabaseInitializer 里没有迁移**(很可能在生产库根本不存在)。B 的真实工作量是**修复 + 接线 + 补参考数据**,远小于 plan 文档给人的印象 |
+| 3 | **B 现在是"假安全"**:UL 校验查不到营养素就 `continue` 跳过、无成分就 `return true`,**fail-open**。在没有 UL 参考数据的情况下把它接上线,会比不做更危险 |
+| 4 | **`nutrition_age_dosage` 表的 CREATE TABLE 是无效 SQL**(索引引用了不存在的 `product_id` 列),MySQL 会直接报错,**这张表从来没能被创建成功**;且该表**没有任何字段关联到产品** |
+| 5 | **A 的落点漏了一处关键链路**:Product 有 → NutritionProduct 的自动同步(`syncFromProduct`)已存在且被 3 处调用,但它**只同步价格/图片/描述**。若不给 Product 加字段就 UI 上改,营养侧拿不到;反之若加了不同步,双向不一致 |
+
+**建议派发顺序**:**T19-A 先做**(自包含、不依赖外部参考数据)→ **T19-B 拆出并降级为"先补地基"**(修表 + 补迁移 + **先明确 UL 参考数据由谁提供**,数据到位前不得接上线)。
+
+---
+
+## 1. 代码事实核查
+
+> 全部基于 2026-10-27 工作区实际读取,文件路径可直接复核。
+
+### 1.1 实体清单
+
+| 实体 | 表 | 文件 | 营养相关字段 | 年龄/剂量相关字段 |
+|---|---|---|---|---|
+| **Food** | `foods` | `entity/Food.java` | ✅ `calories / protein / fat / carbs / fiber / energyKj / starch / cholesterol` + `nutritionTags`(String) + `allergens` + `suitableFor` + `dietType` | ❌ 无 |
+| **Product**(商城商品) | `products` | `entity/Product.java` | ⚠️ **无热量/蛋白等营养成分**;但已有三类安全字段:`allergenTags`(逗号分隔)、`contraindicatedDiseases`(逗号分隔)、`drugInteractionFlags` | ❌ 无 |
+| **NutritionProduct**(营养产品) | `nutrition_product` | `entity/NutritionProduct.java` | ⚠️ 只有 `ingredients`(配料表文本) + `efficacyTags`(JSON) + `spec` + `netContent` + `form` | ⚠️ `suitableFor`(TEXT,适用人群自由文本) |
+| **NutritionIngredient** | `nutrition_ingredient` | `entity/NutritionIngredient.java` | `name / ingredientType / amountPerServing` 等 | ❌ |
+| **NutritionUlLimit** | `nutrition_ul_limit` | `entity/NutritionUlLimit.java` | `nutrientName / limitValue / unit` | ⚠️ `ageMinMonths / ageMaxMonths`(**VARCHAR(20),非 INT**) |
+| **NutritionAgeDosage** | `nutrition_age_dosage` | `entity/NutritionAgeDosage.java` | `ingredientName / amountPerServing / unit / dailyValue` | ⚠️ `ageMinMonths / ageMaxMonths`(INT)· **无 productId** |
+
+### 1.2 五条关键发现(决定方案走向)
+
+#### 发现 1:B 的功能其实已经写完了,但是**死代码**
+
+`service/NutritionProductService.java`
+
+- `validateProductForAgeAndDosage(productId, ageInMonths, ageMinMonths, ageMaxMonths, ...)`:**L357-368,已实现**
+- `validateDosageAgainstUlLimit(productId)`:**L373-420,已实现**,含"同一成分多来源剂量累加"
+
+**但全仓搜索 `validateProductForAgeAndDosage` 只有 1 处命中(就是它自己的定义)——零调用方、无 Controller 端点、无定时任务。**
+
+#### 发现 2:B 依赖的两张表大概率在生产库不存在
+
+| 表 | schema.sql | DatabaseInitializer 迁移 |
+|---|---|---|
+| `nutrition_product` | ✅ | ✅(迁移 228 / 372 都改过它,**表是真实的**) |
+| `nutrition_age_dosage` | ✅(但 SQL 无效,见发现 3) | **❌ 0 处** |
+| `nutrition_ul_limit` | ✅ | **❌ 0 处** |
+| `nutrition_ingredient` / `category` / `brand` / `platform_listing` / `product_review` | ✅ | **❌ 0 处** |
+
+且 `application.yml` 中**没有任何 `spring.sql.init` / `schema-locations` 配置** → `schema.sql` 不会自动执行,仅供人工建库参考。
+⇒ 除 `nutrition_product` 外,这批表在已部署的库里**很可能压根不存在**,现在查询会直接抛 `Table doesn't exist`。
+
+#### 发现 3:`nutrition_age_dosage` 的建表语句是错的(从未创建成功)
+
+`schema.sql` 中该表定义为 `id / age_min_months / age_max_months / ingredient_name / strain / amount_per_serving / unit / daily_value / purpose / create_time` —— **没有 `product_id` 列**,但末尾却写了:
+
+```sql
+INDEX idx_product (product_id)     -- ← 引用不存在的列
+```
+
+MySQL 会抛 `ERROR 1072: Key column 'product_id' doesn't exist in table`,**这张表从来没能建成**。
+更根本的问题:**该表无论建成与否都没有关联到产品的字段**,所以"某产品的年龄分段剂量"在当前模型下**无处存放**。
+
+#### 发现 4:UL 校验有两个会误导结果的缺陷
+
+```java
+// NutritionProductService.java:399-408
+List<NutritionUlLimit> ulLimits = ulLimitMapper.selectList(
+    new LambdaQueryWrapper<NutritionUlLimit>()
+        .eq(NutritionUlLimit::getNutrientName, entry.getKey()));  // ← 只按营养素名查
+if (ulLimits.isEmpty()) continue;                                  // ← 查不到就跳过(放行)
+NutritionUlLimit ulLimit = ulLimits.get(0);                        // ← 多行时取第 0 行
+```
+
+- **完全忽略年龄**:表结构里明明有 `age_min_months / age_max_months`(UL 本来就是按年龄分段的:儿童维生素 A 的 UL 远低于成人),但查询条件没有带年龄。多年龄段数据并存时 `get(0)` 取到哪行不确定 → **可能把婴儿的 UL 套到成人身上(误判超标),也可能反过来(漏判超标)**。这是**安全性缺陷**,不是优化项。
+- **fail-open**:查不到 UL 记录就 `continue`(放行)、产品无成分就 `return true`(放行)。在 UL 参考数据为空的现状下,该方法恒等于"全通过"。
+
+另:`validateProductForAgeAndDosage` L361 存在 **NPE 风险**——`ageMinMonths != null && ageMaxMonths == null` 时,`ageInMonths > ageMaxMonths` 会拆箱空指针。
+
+#### 发现 5:A 与 B 的联系**已经存在**,但 A 的描述漏掉了它
+
+- `Product.isRecommended = 1` 时触发 `NutritionProductService.syncFromProduct(product)`
+- 调用点:`ProductService.java:316 / 355 / 592`
+- 管理端 `cfc-web/src/views/admin/ProductEdit.vue:34` 有提示文案:「开启后自动同步商品信息至营养产品,小程序营养产品详情可跳转购买」
+
+⇒ **"商城商品"与"营养产品"不是同一个东西,但已有一条单向同步桥**(Product → NutritionProduct,靠 `localProductId` 反查)。
+
+**但同步范围极窄**——`syncFromProduct` 只搬运 `name / coverImage / images / description / price / memberPrice / status`,**不搬任何营养或安全字段**。
+
+⇒ **这是本轮最重要的实现约束**:无论是 A 加营养成分、还是 B 加年龄/UL,只要字段加在 `Product` 上,就**必须同步扩展 `syncFromProduct`**,否则营养侧永远拿不到;反之若只改 `NutritionProduct`,商城侧也不一致。
+
+### 1.3 前端现状
+
+| 位置 | 现状 |
+|---|---|
+| `cfc-web/src/views/admin/ProductEdit.vue` | **无任何营养成分/适用年龄/UL 表单项**(仅有 `isRecommended` 开关,第 34 行) |
+| `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue` | **零营养相关内容** |
+| `AdminFoodController.java:43-52` | Food 侧已透出 `calories/protein/fat/carbs/fiber/nutritionTags` ✅(A 对 Food 的描述属实) |
+
+**未找到**独立的 `danshop` / `dan` 服务目录 → **A 说的"商城商品"就在 `cfc-backend` 的 `products` 表内**。
+
+### 1.4 可复用的既有先例
+
+| 能力 | 位置 | 备注 |
+|---|---|---|
+| 正确的年龄分段表范式 | `DatabaseInitializer.java:3432+`,`health_norm_reference` 表 `age_min INT NOT NULL COMMENT '年龄下限(月)'` / `age_max INT` | **UL/年龄表应照此改:INT 而非 VARCHAR** |
+| 成员生日 | `entity/FamilyMember.java:39 birthday`,L156-160 `getAge()`(**按年算**) | B 需要**月龄**,缺 `(int) months` 工具方法 |
+| 推荐引擎 | `service/NutritionProductService.java` 的 `calcIngredientScore / calcPriceScore` 等 | B 的校验结果应接入推荐排序或拦截,而非孤立存在 |
+
+---
+
+## 2. A / B 关系判定
+
+### 判定:**(b) 拆成两个任务** —— 但需修正两份文档的工作量与前提
+
+| 选项 | 是否成立 | 理由 |
+|---|---|---|
+| (a) 同一需求的两面,合并做 | ❌ | 数据模型不同:`Food` 侧是**每 100g 营养成分**(连续数值),B 是**每产品每年龄段的规则**(离散区间)。消费方不同:A 给**学员看数值**、给饮食推荐做排序;B 是**推荐/下单前的安全闸门**。风险等级不同:A 错了是显示不准,B 错了是把超标剂量推荐给儿童。合并会做出一个两头不靠的东西 |
+| (b) 两件事,拆成 T19-A / T19-B | ✅ **采纳** | 见下 |
+| (c) 一份已过时,以另一份为准 | ❌ | **两份都不过时**。A 对 Food 侧的描述经代码核验属实(`AdminFoodController` 确实已透出 5 项营养值),Product 侧确实缺 → A 的前提成立。B 的两张表 + 两个实体 + 校验方法都已存在 → B 说的"用已有 NutritionProduct 实体"也成立。只是**两份都没说出"对方也部分存在"这件事** |
+
+### 拆分后的重新定级
+
+| 任务 | 原文档给人的印象 | 实际工作量(代码核验后) |
+|---|---|---|
+| **T19-A 营养成分**(Product 侧 + UI) | 加字段 + 加 UI | **确实是从 0 到 1**,但工作量被低估了两块:① 口径对齐 Food(见 Q2)② **必须扩展 `syncFromProduct`**(发现 5) |
+| **T19-B 年龄段 + UL 校验** | 从 0 实现一套年龄分类 + 剂量校验 | **核心逻辑已写完**。真实工作 = 修无效建表 SQL + 补 DatabaseInitializer 迁移 + **修 fail-open 与年龄缺失缺陷** + 接线到推荐链路 + **补 UL/月龄参考数据**。其中最后一项是**数据供给问题,不是开发问题** |
+
+---
+
+## 3. 产品目标
+
+### T19-A:让学员看得懂商品的营养构成
+
+> 商城商品现在只有图文详情,**没有任何营养信息**。学员(尤其在做饮食管理的人)想知道"这个东西多少大卡、蛋白质多少",现在完全看不到;后台运营也无处录入。
+
+| 目标 | 衡量 |
+|---|---|
+| A-1 商品能承载并展示标准化营养成分 | 商品详情页展示营养成分表(每份 / 每 100g 二选一,见 Q2) |
+| A-2 口径与已有 `Food` 侧一致 | 同一"蛋白质"字段在 Food 与 Product 上同单位同基准,可被饮食推荐统一排序 |
+| A-3 录入成本可控 | 运营在 `ProductEdit.vue` 一屏内完成录入 |
+
+### T19-B:让推荐与**建议**不越过安全线
+
+> 营养品(尤其是给儿童/老人服用的)超 UL 摄入有实际风险。系统目前**不存在任何剂量安全校验**(写了但没接线)。
+
+| 目标 | 衡量 |
+|---|---|
+| B-1 营养产品可按年龄段标记适用性与建议剂量 | 每张该产品有 ≥1 条 `(年龄段 → 建议量/上限)` 记录 |
+| B-2 推荐/展示链路能识别"该成员 + 该产品"是否超 UL | 校验函数被调用且返回结构化结果(不只是 boolean) |
+| B-3 **校验必须是"明确通过/明确不通过/数据不足无法判断"三态** | 不允许把"数据不足"静默当作"通过"(当前 fail-open 必须修) |
+
+> ⚠️ **合规**:CFC 是主动健康平台,非医疗机构。B 的产出只能表述为**"相对于公开参考资料的摄入量比对提示"**,**不得**表述为诊断、治疗建议或安全性保证。
+
+---
+
+## 4. 用户故事
+
+### T19-A
+
+| # | As a… | I want to… | so that… |
+|---|---|---|---|
+| A-US1 | 正在做饮食管理的学员 | 在商品详情页看到热量/蛋白质/脂肪/碳水/膳食纤维 | 能把它算进我今天的饮食计划 |
+| A-US2 | 后台运营 | 在商品编辑页录入营养成分 | 前台能展示、推荐能排序 |
+| A-US3 | 营养师/规划师(管理端) | 按营养学口径(如高蛋白、低脂)筛选商品 | 给学员做饮食建议时能快速选品 |
+| A-US4 | 饮食推荐引擎调用方 | 用营养成分对候选商品排序 | 推荐结果符合成员当前的营养侧重 |
+
+### T19-B
+
+| # | As a… | I want to… | so that… |
+|---|---|---|---|
+| B-US1 | 给孩子买营养品的家长 | 知道这个产品是否适合我家孩子的年龄段 | 不会按成人剂量给孩子吃 |
+| B-US2 | 同时服用多种营养品的学员 | 系统在多来源累加后提示是否超过上限 | 避免叠加摄入过量 |
+| B-US3 | 推荐链路 | 在返回推荐前先过一遍安全校验 | 不会把不适合的组合推给用户 |
+| B-US4 | 后台运营/审核人 | 看到"该产品因缺 UL 参考数据,暂无法校验"的明确提示 | 不会误以为系统已校验通过(当前 fail-open 会让所有人误判) |
+
+---
+
+## 5. 需求池
+
+> **P0 = 不做则该条产品线不成立;P1 = 应该做;P2 = 可以做。**
+> 每条含验收标准、受影响位置、依赖。
+
+### 5.1 T19-A(营养成分)
+
+| ID | 需求 | 验收标准 | 位置 | 依赖 |
+|---|---|---|---|---|
+| **A-P0-1** | **Product 增加营养成分字段** | 新增 8 列且口径与 `Food` 一致(见 §6 字段表);`DatabaseInitializer` 用 `ensureColumn` 幂等迁移并**同步 `schema.sql`**;迁移前用 `columnExists` 兜底 | `schema.sql`、`DatabaseInitializer`、`entity/Product.java` | **Q2**(基准口径) |
+| **A-P0-2** | **扩展 `syncFromProduct`** | `Product.isRecommended=1` 同步时,把营养成分一并写入 `nutrition_product`;已存在的营养产品行也要更新(当前 `else` 分支只更新价格/图片) | `NutritionProductService.syncFromProduct`(L约 450 附近) | A-P0-1 |
+| **A-P0-3** | **管理端录入表单** | `ProductEdit.vue` 新增「营养成分」分组:8 个数值输入 + 单位说明 + 「无营养成分数据(非食品类商品)」开关;留空时前台**不显示**该模块而非显示 0 | `cfc-web/src/views/admin/ProductEdit.vue` | A-P0-1 |
+| **A-P0-4** | **小程序商品详情页展示** | 商品详情新增「营养成分」卡片;**全部为空时整卡不渲染**;展示"每份 X g"或"每 100g"基准标注 | `cfc-frontend/pages/discover-detail/product-detail/product-detail.vue` | A-P0-1 |
+| **A-P1-1** | 营养成分数值校验 & 单位提示 | 录入负值/超大值给出明确错误;单位下拉限定 | 同上 + 后端校验 | — |
+| **A-P1-2** | 管理端按营养口径筛选商品 | 商品列表支持"高蛋白/低脂"等条件(需定义阈值,见 Q3) | `cfc-web` 商品列表 | **Q3** |
+| **A-P1-3** | 饮食推荐接入营养成分排序 | 推荐结果可按营养成分排序,与 Food 侧共用口径 | 推荐 Service | A-P0-1 |
+| **A-P2-1** | 与 `Food.nutritionTags` 打通 | 商品侧也支持营养标签 | — | Q2 |
+
+### 5.2 T19-B(年龄段 + UL 剂量校验)
+
+| ID | 需求 | 验收标准 | 位置 | 依赖 |
+|---|---|---|---|---|
+| **B-P0-1** | **修复 `nutrition_age_dosage` 建表 SQL** | ① 删除引用不存在列的 `INDEX idx_product (product_id)`;② **补 `product_id BIGINT` 列并建立索引**(否则"某产品的年龄分段剂量"无处存放——这是该表存在的唯一意义);③ 实体补 `productId` 字段 | `schema.sql`、`entity/NutritionAgeDosage.java` | 无 |
+| **B-P0-2** | **补 DatabaseInitializer 迁移** | `nutrition_age_dosage`、`nutrition_ul_limit`、`nutrition_ingredient`、`nutrition_category`、`nutrition_brand`、`nutrition_platform_listing`、`nutrition_product_review` 七张表均有幂等建表迁移;**先用 `tableExists` 判断**;迁移编号从当前最大值往后 | `DatabaseInitializer.runMigrations()` | B-P0-1 |
+| **B-P0-3** | **UL 表的年龄段改 INT** | `age_min_months / age_max_months` 由 `VARCHAR(20)` 改为 `INT`(对齐既有 `health_norm_reference` 范式);修正 `unit` 列的注释(现为"价格单位",实为剂量单位);修正误名索引 `idx_platform` | `schema.sql`、迁移、`entity/NutritionUlLimit.java` | **Q4** |
+| **B-P0-4** | **修 fail-open 与年龄缺失** | ① UL 查询**必须带年龄条件**(非 0 行时才比较),多命中按最严格取值而非 `get(0)`;② 返回**三态结果**(通过 / 不通过 / **数据不足无法判断**),禁止布尔吞掉第三态;③ 修 `ageMaxMonths == null` 的 NPE | `NutritionProductService.validateDosageAgainstUlLimit`(L399-417)、`validateProductForAgeAndDosage`(L357-368) | B-P0-3 |
+| **B-P0-5** | **成员月龄工具方法** | 提供 `birthday → 月龄` 的计算(现有 `getAge()` 只按年,不满足按月分段);生日缺失时返回"未知年龄"而非 0 | `entity/FamilyMember.java` 或工具类 | 无 |
+| **B-P1-1** | **接线:把校验接到推荐/详情链路** | 营养产品详情、推荐结果可返回安全性提示;当前零调用方状态必须结束 | `NutritionProductController` 等 | B-P0-4、**Q1**、**Q5** |
+| **B-P1-2** | 前端展示安全提示 | 产品详情/推荐卡片显示"适合 X 岁–Y 岁""多来源合计已达上限的 Z%""缺乏参考数据,暂无法校验"三种状态,文案需合规软化(§7) | 小程序 + 管理端 | B-P1-1、**Q6** |
+| **B-P2-1** | 多来源累加(跨产品) | 成员购物车/常用营养品范围内做跨产品累加校验 | — | Q1 |
+
+> ⚠️ **B 的上线前置(阻塞项)**:**B-P1-1 与 B-P1-2 必须在 UL 参考数据入库之后才允许上线**。参考数据为空时,按现在的 fail-open 逻辑会 100% 返回"通过",等于挂了一块"已通过安全校验"的牌子却没有校验——**这比不做更危险**。修完 B-P0-4 后第三态"数据不足无法判断"会暴露真相,但更好的做法是数据到位前整个入口不开启。
+
+---
+
+## 6. 涉及实体与字段
+
+### 6.1 T19-A:Product 新增字段(建议,待 Q2 定基准)
+
+| 字段 | 类型 | 说明 | 对齐 `Food` |
+|---|---|---|---|
+| `calories` | DECIMAL(10,2) | 能量 kcal | `Food.calories` |
+| `protein` | DECIMAL(10,2) | 蛋白质 g | `Food.protein` |
+| `fat` | DECIMAL(10,2) | 脂肪 g | `Food.fat` |
+| `carbs` | DECIMAL(10,2) | 碳水 g | `Food.carbs` |
+| `fiber` | DECIMAL(10,2) | 膳食纤维 g | `Food.fiber` |
+| `sodium` | DECIMAL(10,2) | 钠 mg | `Food` 无,需同步补(否则推荐口径不齐) |
+| `serving_size` | DECIMAL(10,2) | 每份量 g/ml | `Food` 无 |
+| `nutrition_basis` | VARCHAR(16) | **`per_100g` / `per_serving`**,`NULL` = 无数据 | `Food` 无,见 Q2 |
+
+> **务必加 `nutrition_basis`**:`Food` 侧所有数值隐性按 `per 100g`(单位注释里写了),但**没有字段记录这个基准**。若 Product 侧不明确标注基准,将来两表 JOIN 排序会把"每份 300mg 钠"和"每 100g 300mg 钠"当同一个数比 —— 这是**静默的错误排序**。
+
+### 6.2 T19-B:已有表修复 + 补字段
+
+| 表 | 处置 |
+|---|---|
+| `nutrition_age_dosage` | **加 `product_id BIGINT` 列 + 索引**(现无,导致该表无法表达"哪个产品的哪个年龄段");删除无效 `idx_product`;`daily_value` 建议改 DECIMAL(现 VARCHAR(50)) |
+| `nutrition_ul_limit` | `age_min_months / age_max_months` **VARCHAR(20) → INT**;`unit` 注释改正;`idx_platform` 改名 `idx_ingredient_type` |
+| `nutrition_product` | 依赖现有的 `suitable_for`(TEXT)、`efficacy_tags`,本轮建议**不改**;但见 §7 合规风险示警 |
+
+---
+
+## 7. 合规红线(必须守住)
+
+1. **禁止疗效宣称**。现有 `nutrition_product.efficacy_tags`("补钙/补锌/益生菌/维生素D")与 `nutrition_age_dosage.purpose`("功效说明")**已经存在合规风险**——普通食品/商品在中国法规下不得宣称保健功能。**本轮不建议扩容这两个字段**;已录入内容的合规性请业务方自查,必要时另立整改项。
+2. **B 的产出不是诊断也不是治疗建议**。措辞只能是"根据公开参考资料比对"级别的提示,配"仅供参考,不构成医学建议"。禁止出现"安全""可放心服用""不会过量"等**保证性**表述——一旦给出保证,法律责任就落到平台。
+3. **"数据不足"必须说出来**,不许静默当作通过(B-P0-4)。
+4. 小程序实现约束:禁可选链 `?.`、禁 CSS Grid、禁 `:key` 表达式、禁 `new Date(string)`。
+
+---
+
+## 8. 待确认问题(需廖总拍板)
+
+| # | 问题 | 选项 | 产品建议 |
+|---|---|---|---|
+| **Q1** | **T19-B 的真实业务目的是什么?**(这是根本问题) | ① 给推荐引擎做前置过滤(超标就不推荐)<br>② 给前台做提示(展示文案,不阻断购买)<br>③ 仅供后台运营内部参考 | **建议 ①+②**,先做 ①(内部过滤,风险最低),② 需先解决 UL 数据来源与免责话术。**必须先回答这条**,否则 B 的交互形态无法定 |
+| **Q2** | **营养成分的基准口径**:按每 100g 还是按每份? | ① 统一每 100g(与现有 Food 侧一致)<br>② 按每份(商品更符合实际,但 Food 侧要改)<br>③ 两者都存 | **③ 两者都存 + `nutrition_basis` 标注**。理由:`Food` 已是每 100g 且被推荐链路依赖,改它成本高;而商品用户实际看"每份"。只存一个必然出现错误比较 |
+| **Q3** | **UL / 月龄参考数据(如中国居民膳食营养素参考摄入量 DRIs)由谁提供?** | ① 廖总/专业方提供清单<br>② 开发自行查找录入<br>③ 暂不录入 | **①**。这是**医学营养专业数据**,不能由开发凭搜索引擎填。**这条直接决定 B 能否上线**——数据不到位,B 就是"假安全" |
+| **Q4** | **年龄分段粒度**按什么? | ① 月龄 0-12 / 12-36 / 36-144 / 144+(婴幼儿细分)<br>② 年段(成人/儿童/老人)<br>③ 由数据决定 | **①**。既有 `nutrition_age_dosage`、`health_norm_reference` 都已经是月龄设计;且 UL 的年龄差异主要发生在婴幼儿段 |
+| **Q5** | **B 的触发时机**:推荐时 / 详情页打开时 / 加购时 / 下单前? | — | **推荐时做过滤 + 详情页做展示**。加购/下单前拦截涉及交易链路改动,本轮不建议 |
+| **Q6** | **T19-A 与 T19-B 的派发顺序** | ① 先 A 后 B<br>② 先 B 后 A<br>③ 并行 | **① 先 A 后 B**。A 自包含、不依赖外部数据;B 卡在 Q3 的参考数据供给上,数据未定就开工一定会返工 |
+
+---
+
+## 9. 建议的更新后任务池登记
+
+| 新编号 | 名称 | 前置 | 阻塞于 |
+|---|---|---|---|
+| **T19-A** | 商城商品营养成分字段 + 录入 + 展示 | 无 | **Q2**(基准口径) |
+| **T19-B1** | 营养产品安全数据地基(修表 + 迁移 + 修 fail-open + 月龄工具) | 无 | 无,**可先行** |
+| **T19-B2** | UL/月龄参考数据入库 | **Q3**(数据由谁提供) | Q3 未答则不得开工 |
+| **T19-B3** | 年龄/UL 校验接线 + 前端提示 | T19-B1 + T19-B2 | **Q1 / Q5 / Q6** |
+
+> **给 plan 文档的修订建议**:`docs/superpowers/plans/2026-10-12-nutrition-product-tagging.md` 的 Global Constraints「Must use existing Product and NutritionProduct entities」**仍然正确且应当保留**——代码核验证实两个实体都存在。但需补一句:**这些实体的配套设施(表迁移、年龄字段类型、产品关联)是不完整的,本轮必须先补全地基**。
+
+---
+
+## 10. 交付范围小结
+
+- **本文交付**:需求冲突澄清 + 代码事实核查 + 增量 PRD(目标 / 故事 / 需求池 / 实体字段 / 合规 / 待拍板)
+- **未含**:代码实现、SQL 可执行脚本、接口字段级契约(待 Q1–Q6 拍板后由架构师出技术设计)
+- **未改任何业务代码文件**,未 commit / 未 push
+
+**下一步**:廖总拍板 Q1–Q6 → 架构师基于本文 §5/§6 出技术方案 → 工程师按 §9 的顺序实施。

Some files were not shown because too many files changed in this diff