report-proxy-verify-2026-10-25.md 8.9 KB

报告代传功能运行态验证报告

  • 验证环境:/sc-data/cfc + 本地启动后端(127.0.0.1:9082)+ 最小化 LangGraph 桩件(127.0.0.1:9000)+ 测试库 zxyj@192.168.16.251:3306
  • 基线:origin/cfclub
  • 结论:代传功能当前完全不可用(P0),附带 4 项数据/安全缺陷,全部已运行态实锤。

一、P0-5(根因,最严重):proxy-parse 不创建草稿、不返回 draftId

实测 POST /api/health/report/proxy-parse(admin token + 真实 PDF)返回 code:200,响应体缺:draftId、extractedName、extractedReportNumber、extractedReportDate、payload、needBind。 实测数据库:proxy-parse 未创建任何草稿记录。 后果:前端 previewResult 无 draftId → 点「确认入库」命中 if (!this.previewResult || !this.previewResult.draftId) → 直接 warning「没有可确认的草稿」→ 主链路(上传→预览→确认入库)完全跑不通;预览页「被检测人/报告编号/报告日期/健康总分/菌群健康分/肠型」6 行全 '-'。 根因:LangGraph /api/v1/report/parse 不建草稿、不返 draftId;非代传链路是「Java 先建草稿 → 调 LangGraph → updatePayload → buildParseResultFromLg 组装返回体」,代传链路把这两步都省了,直接透传 LangGraph 原始返回。 连带:draftId == null → updateNote 分支被跳过 → 备注连草稿表都没进。 修复方向:proxyParsePreview 复用非代传链路模式。

二、P0-1 代传备注不落库(SQL 级证据)

用人工构造的 pending 草稿(draft 98,payload 691KB,remark=test note)+ subjectId 1018 调 proxy-confirm,返回 {reportId:36, subjectId:1018, status:"confirmed"}。 查 health_reports WHERE id=36:proxy_uploader_id=1001 ✅ / proxy_upload_note=NULL ❌ / ai_analysis=NULL ❌ / remark=「募极生物肠道菌群检测报告」(硬编码)。 后端日志 SQL:UPDATE health_reports SET ... remark=...募极生物肠道菌群检测报告... proxy_uploader_id=1001 ... —— UPDATE 语句中根本不包含 proxy_upload_note 字段,坐实 P0-1 与 P0-2。

三、P0-4 AI 解读两层问题

  1. proxy-confirm 响应只有 reportId/subjectId/status,无 aiAnalysis 键 → 前端读 res.data.aiAnalysis 恒 undefined → AI 解读结果卡片永不显示。
  2. 后端 ai_analysis 实测 NULL,逐段定位:配额扣减 INSERT INTO basic_quota_usage 已执行 ✅;foodRecommendService.recalculateForUser(1018) 已执行 ✅(FoodRecommendIndexMapper.delete(user_id=1018) + 日志「报告20无食材推荐数据,已清除用户1018的推荐指数」);LangGraph /api/v1/analyze 返回 500 → triggerAiAnalysis 的 catch 吞掉异常 → 主流程照样成功。 诚实标注:analyze 500 部分可能源于本次最小化 LangGraph 桩件的 LLM 配置,不能 100% 归因生产;但「失败被静默吞掉、主流程与用户均无感知」是确定的代码缺陷。

四、P1-2 代传越权缺口(实锤)

