Explorar o código

docs: 报告代传功能修复方案(T29,分三批 B1/B2/B3)

zxyj-web hai 2 semanas
pai
achega
164c9fbc55
Modificáronse 1 ficheiros con 164 adicións e 0 borrados
  1. 164 0
      docs/superpowers/plans/2026-10-25-report-proxy-fix-plan.md

+ 164 - 0
docs/superpowers/plans/2026-10-25-report-proxy-fix-plan.md

@@ -0,0 +1,164 @@
+# 后台「报告代传」功能修复方案(T29,分三批)
+
+- **背景**:`docs/报告代传功能核查报告.md`(已合入 cfclub)运行态实锤 5 项 P0 + 8 项中低危缺陷,代传主链路完全不可用。
+- **基线**:`origin/cfclub` 最新(派发时以 `git fetch origin cfclub` 为准)。
+- **派发方式**:分 **B1 / B2 / B3 三批**独立派发、独立分支、独立核验;B2 基于「B1 已合入的 cfclub」,B3 基于「B2 已合入的 cfclub」。
+- **迁移**:本任务**全部不需要新迁移**(`proxy_uploader_id` / `proxy_upload_note` 列已存在:schema.sql:1863-1864,实体 HealthReport.java:114/117)。
+
+---
+
+## 批次 B1(功能可用性 + 数据正确性)— 分支 `feature/proxy-fix-b1-core`
+
+### B1-1 🔴 打通 draftId:重构 `proxyParsePreview`(先决项,最高优先级)
+**现状**(`HealthReportController.java` `proxyParsePreview`,约 :2617):
+- `storeFile` → `callLangGraphParse`(**透传 LangGraph 原始返回**)→ 从 result 取 `draftId`(恒 null)→ `updateNote` 分支被跳过 → 返回 lgData。
+- 根因:LangGraph `/api/v1/report/parse` **不建草稿、不返回 draftId**;非代传链路是「Java 先建草稿 → 调 LangGraph → updatePayload → buildParseResultFromLg 组装」。
+
+**目标**:复用非代传链路模式(照抄同文件 `parseByDraftId` :1055-1102 与 `buildParseResultFromLg` :1125-1163):
+1. 校验角色 + `familyId` 非空(保留现有 `isProxyUploader`)。
+2. `storageService.storeFile(file, "health-reports")` 存 OSS。
+3. **先建草稿**:`healthReportDraftService.createDraft(userId, familyId, reportType, fileUrl, originalFilename, null)`(reportType 传参默认 `"auto"` 时落 `"gut_flora"`;签名见 `HealthReportDraftService.java:47-75`)。
+4. **建草稿后立即** `updateNote(draft.getId(), note)`(note 非空时),确保草稿层带备注。
+5. `storageService.getFilePath(fileUrl)` → 调 LangGraph(复用 `callLangGraphParse`)。
+6. 解析成功 → **调 `buildParseResultFromLg(lgData, draft, familyId)`** 组装返回体(自带 draftId/payload/matchedMemberId/extractedName/extractedGender/extractedAge/needBind/confidence/candidates)。
+7. 解析失败 → **调 `parseLocalFallback(absolutePath, draft, familyId, userId)`**(同文件 :1164,本地 Java 兜底,签名一致可直接复用)。
+8. 返回体补 `fileUrl` / `proxyUploaderId` / `proxyUploadNote`(保持前端既有字段契约:`ProxyReportUpload.vue:39-41` 读 `extractedName` / `extractedReportNumber` / `extractedReportDate` —— **注意**:`buildParseResultFromLg` 只 put `extractedName/Gender/Age`,没有 `extractedReportNumber` / `extractedReportDate`。**需要补**:从 lgData 的 overview(`report_number` / `report_date`)读出来一并 put 进 result,否则预览三行仍 '-')。
+
+**验收**:`proxy-parse` 返回体含 `draftId`(非 null)、`payload`、`extractedName/extractedReportNumber/extractedReportDate`、`matchedMemberId`/`needBind`;数据库草稿表新增 1 行 pending 且 `remark` 带 note;LangGraph 不可用(可模拟超时)时代传仍能本地解析入库。
+
+### B1-2 代传备注落库:`setProxyUploader` 增加 note 参数
+**现状**(`HealthReportService.java:2059-2066`):只写 `proxyUploaderId`,`proxy_upload_note` 无任何写入路径。
+**目标**:
+- 改签名 `setProxyUploader(Long reportId, Long proxyUploaderId, String proxyUploadNote)`,内部 `report.setProxyUploadNote(proxyUploadNote)`。
+- `HealthReportController.proxyConfirm`(约 :2696):在 `auditConfirm` 前先 `healthReportDraftService.getDraftById(draftId)` 取 `remark`,confirm 后调用时传入。
+- 全仓库搜 `setProxyUploader(` 调用点,一并改签名(**改签名必须同步所有调用点,禁止留下旧签名调用**)。
+**验收**:代传确认后 `health_reports.proxy_upload_note` = 代传时填的 note(实测查库确认,不再 NULL)。
+
+### B1-3 正式报告 remark:代传场景优先草稿 remark
+**现状**(`HealthReportService.buildReportFromPayload`,约 :1630 附近):`report.setRemark("募极生物肠道菌群检测报告")` 硬编码。
+**目标**:改为 `String remark = (draft.getRemark() != null && !draft.getRemark().isEmpty()) ? draft.getRemark() : "募极生物肠道菌群检测报告";`(非代传草稿 remark 为空 → 行为不变)。
+**验收**:代传草稿带 remark 时,正式报告 remark = 草稿 remark。
+
+### B1-4 两个代传页「报告详情」弹窗字段全空
+**现状**:后端 `getReportDetail` 返回嵌套 `{report:{...}, indicators, ...}`;前端 `ProxyReportUpload.vue:261` 与 `ProxyReport.vue` 都 `self.detailReport = res.data` 按扁平取字段 → 全 undefined。
+**目标**:两页都改为 `self.detailReport = res.data.report || res.data`(正确先例 `admin/HealthReportEdit.vue:269` 用 `detail.report || {}`)。
+**验收**:点「查看」弹窗显示报告ID/类型/编号/日期/总分/菌群分/肠型/肠道年龄/AI解读,不再全是 '-'。
+
+### B1-5 `proxyConfirm` 返回体带 AI 解读 + 失败状态
+**现状**:`auditConfirm` 返回仅 `{reportId, subjectId, status}`;`triggerAiAnalysis`(约 :2752)为 void,LangGraph analyze 失败被 catch 吞掉(log.warn),用户与主流程均无感知;实测 analyze 返回 500(部分可能是本地桩件环境,代码级缺陷「静默吞错」与运行环境无关,确定要修)。
+**目标**:
+- `triggerAiAnalysis` 改为**返回 `Map<String,Object> aiResult`**:成功含 `success=true, analysis, traceId`;配额不足含 `success=false, reason="quota", toast`;LangGraph 失败含 `success=false, reason="langgraph_failed", message`。
+- `proxyConfirm` 把该结果并入返回体:`result.put("aiAnalysis", analysis)`(前端 `res.data.aiAnalysis` 已有读取逻辑,`ProxyReportUpload.vue:216` / `ProxyReport.vue:286`)、`result.put("aiAnalysisStatus", ...)`、`result.put("aiAnalysisMessage", ...)`。
+- ⚠️ 本批**只修代码层(失败不再静默 + 结果回传)**;「analyze 500 的生产环境复测」属部署验证,不属本批代码范围,方案末尾如实标注。
+
+**验收**:代传确认响应体含 `aiAnalysis`(成功时)/ `aiAnalysisStatus` + `aiAnalysisMessage`(失败时);后端日志不再吞掉 analyze 异常。
+
+### B1-6 两个列表补字段(admin 标题/上传时间/代上传人;teacher 报告对象/代上传人)
+**现状**:
+- admin 列表 `listAllReportsForAdmin`(`HealthReportService.java:1174-1216` 附近):map 无 `title`/`createdAt`/`proxyUploaderName` → `ProxyReportUpload.vue:92`(`prop="title"`)、`:97`(`formatTime(row.createdAt)`)恒空。
+- teacher 列表走 `/report/list` 返回 `List<HealthReport>` 实体:实体无 `subjectName`/`proxyUploaderName` → `ProxyReport.vue:94`(`row.subjectName || row.userName`)、`:112`(`row.proxyUploaderName`)恒 '-'。
+
+**目标**(实现方式按项目既有惯例,先看同项目类似列表怎么补展示字段的,不要自创):
+- admin 列表:每行补 `title`(可用 `personName + reportType 标签` 拼或报告编号,前端有 `typeLabel`)、`createdAt`(报告创建时间)、`proxyUploaderName`(`proxy_uploader_id` join `users` 取姓名,无则 '-')。
+- teacher 列表:每行补 `subjectName`(`subjectId` → `family_members` 或 `users` 姓名)、`proxyUploaderName`。
+- **分页问题(/report/list 忽略 page 返回 List)不在本批处理**,留 B3-4,本批只补字段。
+
+**验收**:admin 端「标题」「上传时间」列有值;teacher 端「报告对象」「代上传人」列有真实姓名。
+
+---
+
+## 批次 B2(权限与越权 + 安全)— 分支 `feature/proxy-fix-b2-security`
+
+> ⚠️ **全局影响面**:B2-4(FamilyAccessInterceptor 空集拒绝)是**全局安全策略变更**,影响所有带 familyId 的路由(不止代传)。B2 会话必须:
+> 1. `grep -rn "familyId" cfc-backend/src/main/java/.../controller/ | grep -i mapping` 等穷举带 familyId 的路由清单;
+> 2. 对每条路由判断「请求带 familyId 且用户有效家庭集为空」时,改后会被 403 —— 是否有**正常业务**依赖这种组合(例如新注册无家庭用户、DAN 报告、A2 采集);
+> 3. 若发现正常路径依赖「空集放行」,**停下回报主理人裁决**,不要擅自给拦截器加白名单绕过。
+
+### B2-1 `/api/family/member/list` 支持 familyId + 行级隔离(P1-1)
+**现状**(`FamilyMembersController.java:62-81`):完全忽略请求体 `familyId`,只按 `@RequestAttribute("userId")` 返回调用者自己家庭成员;无家庭时 `ensureUserFamily(userId)` **兜底自动建空家庭**。teacher 选目标家庭后取成员被 FamilyAccessInterceptor 403 拦。
+**目标**:
+- `params.familyId` 非空时:按该 familyId 返回家庭成员,并校验调用者访问权——admin 任意;teacher/营养师/规划师复用 `AdminController.getFamilies`(约 :767-805)的行级隔离口径(`teacher_family_ids` / 绑定关系)。
+- **删除 `ensureUserFamily` 兜底建家庭**:无家庭/无可见成员 → 返回空列表,绝不静默建空家庭(排查 `listMembers` 全调用链,前端依赖空列表兜底的页面不受影响)。
+**验收**:teacher 传自己的绑定 familyId → 返回该家庭全部成员;传无关 familyId → 空列表/403,且**不新建任何家庭行**。
+
+### B2-2 `proxyParsePreview` 增加 familyId 绑定校验(P1-2)
+**现状**:只校验角色,不校验 familyId 与代传人绑定关系;实际兜底靠 FamilyAccessInterceptor(对空有效家庭集放行 → 越权缺口)。
+**目标**:在 B1 重构后的 `proxyParsePreview` 中,角色校验后、建草稿前,**增加显式绑定校验**(复用 `AdminController.getFamilies` 行级隔离口径;admin 跳过):`familyId` 不在调用者有效家庭集内 → `return Result.error("无权为该家庭代传报告")`。**不依赖拦截器兜底**(multipart body 不走 body 递归解析,见 B2-4 注)。
+**验收**:未绑定家庭的 teacher/营养师账号代传 → 明确错误提示,不再成功。
+
+### B2-3 `proxyConfirm` 校验草稿归属与可确认者(P1-2 补充)
+**现状**:`auditConfirm` 用前端传的 `subjectId` 直接匹配,不校验 subjectId 是否属于草稿 familyId;「代上传模式跳过草稿归属校验」注释即风险点。
+**目标**:
+- `auditConfirm(draftId, subjectId, auditorId)` 内(或在 proxyConfirm 调用前):校验 `draft.familyId` 与 `subjectId` 对应成员所属家庭一致(`family_members.family_id`),不一致 → 明确错误。
+- 草稿只能被「创建者本人」或「admin」确认;teacher/营养师只能确认自己创建的草稿。**注意与正常审核链路(admin 审核他人草稿)兼容**:admin 不受限。
+**验收**:篡改 subjectId 为其他家庭成员 → 确认失败带原因;他人草稿非 admin 确认 → 拒绝。
+
+### B2-4 `FamilyAccessInterceptor` 空有效家庭集拒绝(P2-7,全局安全策略)
+**现状**(`FamilyAccessInterceptor.java:60-63`):`effectiveFamilyIds` 为 null/空 → `return true` 放行。空集 = 未绑定任何家庭的账号 → 权限反而最宽松,是本次实锤越权的根因。
+**目标**:改为**空集也继续校验**:`effectiveFamilyIds == null || isEmpty` 时,若 `collectFamilyIds(request)` 非空 → 一律 403「无权访问该家庭数据」(enforcement 模式);`requestedFamilyIds` 为空则维持放行(不携带 familyId 的请求不受影响)。
+- 注意 multipart 代传请求:`collectFamilyIds` 的 body 分支依赖 `RequestBodyCachingFilter`(只缓存 application/json),multipart 表单的 familyId 走 `request.getParameter("familyId")`(已有该分支)—— B2 实现时给 `collectFamilyIds` 补注释说明,并验证 multipart 路径能取到 familyId。
+**验收**:按报告 §4.6 复现路径(`family_id` NULL + `teacher_family_ids` 空的新账号传 familyId=2009)→ 403;不带 familyId 的既有正常请求不受影响(回归抽查几条带 familyId 的既有路由:报告列表/成员列表/任务等)。
+
+### B2-5 `proxy-parse` / `proxy-confirm` 移入 `LOGIN_REQUIRED_PATHS`(P2-6)
+**现状**(`JwtInterceptor.java:80-81`):两接口不在 PUBLIC 也不在 LOGIN_REQUIRED;无 token 请求被 `setAnonymousUser` 放行到 Controller 再返业务 500「无权限」——前端不跳登录。
+**目标**:加入 `LOGIN_REQUIRED_PATHS`(该数组语义 = 无 token 直接 401)。
+**验收**:无 token 调 `proxy-parse` → HTTP 401。
+
+### B2-6 权限校验编排说明(P1-2/P2-7 关系收口)
+- 在 B1 重构后的 `proxyParsePreview` 里加注释块说明**三层校验编排**:① 角色(isProxyUploader)② familyId 绑定(B2-2 显式校验)③ 拦截器兜底(B2-4)——明确「哪层失败返回什么码」。
+- 复现测试账号注意:测试后**删除**,不留残留(照核查报告 §4.7 纪律)。
+
+---
+
+## 批次 B3(健壮性与体验)— 分支 `feature/proxy-fix-b3-robust`
+
+### B3-1 配额不足/解读失败的 toast 文案前端化(P2-1)
+- 前端两页已有 `analysisResult` 逻辑;按 B1-5 返回的 `aiAnalysisStatus` / `aiAnalysisMessage` 在确认成功后提示「AI 解读未完成:原因」而非静默。
+
+### B3-2 草稿过期降级(P2-2)
+- 后端:`getActiveDraftOrThrow` 抛错信息保持;前端确认失败时区分「草稿不存在或已过期」→ 提示重新上传,且支持**基于 `fileUrl` 重新解析**(复用原文件,不重新传 PDF):新增或复用接口按 fileUrl 重新 createDraft + 解析(本批实现时先看 `/report/upload` + `/report/parse` 两阶段链路能否复用;能复用则代传页改为「上传→预览→确认」三段式,不重建轮子)。
+
+### B3-3 LangGraph 失败本地兜底(P2-4)
+- B1-1 已把 `parseLocalFallback` 接进代传链路;本批验证该路径在 LangGraph 不可用时的真实表现,补齐错误提示文案(「解析服务暂不可用,已用本地解析」等)。
+
+### B3-4 teacher 端列表分页修复(P1-4 分页部分)
+- `/report/list` 只收 `size` 忽略 `page`,返回 List → 前端 `res.data.records` 判断失败、翻页重复。
+- 目标:支持 `page`/`size` 返回分页结构(对齐项目分页惯例),或 teacher 页改走 admin-list 同款分页接口;两者选一,前端同步改。
+- ⚠️ 该接口同时被小程序报告列表页使用——**改动必须兼容小程序端现有调用**(默认不传 page 时行为与现一致)。
+
+### B3-5 部署文档补环境依赖(P2-8)
+- `application.yml:134-138` 附近 `langgraph.base-url` 默认 localhost:9000、生产需 `LANGGRAPH_BASE_URL`;uploads 目录需与 LangGraph 容器共享 volume。
+- 在 `docs/` 部署/运维文档(找到现有部署文档,没有就追加到 AGENTS.md 或新建 `docs/deployment/` 小节)补强制检查项清单。
+
+### B3-6 `updateNote` 命名澄清(4.8)
+- `HealthReportDraftService.updateNote(draftId, note)` 实际写 `remark` 列(无 note 列)。
+- 目标:重命名为 `updateRemark(draftId, remark)` 并同步所有调用点;或保留方法名但加明确注释「写入 health_report_drafts.remark 列」。二选一,优先重命名(改动点少)。
+
+---
+
+## 验收与自检清单(每批通用)
+
+1. `mvn -pl cfc-backend -am compile` 通过(Java 8 + Spring Boot 2.7)。
+2. 前端改动页无语法错误;`cfc-web` 构建通过(本批若只改 .vue 且不影响构建,至少 `npm run build` 相关校验或跳过并在回报中说明)。
+3. 运行态验证:按批次验收口径逐条实测(测试库用 `192.168.16.251:3306`,测试数据用完即删,照核查报告 §4.7 纪律)。
+4. 每批独立 commit(信息含 `T29-Bn` 与改动摘要)并 `git push -u origin feature/proxy-fix-bN-*`。
+
+## 交付纪律(三批通用,违反=退回重做)
+
+1. 规格文档:本文件(`docs/superpowers/plans/2026-10-25-report-proxy-fix-plan.md`,在 origin/cfclub)。**开工第一步先读它**,按文档实现,不要自行发挥。
+2. 基线:`git fetch origin cfclub` → 基于 **B1: origin/cfclub / B2: 已含 B1 的 origin/cfclub / B3: 已含 B2 的 origin/cfclub** 新建对应分支,**不要合并主干 cfclub**。
+3. 只 `git add` 自己新增/修改的文件(**禁止 `git add .`**),提交前 `git diff --cached --stat` 自查。
+4. 先 `mvn -pl cfc-backend -am compile` 通过,再做前端构建。
+5. 完成后**自行** `git commit`(提交信息写明 T29-Bn 与改动摘要)并 `git push -u origin feature/proxy-fix-bN-*`——**无需等待主理人确认即可提交推送**。
+6. **不要自行合并 cfclub**(合入由主理人分批执行)。
+7. 若规格与代码实际不符(找不到对应文件/接口形态与描述明显不同),**停下并回报**,不要凭空实现。
+8. ⚠️ 本任务**不涉及**删数据/删行/迁移外键/改 UNIQUE 约束/新建表(无迁移)。若实现中发现必须做这些不可逆操作,先停下回报主理人确认。
+9. ⚠️ B2-4 为全局策略变更,若发现正常业务依赖旧「空集放行」行为,**停下回报**,不擅自白名单。
+
+## 回报内容要求(会话完成时)
+
+- 分支名 + commit 哈希(逐个 commit 列出)
+- 改动文件清单(按 B 批内条目号组织,如 B1-1 ~ B1-6)
+- 编译/构建结果(mvn compile 输出摘要)
+- 运行态验证逐条结论(每项 AC:通过/未验证/失败+原因)
+- 遗留问题与风险(含「analyze 500 需生产复测」的明确标注)