Преглед изворни кода

docs: 合并 报告代传功能核查报告 V3(origin/docs/report-proxy-verify)

静态核查 13 项缺陷分级 + 运行态验证 4.1-4.8(5 项 P0 全部实锤)+ 测试数据清理记录
+ 先决项/三批共 16 条修复建议。
zxyj-web пре 2 недеља
родитељ
комит
c6aafdc52a
1 измењених фајлова са 265 додато и 0 уклоњено
  1. 265 0
      docs/报告代传功能核查报告.md

+ 265 - 0
docs/报告代传功能核查报告.md

@@ -0,0 +1,265 @@
+# 后台「报告代传」功能核查报告
+
+- **核查对象**:CFC 后台代传报告功能(admin / teacher 双端)
+- **核查基线**:`origin/cfclub` = `0c70cf62b`(2026-10-25)
+- **核查方式**:主理人静态代码核查 + 独立 opencode 会话运行态验证
+- **核查日期**:2026-10-25
+- **总体结论**:❌ **当前状态完全不可用,不可交付** —— 运行态实测确认 `proxy-parse` 返回 200 却**不创建任何草稿、不返回 draftId**,**「上传→预览→确认入库」主链路跑不通**;叠加备注不落库、详情弹窗全空、AI 解读不写入、代传越权缺口共 **5 项 P0 缺陷**(全部已运行态实锤,含 SQL 级与日志级证据),另有 8 项中低危问题。
+
+---
+
+## 一、功能边界与链路结构
+
+### 1.1 入口
+| 端 | 页面 | 路由 | 菜单权限 |
+|---|---|---|---|
+| 管理端 | `cfc-web/src/views/admin/ProxyReportUpload.vue` | `/report-proxy-upload` | `health:reports`(`Layout.vue:253`) |
+| 老师端 | `cfc-web/src/views/teacher/ProxyReport.vue` | `/teacher-proxy-report` | 路由已注册(`router/index.js:233`) |
+
+### 1.2 接口(`cfc-backend` HealthReportController)
+```
+POST /api/health/report/proxy-parse     (multipart: file + familyId + subjectId + note)
+  └─ isProxyUploader 角色校验(admin / teacher / 营养师approved / 规划师approved)
+  └─ storageService.storeFile(file, "health-reports")   → OSS
+  └─ callLangGraphParse → POST {langgraph.base-url}/api/v1/report/parse
+  └─ 存草稿 health_report_drafts → 返回 draftId / fileUrl / lgData
+  └─ updateNote(draftId, note) → 写草稿 remark
+
+POST /api/health/report/proxy-confirm    (json: draftId + subjectId)
+  └─ healthReportService.auditConfirm(draftId, subjectId, userId)   @Transactional
+       └─ getActiveDraftOrThrow(status=pending 且未过期,TTL 72h)
+       └─ buildReportFromPayload → createReport → bindReportToMember → saveFoodSuitability
+       └─ markDraftConfirmed
+  └─ foodRecommendService.recalculateForUser(subjectId)
+  └─ healthReportService.setProxyUploader(reportId, userId)
+  └─ triggerAiAnalysis(reportId, subjectId, userId)
+       └─ consumeMemberQuota(proxyUploaderId, "REPORT_READING", false)
+       └─ POST {langgraph.base-url}/api/v1/analyze → setAiAnalysis
+```
+
+### 1.3 数据落点
+- 表 `health_reports`:`proxy_uploader_id`(BIGINT)、`proxy_upload_note`(TEXT)—— **迁移 180/181 已建,字段就位**
+- 表 `health_report_drafts`:`remark`(存代传备注)
+
+---
+
+## 二、缺陷清单(按严重度)
+
+### P0-1 代传备注永久丢失(实锤)
+- `HealthReportService.setProxyUploader`(:2059-2066)**只写 `proxyUploaderId`**,实体 `HealthReport.proxyUploadNote`(:117)与表列 `proxy_upload_note`(schema.sql:1863)虽已就位,但**无任何写入路径**
+- 备注只停留在草稿表 `health_report_drafts.remark`
+- **影响**:代传人填写的备注在报告正式入库后彻底消失,业务侧不可追溯
+
+### P0-2 正式报告 remark 硬编码,代传备注二次丢失(实锤)
+- `HealthReportService.buildReportFromPayload`(:1630):`report.setRemark("募极生物肠道菌群检测报告")` —— 硬编码常量,不读草稿 note
+- **影响**:即使修好 P0-1,也需同时把草稿 remark 映射进正式报告(或直接写入 proxy_upload_note)
+
+### P0-3 两个代传页的「报告详情」弹窗全部字段为空(实锤)
+- 后端 `getReportDetail`(:606-653)返回**嵌套**结构:`{report:{...}, indicators, gutFlora, diseaseRisks, blocks, payload}`
+- 前端 `ProxyReportUpload.vue:110-121` 与 `ProxyReport.vue:146-159` 按**扁平**字段取值:`detailReport.id` / `.reportType` / `.reportNumber` / `.overallScore` / `.gutType` / `.proxyUploaderName` / `.remark` / `.aiAnalysis` —— 全部 `undefined`
+- 正确参照:`admin/HealthReportEdit.vue:269` 用 `detail.report || {}`
+- **影响**:管理员与老师点「查看」,弹窗除空外全是 '-',AI 解读也看不到
+
+### P0-4 AI 解读结果卡片永不显示(实锤)
+- `proxyConfirm` 返回的是 `auditConfirm` 的 map,仅含 `reportId` / `subjectId` / `status` —— **不含 `aiAnalysis`**
+- 前端 `ProxyReportUpload.vue:216` 与 `ProxyReport.vue:286` 读 `res.data.aiAnalysis` → 恒 `undefined` → `analysisResult` 恒 `null`
+- **影响**:即便后端 `triggerAiAnalysis` 成功把解读写进 `health_reports.ai_analysis`,用户当场也看不到(需重新进详情,而详情又因 P0-3 空)
+
+### P1-1 「报告对象」下拉永远列不出目标家庭成员(运行态实锤,机制与静态判断不同)
+- `FamilyMembersController.listMembers`(:62-81)**完全忽略请求体 `familyId`**,只按 `@RequestAttribute("userId")` 返回**调用者自己家庭**的成员
+- `JwtInterceptor:152-171` 的 `familyId` 请求属性 = 登录用户自己的 `user.family_id`,与代传页面选的目标家庭无关
+- **运行态实测(teacher 账号)**:
+  - 传 `familyId=2009` 调 `/api/family/member/list` → **HTTP 403**(`FamilyAccessInterceptor` enforcement 模式拦截跨家庭)
+  - 不传 familyId 调该接口 → **只返回老师本人 1 条**(id 1032)
+- **影响**:老师选定「客户家庭」后取成员必被 403 拦;不传则下拉只有老师自己 —— **两种路径都选不到目标家庭成员,代传无法完成**
+- 副作用:无家庭账号会触发 `ensureUserFamily(userId)` **兜底自动建一个空家庭**
+
+### P1-2 代传越权:多数被拦截,但存在「空有效家庭集」绕过(运行态实锤)
+- 静态判断:`proxyParsePreview` 只校验**角色**(`isProxyUploader`),**不校验 `familyId` 与代传人的绑定关系**
+  - 对比:`AdminController.getFamilies`(:767-805)对 teacher/营养师按 `teacher_family_ids` 做了行级隔离
+- `proxyConfirm` → `auditConfirm` 用前端传的 `subjectId` 直接匹配,**不校验 subjectId 是否属于草稿的 familyId**
+- **运行态实测修正**(存在 `FamilyAccessInterceptor` enforcement 兜底):
+  - teacher(`family_id=0`,有效家庭集非空)传 `familyId=2009` → **403 被拦** ✅
+  - **teacher(`family_id` 为 NULL 且 `teacher_family_ids` 为空)传 `familyId=2009` → 越权成功** ❌
+- 根因:`FamilyAccessInterceptor:60-63` 在 `effectiveFamilyIds` 为空时**直接 return true 放行** —— 而空集合恰好对应「尚未绑定家庭」的新账号,这类账号权限反而最宽松
+- 另一绕过面:`/api/admin/**` 整体跳过该拦截器(:41 / :51-53)
+- **影响**:新建规划师/营养师账号即可为任意家庭代传并绑定任意成员,属真实可利用的越权缺口
+
+### P1-3 admin 端列表「标题」「上传时间」列恒空(实锤)
+- `HealthReportService.listAllReportsForAdmin`(:1174-1216)组装 map 时只有 id/reportType/各 score/reportDate/personName/fileUrl/gutAge/gutType/memberId/childName
+- **无 `title`、无 `createdAt`、无 `proxyUploaderName`**
+- 前端 `ProxyReportUpload.vue:92`(`prop="title"`)、`:97`(`formatTime(row.createdAt)`)→ 恒空
+
+### P1-4 teacher 端列表「报告对象」「代上传人」列恒 '-',且显示的不是自己代传的报告(实锤)
+- teacher 页调 `/report/list`,返回 `List<HealthReport>` 实体 —— 实体**无 `subjectName`、无 `userName`、无 `proxyUploaderName`** 字段(`HealthReport.java` 只有 `personName`)
+- 前端 `ProxyReport.vue:94`(`row.subjectName || row.userName`)、`:112`(`row.proxyUploaderName`)→ 恒 '-'
+- 且 `/report/list` 的 `familyId` 取自代传人**自己家庭** → 显示的根本不是自己代传过的报告
+- 后端 `listReports`(:316-355)**只接收 `size`、忽略 `page`**,返回 List 而非 Page → 前端 `res.data.records` 判断失败、`total` 走 `length`、翻页重复返回同一批 → **分页形同虚设**
+
+### P2-1 AI 解读配额不足时静默跳过,用户无感知
+- `triggerAiAnalysis`(:2752-2760):`consumeMemberQuota(...)` 不成功 → `log.info` + `return`,**主流程仍返回成功**
+- BASIC 口径为「报告 AI 解读 1 次/月」→ 第 2 次代传起解读必然缺失,且界面无任何提示
+
+### P2-2 草稿过期无降级路径
+- `getActiveDraftOrThrow`(:1562-1571):`status != pending` 或 `expireAt` 已过 → 返回 null → `auditConfirm` 抛「草稿不存在或已过期」
+- 前端只会提示「确认失败: 草稿不存在或已过期」,**用户必须重新上传整份 PDF**(TTL 72h)
+
+### P2-3 预览解析三行恒空(待运行态确认)
+- `proxyParsePreview`(:2616-2667)直接返回 LangGraph 的 `lgData` 原样,仅补 `fileUrl`/`proxyUploaderId`/`proxyUploadNote`
+- 而 `extractedName` / `extractedReportNumber` / `extractedReportDate` 只在**非代传**链路 `buildParseResultFromLg`(:1147-1150)里 put
+- 若 LangGraph 返回体不含这三个 key → `ProxyReportUpload.vue:39-41` 预览三行恒 '-'(运行态待确认)
+
+### P2-4 代传链路缺本地解析兜底
+- `callLangGraphParse`(:2727-2747)catch 任何异常只 `log.warn` 回退 null → Controller 返回「报告解析失败」
+- 对比非代传链路 `parsePreview` 有 `parseLocalFallback`(:1111-1116)本地 Java 解析兜底
+- **影响**:LangGraph 不可用时代传功能整体不可用,而非降级可用
+
+### P2-6 匿名请求鉴权层级错误(运行态实锤)
+- `/api/health/report/proxy-parse` 与 `/proxy-confirm` **既不在 `PUBLIC_PATHS` 也不在 `LOGIN_REQUIRED_PATHS`**
+- 无 token 请求 → `JwtInterceptor` 走 `setAnonymousUser` 放行 → Controller 层 `isProxyUploader` 返回 false → 返回**业务 `code: 500`「无权限」**
+- **影响**:错误码语义错误(应 401/403),前端拦截器不会触发登录跳转;用户看到「无权限」却不知是未登录
+- ✅ **OSS 刷写风险已排除**:实测确认角色校验在 `storageService.storeFile` **之前**,无凭据请求不会写入文件
+
+### P2-7 `FamilyAccessInterceptor` 空有效家庭集放行(越权缺口根因)
+- `FamilyAccessInterceptor:60-63`:`effectiveFamilyIds` 为空时 `return true` 直接放行
+- 空集合对应「尚未绑定任何家庭」的新规划师/营养师账号 → 实测该类账号可成功为无关家庭代传
+- 与 P1-2 是同一问题的两面
+
+### P2-8 环境依赖未文档化
+- `application.yml:134-138`:`langgraph.base-url` 默认 `http://localhost:9000`,生产需 `LANGGRAPH_BASE_URL` 环境变量
+- 同处 TODO 注释:uploads 目录需与 LangGraph 容器**共享 volume**,否则 `file_path` 不可访问
+- 生产部署若未满足,`proxy-parse` 必然解析失败
+
+---
+
+## 三、修复建议(优先级排序)
+
+### 0️⃣ 先决项:代传链路当前跑不通,必须先打通(根因已定位)
+> 运行态实测:`proxy-parse` 返回 `code: 200`,但**不创建草稿、不返回 `draftId`**,前端直接卡在「没有可确认的草稿」。
+
+**根因(已实测确认)**:LangGraph 的 `/api/v1/report/parse` **不建草稿、不返回 `draftId`**;非代传链路是「Java 侧先建草稿 → 调 LangGraph → `updatePayload` → `buildParseResultFromLg` 组装返回体」,而**代传链路把这两步都省了**,直接透传 LangGraph 原始返回。
+
+**修复方案**:把 `proxyParsePreview` 改成复用非代传链路的模式:
+1. 校验角色 + familyId 权限后,`healthReportDraftService.createDraft(...)` 先建草稿(reportType 由 `reportType` 参数或 LangGraph 判定)
+2. `storageService.storeFile` → `callLangGraphParse`
+3. 解析成功 → `buildPayloadFromLgData` + `updatePayload(draftId, payloadJson)`
+4. 用 `buildParseResultFromLg(lgData, draft, familyId)` 组装返回体(自带 `draftId` / `extractedName` / `extractedReportNumber` / `extractedReportDate` / `payload` / `matchedMemberId` / `needBind`)
+5. 解析失败 → 走 `parseLocalFallback`,或明确返回错误码 + 原因(不要静默 null)
+6. `updateNote(draftId, note)` 在建草稿后立即写入,确保草稿层就带上备注
+
+### 第一批(功能可用性 + 数据正确性,与先决项同批)
+1. **打通 draftId**(见上,先决项)→ 打通后立刻补上 `proxyParsePreview` 的 `extractedName` / `extractedReportNumber` / `extractedReportDate` / `payload`(对齐 `buildParseResultFromLg`),否则预览页 6 行仍全 '-'
+2. `setProxyUploader(reportId, proxyUploaderId, proxyUploadNote)` 增加 note 参数并写入 `proxy_upload_note`;`proxyConfirm` 从草稿 `remark` 取 note 传入
+3. `buildReportFromPayload` 的 remark 改为:代传场景优先取草稿 remark,缺省才用现有硬编码文案
+4. 两个代传页详情弹窗统一改为 `detail.report || {}` 取值(对齐 `HealthReportEdit.vue:269`)
+5. `proxyConfirm` 返回体补 `aiAnalysis`;并修复 `triggerAiAnalysis` 未写入的问题(先定位是配额不足静默 return,还是 LangGraph analyze 调用失败)
+6. 后端列表/详情补 `proxyUploaderName`(姓名字段),修复 admin/teacher 两端的「代上传人」列
+
+### 第二批(权限与越权 + 安全,建议同一次改动)
+7. `/api/family/member/list` 增加 `familyId` 支持:按传入 familyId 返回该家庭成员,并校验调用者(admin 任意 / 管家按 butler_id / teacher·营养师按绑定关系);无可访问家庭时**返回空列表而非 `ensureUserFamily` 建空家庭**
+8. `proxyParsePreview` 增加 familyId 绑定校验(复用 `AdminController.getFamilies` 的行级隔离口径),**不依赖 `FamilyAccessInterceptor`**(它对空有效家庭集放行,且 multipart body 不走递归解析)
+9. `proxyConfirm` 增加校验:草稿 familyId 与 subjectId 归属一致;草稿只能被其创建者或 admin 确认
+10. **把 `/api/health/report/proxy-parse`、`/api/health/report/proxy-confirm` 移入 `JwtInterceptor.LOGIN_REQUIRED_PATHS`**,让无凭据请求在拦截器层就 401,而不是放行到业务层再返业务 500(当前无凭据时返回 500「无权限」,前端不会跳登录)
+11. **修 `FamilyAccessInterceptor:60-63`**:`effectiveFamilyIds` 为空时应**拒绝**跨家庭请求,而非 `return true` 放行(这是本次实锤的越权缺口)
+12. `RequestBodyCachingFilter` 目前只缓存 `application/json`;若依赖 body 递归解析做权限校验,需明确 multipart 请求的 familyId 采集路径(`request.getParameter`)
+
+### 第三批(健壮性与体验)
+11. 配额不足 / AI 解读失败时,在 `proxyConfirm` 返回体中带回明确状态与提示文案(前端提示而非静默)
+12. 草稿过期时前端引导「重新上传」,或后端支持基于原文件 URL 重新解析(复用 `fileUrl`)
+13. 为 LangGraph 失败增加本地解析兜底(对齐非代传链路 `parseLocalFallback`)
+14. teacher 端列表改走 admin-list 同款分页接口,或后端 `/report/list` 支持 `page`;并在列表中补 `proxyUploaderName`
+15. 部署文档补 `LANGGRAPH_BASE_URL` 与 uploads 共享 volume 的强制检查项
+16. 重命名 `updateNote(draftId, note)` → `updateRemark`,或至少加注释说明实际写入 `remark` 列
+
+---
+
+## 四、运行态验证(实测环境:`/sc-data/cfc` + 测试库 `192.168.16.251:3306`)
+
+> 验证会话:`pub-iwintrue` / `ses_e8d7f729fffe9hAMH43JscFd4g`
+> 报告文件:`docs/report-proxy-verify-2026-10-25.md`(会话产出后 commit + push)
+
+### 4.1 🔴 P0-5 运行态实锤:代传功能无法完成入库(本次最高优先级)
+- 实测 `POST /api/health/report/proxy-parse` **返回 `code: 200`,但响应体中**:
+  - ❌ **无 `draftId`**
+  - ❌ 无 `extractedName` / `extractedReportNumber` / `extractedReportDate`
+  - ❌ 无 `payload` / `needBind`
+- ❌ **实测数据库:`proxy-parse` 完全没有创建任何草稿记录**(草稿表无新增行)——不是「建了但内容空」,而是**根本没建**
+  - 连带后果:`draftId == null` → `updateNote(draftId, note)` 分支被跳过 → **备注连草稿表都没进**
+- 前端连锁后果:`ProxyReportUpload.vue` 的 `previewResult` 无 `draftId` → 点「确认入库」时 `if (!this.previewResult || !this.previewResult.draftId)` 命中 → 直接 `warning('没有可确认的草稿')`
+- **结论**:运行态下**代传功能完全无法走完「上传 → 预览 → 确认入库」主链路**,且失败时用户只看到「没有可确认的草稿」这种无信息量的提示,无法定位原因
+- 同时印证 P2-3:预览页「被检测人 / 报告编号 / 报告日期 / 健康总分 / 菌群健康分 / 肠型」**6 行全部显示 '-'**
+
+### 4.1.1 根因已定位(P0-5 的确切成因)
+- **LangGraph 的 `/api/v1/report/parse` 返回体不含 `draftId`,也不负责建草稿**
+  - 实测 LangGraph 返回键为 `format` / `overview` / `disease_risks` / … 等,**无 `draftId`、无 `extractedName`、无 `payload`**
+- **非代传链路是「Java 先建草稿 → 再调 LangGraph → 用 `buildParseResultFromLg` 统一构造返回体」**
+- **代传链路把「建草稿 + 组装返回体」两件事都丢掉了**:直接调 LangGraph 再原样透传 → 既无草稿、无 draftId,也无 extracted* 字段
+- **修复方向明确**:代传链路应复用非代传的「先建草稿 → 调 LangGraph → `updatePayload` → `buildParseResultFromLg` 组装」模式,而不是透传 LangGraph 原始返回
+
+### 4.2 🔴 P0-1 运行态实锤:代传备注不落库
+- 实测 `proxy-confirm` 返回 `{reportId:36, subjectId:1018, status:"confirmed"}` 后查库:
+  | 字段 | 实测值 | 结论 |
+  |---|---|---|
+  | `proxy_uploader_id` | `1001` | ✅ 正常写入 |
+  | **`proxy_upload_note`** | **`NULL`** | ❌ **P0-1 确认**:代传备注永久丢失 |
+  | `ai_analysis` | `NULL` | ❌ AI 解读未写入(见 4.4) |
+
+### 4.3 🔴 P0-4 运行态实锤:AI 解读卡片永不显示
+- `proxy-confirm` 响应体**只有 `reportId` / `subjectId` / `status` 三个键,无 `aiAnalysis`**
+- 前端 `res.data.aiAnalysis` 恒 `undefined` → `analysisResult` 恒 `null` → 「AI 解读结果」卡片永不出现
+- 确认静态判断无误
+
+### 4.4 P0-4 加重:AI 解读本身也未写入
+- 报告中 `ai_analysis` 为 `NULL`,说明不仅是「没返回给前端」,而是**后端 `triggerAiAnalysis` 根本没写成功**
+- 运行态日志逐段定位(已排除前两项):
+  - ✅ **配额扣减成功**:`INSERT INTO basic_quota_usage (...)` 已执行 → 不是 P2-1 的配额静默跳过
+  - ✅ **食材推荐已执行**:`foodRecommendService.recalculateForUser(1018)` 实际调用(`FoodRecommendIndexMapper.delete(user_id=1018)` + 日志「报告20无食材推荐数据,已清除用户1018的推荐指数」)
+  - ❌ **LangGraph `/api/v1/analyze` 返回 500**:`graph.ainvoke` 抛 ValueError → `triggerAiAnalysis` 的 catch 把它吞成 `log.warn("AI解读触发失败: ...")`,**主流程照样返回成功**
+- ⚠️ 诚实标注:本次验证用的是最小化本地 LangGraph 服务,其 analyze 图依赖 LLM 配置,`ValueError` **部分可能是本地桩件环境所致**,不能 100% 归因为生产缺陷;但**「analyze 失败被静默吞掉、用户与主流程均无感知」这一代码缺陷是确定的**,与运行环境无关
+- 结论:AI 解读有**两层问题** —— ① 失败被静默(确定)② 失败本身(需生产环境复测)
+
+### 4.5 P2-6 匿名请求鉴权层级错误(安全,新发现;OSS 风险已排除)
+- 实测无 token 调 `proxy-parse` → 返回**业务 `code: 500`「无权限代上传报告」**,**不是 HTTP 401**
+- 根因:`/api/health/report/proxy-parse` **既不在 `PUBLIC_PATHS` 也不在 `LOGIN_REQUIRED_PATHS`**
+  - `JwtInterceptor` 对无 token 请求走 `setAnonymousUser`(`userId=-1` / `role=anonymous` / `familyId=null`)放行
+  - Controller 层 `isProxyUploader` 返回 false → 返回业务 500
+- ✅ **重要更正**:实测确认**角色校验发生在 `storageService.storeFile` 之前** → 无凭据请求**不会**写入 OSS,**未构成刷存储攻击面**
+  - 准确表述应为:**鉴权层级错误 + 错误码语义错误**(业务 500 而非 401/403),导致前端拦截器不触发登录跳转、用户只看到「无权限」却不知是未登录
+- 残余风险:`proxy-confirm` 同样未在 `LOGIN_REQUIRED_PATHS`,匿名调用可能触发 NPE / 堆栈信息泄露(待实测确认)
+
+### 4.6 运行态修正:代传越权的实际边界
+- 存在 `FamilyAccessInterceptor`(enforcement 模式,`family.access.mode` 默认即 `enforcement`)为代传接口提供了一道路由级兜底
+- 但该拦截器有两个明确缺口:
+  1. `effectiveFamilyIds` 为空时直接放行(:60-63)→ **新建未绑定家庭的规划师/营养师账号可越权代传任意家庭**(实测已复现)
+  2. `/api/admin/**` 整体跳过(:41 / :51-53)
+- 另:`RequestBodyCachingFilter` **只缓存 `application/json`** 请求体 → 代传的 **multipart** 表单请求,拦截器只能从 `request.getParameter("familyId")` 取值(multipart 表单参数在 Spring 参数解析后通常可取到,但 body 递归解析路径对 multipart 失效)
+
+### 4.7 测试数据清理记录(验证会话已全部清理,测试库无残留)
+本次验证产生的测试数据均已在验证结束后**主动删除**(含关联的 `health_indicators` / `health_gut_flora` / `health_disease_risks` / `report_food_suitability` / `report_payload_logs` 记录):
+- `health_reports` id=36、37(代传确认产生的报告)
+- `health_report_drafts` id=98、99(人工构造的 pending 草稿)
+- `users` id=81317(为复现越权而新建的 teacher 账号,`family_id` NULL、`teacher_family_ids` 空)
+- 测试库当前无本次验证的残留数据;复现可照正文各节重新构造
+
+### 4.8 命名澄清(避免误修)
+- `health_report_drafts` 表**没有 `note` 列**,用的是 `remark VARCHAR(500)`(schema.sql:1919-1942)
+- `HealthReportDraftService.updateNote`(:151-157)内部 set 的是 `getRemark()`,**不会抛 Unknown column**
+- ⚠️ 命名误导:`updateNote(draftId, note)` 参数名与列名不一致,极易误导后续开发;建议改名或加注释
+
+---
+
+## 五、附:核查基线与文件清单
+
+| 文件 | 关键行 |
+|---|---|
+| `cfc-backend/.../controller/HealthReportController.java` | 316 / 445 / 581 / 606 / 1111 / 1147 / 2358 / 2602-2785 |
+| `cfc-backend/.../service/HealthReportService.java` | 606 / 1174 / 1488 / 1562 / 1592 / 2059 / 2071 |
+| `cfc-backend/.../service/HealthReportDraftService.java` | 22(TTL 72h) / 151(updateNote) |
+| `cfc-backend/.../controller/family/FamilyMembersController.java` | 62-81 |
+| `cfc-backend/.../config/JwtInterceptor.java` | 80-83 / 152-171 / 211-217 |
+| `cfc-backend/.../controller/admin/AdminController.java` | 767-805 |
+| `cfc-backend/.../entity/HealthReport.java` | 113-120 |
+| `cfc-backend/src/main/resources/schema.sql` | 1862-1863 |
+| `cfc-web/src/views/admin/ProxyReportUpload.vue` | 39-41 / 92 / 97 / 110-121 / 216 / 240 |
+| `cfc-web/src/views/teacher/ProxyReport.vue` | 94 / 112 / 146-159 / 286 / 300-305 |
+| `cfc-web/src/api/admin.js` | 87 / 2079-2098 / 2109-2120 |