FamilyAccessInterceptor 在 enforcement 模式(默认)。实测:

  • teacher user 1006(family_id=0,有效家庭集非空)传 familyId=2009 调 proxy-parse → 403 被拦 ✅
  • teacher user 81317(family_id NULL,teacher_family_ids 为空)传 familyId=2009 → 越权成功 ❌ 根因:FamilyAccessInterceptor:60-63 在 effectiveFamilyIds 为空时 return true 放行;空集合恰好对应未绑定任何家庭的新账号。另 /api/admin/** 整体跳过该拦截器。 测试库无天然的「家庭未绑定」账号,本次为验证新建了 user 81317。

五、P1-1 报告对象下拉选不到目标家庭成员(实锤)

  • teacher 传 familyId=2009 调 /api/family/member/list → 403(FamilyAccessInterceptor 拦跨家庭)
  • 不传 familyId 调同接口 → 只返回老师本人 1 条(id 1032)
  • FamilyMembersController.listMembers 完全忽略请求体 familyId,只按当前登录用户自己的家庭取成员;无家庭账号还会触发 ensureUserFamily 兜底自动建空家庭。 两条路径都选不到目标家庭成员,代传无法完成。

六、P2-6 匿名访问鉴权层级错误(OSS 风险已排除)

proxy-parse 无 token → JwtInterceptor 走 setAnonymousUser 放行 → Controller isProxyUploader 返回 false → 业务 code 500「无权限」而非 HTTP 401/403。proxy-parse/proxy-confirm 均不在 LOGIN_REQUIRED_PATHS。 实测确认角色校验发生在 storageService.storeFile 之前 → 无凭据请求不会写入 OSS,刷存储风险排除;残余问题是错误码语义错误导致前端拦截器不触发登录跳转。

七、测试数据清理记录(已全部清理,测试库无残留)

本次验证共产生以下测试数据,均已在验证结束后主动删除(连同关联的 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 空) 测试库当前无本次验证的残留数据,复现步骤见正文各节,可按正文描述重新构造。

八、其他确认项

  • /report/list(teacher 列表)返回 List 非 Page → 前端 res.data.records undefined → total 走 length → 分页形同虚设;实体无 subjectName/userName/proxyUploaderName → 「报告对象」「代上传人」列恒 '-'。
  • /report/admin-list 的 map 无 title/createdAt/proxyUploaderName → admin 列表「标题」「上传时间」列恒空。
  • /report/detail 返回嵌套 {report:{...}},两个代传页按扁平字段取值 → 详情弹窗全空。
  • 命名澄清:health_report_drafts 用 remark 列(无 note 列),HealthReportDraftService.updateNote set 的是 getRemark(),不会 Unknown column;但命名极易误导,建议改名或加注释。
  • 环境依赖:langgraph.base-url 默认 http://localhost:9000,生产需 LANGGRAPH_BASE_URL;uploads 需与 LangGraph 容器共享 volume。

  • 附一:验证者补充更正(事实更正,不改变上述结论)

    关于第七节「测试数据清理记录」:测试库写入已全部清理(明细见第七节)。仅对象存储侧残留本次上传的 3 份测试 PDF(JDCloud OSS 测试桶 cfc 的 uploads/2026/10/25/ 下),因对象存储删除不可逆、避免误删他人对象而保留。如需复现上述用例,请按正文各节重新构造草稿与账号(脚本要点:health_report_drafts 插入 pending 行、payload_json 取自 id=4、expire_at = NOW() + 72h、remark 写备注;users 插入 family_id=NULL 且 teacher_family_ids 为空的 teacher 账号;basic_quota_usage(1001, REPORT_READING) 的 used_count 已被重置回 0)。

    关于「最小化 LangGraph 桩件」:本次 127.0.0.1:9000 并非返回伪造响应的假桩,而是挂载仓库内真实 cfc-langgraph/app/api/report_parse.py 与 analyze.py 两个 FastAPI 路由的最小装载(完整 app.main:app 因本机 sqlite<3.35 无法启动 chromadb);/api/v1/report/parse 实测返回真实解析结果(format=beijing、overview.report_number=2025-200-89746 等)。/api/v1/analyze 的 500 根因已定位为最小装载缺少 langgraph checkpointer 的 thread_id 配置(ValueError: Checkpointer requires one or more of the following 'configurable' keys),属验证环境限制而非产品缺陷。

    关于「跨家庭 subjectId 错绑」的额外实锤(第八节未列):auditConfirm 不校验 subjectId 是否属于草稿的 familyId。用 family=2009 的草稿 id=99 调 proxy-confirm {draftId:99, subjectId:1014}(1014 属 family 2005)→ 返回 200,落库 health_reports#37: family_id=2009, subject_id=1014,即报告挂在 2009 家、被检测人却是 2005 家成员。与 P1-2 叠加后,「无家庭绑定账号」可向任意家庭写入并绑定到别家成员的报告。该行已随 id=37 一并清理。

    关于「admin 端报告对象下拉」补充:admin(userId=1001, family 2009)调 /api/family/member/list {"familyId":2005} → 403(admin 并不豁免 FamilyAccessInterceptor,仅 /api/admin/** 路径豁免);而 /api/admin/families 对 admin 返回全部 16 个家庭(ids: 2018…2004, 1)→ admin 页面选择除 2009 外的家庭后「报告对象」下拉必为空。teacher 侧 /api/admin/families 返回 0 条(按 teacher_family_ids 过滤)→ 家庭下拉本身为空,流程无法起步。

    关于编译/构建命令的实际形态:仓库根目录无聚合 pom.xml(cfc-backend/pom.xml 为独立模块),故 mvn -pl cfc-backend -am compile 在根目录报 Could not find the selected project in the reactor: cfc-backend;等价命令在模块内执行通过:cd cfc-backend && mvn -pl . compile -DskipTests → BUILD SUCCESS(exit 0)。cfc-web 无 lint 脚本(package.json scripts 仅 dev/serve/prebuild/build/bump/test/test:coverage),故只跑 npm run build。