Files
zyt/app/research/ai_server_context_audit_20260821.md
2026-08-22 08:51:35 +08:00

23 KiB
Raw Permalink Blame History

Server AI/LLM 患者上下文与接口安全审计

审计日期:2026-08-21
审计范围:server/appserver/config 及与真实请求拼装直接相关的 app/src/doctor_workstation
方法:只读静态审计;未连接生产数据库、未请求任何模型服务、未修改生产代码或测试。行号以本次工作区内容为准。

1. 结论

当前不存在一套被所有患者 AI 入口复用的统一上下文。实际有三套相互独立的患者上下文:

  1. DiagnosisAiLogic:诊单 AI 助手、诊单智能分析、诊单 AI 报告共用 buildCaseContext(),但它只基于当前一张诊单详情,并不聚合医生备注文字、跟踪记录、视频转写、聊天、已开处方或处方病历快照。证据:server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:349:492:564 均调用 buildCaseContext();该方法仅遍历诊单及附件数量,见 :875-947
  2. PatientAiReportLogic:患者纵向 AI 报告明确与 DiagnosisAiLogic 独立,见 server/app/adminapi/logic/tcm/PatientAiReportLogic.php:18-22。它覆盖历次诊单、备注、日常记录、聊天及视频转写,是当前最完整的一套,但仍不读取 tcm_prescription 已开处方、药味和 case_record
  3. DailyDietAiLogic:患者端饮食建议另建一套上下文,只含姓名、性别、年龄和近 30 天血糖统计/近 7 条明细,见 server/app/api/logic/tcm/DailyDietAiLogic.php:279-339

此外,桌面端给诊单 AI 助手补上下文时存在两个确定的契约错误:跟踪接口返回 blood_records,客户端却读取 blood_sugar;处方接口返回 Prescription 数据类,客户端只接受 Mapping。因此界面声称附带的“每日血糖/处方记录”在真实远端数据形态下会缺失。证据见第 4 节。

最高优先级安全问题是 AiChatService 在阻塞与流式请求中都关闭 TLS 证书和主机名校验,会让患者姓名、血糖及问题内容面临中间人窃取或篡改风险:server/app/common/service/AiChatService.php:48-59:144-160

2. AI/LLM 入口清单

入口 是否调用 LLM 上下文构造 备注
tcm.diagnosis/aiAssistantaiAssistantStream DiagnosisAiLogic::buildCaseContext() + 客户端把额外资料塞入 prompt 控制器入口:server/app/adminapi/controller/tcm/DiagnosisController.php:913-1020;阻塞/流式最终都用同一 prepared contextserver/app/adminapi/logic/tcm/DiagnosisAiLogic.php:316-397
tcm.diagnosis/aiAnalysis DiagnosisAiLogic::buildCaseContext() server/app/adminapi/controller/tcm/DiagnosisController.php:1034-1047server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:481-516
tcm.diagnosis/generateAiReports 是,每次固定生成 qwen/openai 两份 DiagnosisAiLogic::buildCaseContext() server/app/adminapi/controller/tcm/DiagnosisController.php:1087-1098server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:553-618
patientAiReports 否,只读历史 不组装上游请求 server/app/adminapi/controller/tcm/DiagnosisController.php:1052-1064
generatePatientAiReport PatientAiReportLogic::buildSourceSnapshot() server/app/adminapi/controller/tcm/DiagnosisController.php:1069-1082;全量聚合见 server/app/adminapi/logic/tcm/PatientAiReportLogic.php:335-398
aiReportseditAiReportaiPatientOptions 否,只读/编辑/选择 不调用模型 控制器见 server/app/adminapi/controller/tcm/DiagnosisController.php:879-926:1104-1115
dailyDietAiRecommenddailyDietAiAsk 及两条 Stream DailyDietAiLogic::buildPatientContext() server/app/api/controller/TcmController.php:900-979;上下文见 server/app/api/logic/tcm/DailyDietAiLogic.php:279-339
处方库 generateAiReports 是,但不是患者入口 处方库名称、类型、最多 80 味有效药材 server/app/adminapi/logic/tcm/PrescriptionLibraryAiLogic.php:145-222:400-426;不应强行复用患者上下文
DailyBloodCareAiLogic 当前不可达 计划复用 DailyDiet 全仓只有类自身引用,没有控制器/路由;且调用不存在的公开方法 DailyDietAiLogic::getPatientContext(),见 server/app/api/logic/tcm/DailyBloodCareAiLogic.php:161-176,实际方法是私有 buildPatientContext()server/app/api/logic/tcm/DailyDietAiLogic.php:279

全仓 PHP 搜索只发现两种上游客户端:DifyChatServiceAiChatService。其调用者分别是 DiagnosisAiLogicPatientAiReportLogicPrescriptionLibraryAiLogic,以及 DailyDietAiLogic、未接线的 DailyBloodCareAiLogic

3. “患者发给 AI”时各类资料的真实覆盖

符号: 文字进入上游;△ 只有部分字段/数量元数据/依赖客户端; 未进入。

资料类型 诊单 AI 助手/分析/诊单报告 患者纵向 AI 报告 患者端饮食 AI
患者基本信息 △ 性别、年龄、身高、体重、婚姻等;服务端不主动发送姓名/电话/身份证 最新诊单基本信息;上游发送前 _id_name 等键脱敏 △ 姓名、性别、年龄;姓名被直接发送
当前/现病信息 △ 当前诊单的症状、既往史、当前用药、舌脉、治则等;仓库字段漂移导致实际 prescriptiondoctor_advice 漏掉 所有授权诊单的广泛字段,包含诊单上的 prescriptiondoctor_advice 除血糖外不含症状、当前用药、过敏、肝肾风险、医嘱等
每日视频面诊转写 服务端不查;桌面端最多把一条转写塞入 320 字上下文 所有授权诊单通话及转写段,段落可重建 transcript
病例与记录病历 △ 当前诊单字段;不含处方 case_record △ 历次诊单完整,但不含已开处方的 case_record
医生备注/跟踪备注 只聚合备注里的图片,不聚合备注文字 全部医生备注和跟踪备注
舌苔/舌象 △ 舌苔/舌象文字 + 图片数量;不读取图片内容 △ 文字 + 附件数量元数据;明确禁止声称做视觉识别
检查报告 △ 只发送附件数量和“未提供附件内容” △ 只发送附件数量元数据,无 OCR/报告正文
日常记录 服务端不查;桌面端本想补血糖但字段名错误,饮食/运动也未拼入 血糖血压、饮食、运动全量 △ 近 30 天血糖统计、近 7 条逐日明细;不含饮食/运动记录
既往处方 tcm_diagnosis.prescription 被字段白名单漏掉;不查 tcm_prescription;桌面端又因类型判断漏掉远端处方 △ 有历次诊单的自由文本 prescription,但没有正式 tcm_prescription、药味、服法、case_record
IM/企微聊天 腾讯 IM 与企微聊天全量

3.1 诊单 AI 共用的是“窄上下文”

DiagnosisAiLogic::CASE_FIELDS 定义于 server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:210-252。它会加入血压、空腹血糖、身高体重、舌象/报告附件数量,再遍历白名单字段,见 :875-923DiagnosisLogic::detail() 本身只从医生备注聚合舌象与报告图片,未加载备注 content,见 server/app/adminapi/logic/tcm/DiagnosisLogic.php:274-352server/app/adminapi/logic/doctor/DoctorNoteLogic.php:140-164

字段白名单存在明确漂移:仓库诊单表包含 prescriptiondoctor_advice,见 server/sql/tcm_diagnosis.sql:13-21;新增/编辑验证器也使用这两个名称,见 server/app/adminapi/validate/tcm/DiagnosisValidate.php:111。但 AI 白名单只找 prescription_opinionprescription_advice,没有 prescriptiondoctor_advice,见 server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:244-251。相反,白名单中的 chief_complaintpresent_illness 等名称在本仓库 SQL 定义中未找到,应以生产表 SHOW COLUMNS 再核实;无论如何,这已说明上下文与真实字段没有单一契约。

舌象和检查报告只有数量元数据:server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:905-916 明确写入“未提供附件内容”。医生备注实际保存 contenttongue_imagesreport_files,见 server/app/adminapi/logic/doctor/DoctorNoteLogic.php:12-68,但诊单 AI 没有读取 content

3.2 患者纵向报告覆盖广,但漏正式处方

纵向报告先按 patient_idMyPatientLogic 数据域查出全部有效诊单,不设日期或条数上限:server/app/adminapi/logic/tcm/PatientAiReportLogic.php:254-296。随后按这些诊单 ID 全量查询:

  • 医生备注、跟踪备注、血糖血压、饮食、运动:server/app/adminapi/logic/tcm/PatientAiReportLogic.php:342-366
  • 腾讯 IM、企微聊天、视频通话::367-380
  • 视频转写段::382-392,并在 :450-472 重建每次通话的 transcript_text
  • 汇总对象确实包含 doctor_notestracking_notes、三类 daily records、两类 chat records 和 video_calls:495-520

buildSourceSnapshot() 完全没有查询 tcm_prescription。正式处方模型把 herbscase_recordaux_usage 声明为 JSON 字段:server/app/common/model/tcm/Prescription.php:14-21case_record 又是明确的“详细病历(诊单快照 JSON)”:server/database/migrations/2026_03_19_add_prescription_case_record.sql:2。已有按诊单、逐条可见性过滤的安全入口可复用:server/app/adminapi/logic/tcm/PrescriptionLogic.php:937-963

上游脱敏总体正确:患者纵向报告会把 ID、*_id*_name、账户标识替换为脱敏占位,并把附件 URL 替换成数量,见 server/app/adminapi/logic/tcm/PatientAiReportLogic.php:820-848;手机号、身份证、邮箱、URL 正则见 :851-860。提示词也明确禁止对附件和视频画面作视觉推断,只能使用文字、转写和元数据,见 :739-744

3.3 患者端饮食 AI 的上下文过窄且发送姓名

DailyDietAiLogic 查询近 30 天血糖、不限显式行数,筛近 7 天后只取 7 条明细,见 server/app/api/logic/tcm/DailyDietAiLogic.php:291-326。返回上下文只有 patient_nameagegender_text、血糖明细和 7/30 天统计,见 :328-337。推荐与问答提示词都将患者姓名直接发往模型::508-519:560-569:706-712:795-803

对于会给出个体化饮食建议的糖尿病入口,未纳入诊单中的当前用药、过敏、肾脏/肝脏状况、医生医嘱、现有饮食/运动记录和正式处方,可能使建议与真实禁忌或治疗方案冲突。

4. 桌面端实际问答链路的两个确定缺口

诊单 AI 助手不是只用服务端诊单摘要。桌面端先把额外资料拼成最多 320 字的“患者综合资料”,再与医生问题合成最多 500 字的 promptapp/src/doctor_workstation/ui/dialogs/ai_consult.py:2682-2694:2835-2928。服务端把整个 prompt 当作 <USER_QUESTION>,同时另放自己构造的 <CASE_DATA>server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1052-1078。真实流式请求体只有 idprompttaskapp/src/doctor_workstation/services/repository.py:1373-1391

确定缺口如下:

  1. 每日血糖字段名不一致。 客户端先读取 tracking["blood_sugar"],再找 entries|records|itemsapp/src/doctor_workstation/ui/dialogs/ai_consult.py:2710-2742。但服务端跟踪接口返回的是 blood_recordsdiet_recordsexercise_recordsserver/app/adminapi/logic/tcm/DiagnosisLogic.php:4473-4502;仓库层原样返回:app/src/doctor_workstation/services/repository.py:2170-2191。结果是逐日血糖不会进入 prompt,通常只剩诊单上的一次空腹血糖。
  2. 远端处方对象类型不一致。 客户端只处理 Mapping,否则 continueapp/src/doctor_workstation/ui/dialogs/ai_consult.py:2789-2806。远端仓库却把列表解析为 Prescription 数据类:app/src/doctor_workstation/services/repository.py:1643-1649;该类定义于 app/src/doctor_workstation/core/models.py:577-620。结果是正式环境返回的处方不会进入 prompt。当前脚本 app/scripts/check_ai_context.py:252-267 用字典模拟处方,无法覆盖此契约错误。

另有三个设计缺口:

  • 工作区已加载医生备注 notes,见 app/src/doctor_workstation/ui/dialogs/ai_consult.py:3782-3801,但调用 build_patient_ai_context() 时没有 notes 参数,见 :3928-3951
  • 上下文顺序固定为血糖、舌脉、视频、历史 AI 报告、处方,并共享 320 字预算,见 :2855-2898;后面的处方更容易被预算耗尽,没有逐段保底或截断清单。
  • “历史AI报告”会作为新一轮模型输入,见 :2769-2786,存在把旧模型推断当作新证据反复强化的来源污染;应至少明确标记为模型生成内容并默认不作为临床事实。

5. 鉴权与接口安全

5.1 已有的有效控制

  • 管理端经过登录、权限认证等中间件:server/app/adminapi/config/route.php:15-27。诊单 AI 逻辑又独立校验精确权限和 MyPatientLogic::canAccessDiagnosis(),再读详情:server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:842-869;权限匹配是精确小写 URI 白名单::744-755
  • 患者纵向报告同时要求查看权限和生成权限,见 server/app/adminapi/logic/tcm/PatientAiReportLogic.php:126-149,并通过 MyPatientLogic::applyScope() 限定医生/医助/部门数据域,且对不存在和越权返回同一错误,见 :254-296
  • 患者端饮食四个入口不在 notNeedLogin 中:server/app/api/controller/TcmController.php:36-42;每个入口在调用 AI 前执行 ensurePatientOwnsDiagnosis(),通过当前用户的 diagnosis_view_records 验证归属::495-525:904-977
  • aiAnalysis、患者报告读/生成使用严格字段白名单,拒绝客户端传 provider、BASE_URL、凭据和自由来源正文:server/app/adminapi/validate/tcm/DiagnosisValidate.php:196-213:269-327。AI 助手 task 受枚举限制,prompt 最长 500::27-53
  • DifyChatService 只允许 qwen/openai profile、超时 1300 秒,且启用 TLS 校验:server/app/common/service/DifyChatService.php:13-50:305-307:329-343:422-440。它也不会把供应商原始错误正文和密钥回传给控制器。

5.2 缺口与风险等级

P0:患者端 AI 上游关闭 TLS 校验

AiChatService 阻塞和流式路径均设置 CURLOPT_SSL_VERIFYPEER=falseCURLOPT_SSL_VERIFYHOST=falseserver/app/common/service/AiChatService.php:48-59:144-160。该服务承载姓名、血糖和自由问题,风险为患者隐私泄露、模型响应被篡改以及服务端密钥被中间人获取。

最小修复:两处改为 true/2;对 base_url 做与 DifyChatService::isValidBaseUrl() 同等级校验;生产只允许 HTTPS。若企业内网使用私有 CA,应配置 CA bundle,不能关闭校验。

P1:上下文不统一且诊单助手缺失关键临床资料

诊单助手、分析和诊单报告虽然内部共用一份 builder,但这份 builder 没有患者级聚合能力;纵向报告和饮食 AI 又各自维护字段。这会造成同一患者在三个入口得到基于不同事实集的答案。

最小修复:新增服务端只读 PatientAiContextBuilder,先接收已鉴权的 diagnosis/patient scope,再用 profile 决定范围:

  • assistant:当前诊单 + 最近 30/90 天日常记录 + 最近 N 条医生/跟踪备注 + 最近 N 次已完成视频转写 + 最近 N 张可见正式处方;
  • diagnosis_report:当前诊单的完整字段、备注、处方及附件元数据;
  • longitudinal_report:现有全病程分片策略,但补正式处方和来源清单;
  • daily_diet:只取饮食决策所需的最小临床子集,避免姓名。

所有 profile 应返回 source_manifest、每类记录数量、时间窗和 truncated_sections,使 UI 与审计日志能准确说明模型看到了什么。

P1:患者纵向报告无限查询/无限调用成本

诊单查询和九类来源查询均未设置日期或行数上限:server/app/adminapi/logic/tcm/PatientAiReportLogic.php:270-283:342-392。代码会按 120,000 字节切片、逐片调用模型,再最多做 8 轮归并,确保不静默截断::44-51:630-729。完整性设计是优点,但攻击者或异常大患者记录可触发大量数据库内存和模型请求,当前 AI 路径也未发现用户级/患者级频率限制或并发去重。

最小修复:在查询层分页/游标读取;设置总来源字节、最大 chunk 数和最大上游调用数;生成任务使用 (patient_id, model, source_hash) 幂等锁;按管理员/患者限流。达到上限时返回显式“资料过多,需要缩小时间范围”,不可仍标记 snapshot_complete=true

P1:原始患者快照落库,保留范围过大

上游发送前会脱敏,但落库的 source_snapshot 是脱敏前的 $sourceJson:快照先在 server/app/adminapi/logic/tcm/PatientAiReportLogic.php:163-166 构造,脱敏只在 generateUpstreamReport():638-640 执行,而原始 JSON 在 :203-225 直接写入 source_snapshot。它包含姓名、内部 ID、聊天/转写正文和附件 URL。历史接口有意识地不读取这个大字段,见 :1012-1025,但数据库静态泄露与过度保留风险仍存在。

最小修复:若无需法律审计复现,仅存脱敏快照 + 哈希 + source manifest;若必须保存原文,则字段级加密、独立访问权限、明确 TTL/删除策略,并记录谁读取过原始快照。

P1:患者端饮食 AI 暴露不必要姓名且临床约束不足

姓名对 GI 推荐没有必要,但当前四套 prompt 均发送姓名,证据见第 3.3 节。与此同时,可能直接影响饮食安全的过敏、肾病/肾功能、当前用药和医生医嘱未进入模型。

最小修复:删除 patient_name;加入最小化的风险字段并设置“若过敏/肝肾/用药信息缺失,不给个体化禁忌结论”;将问答文本放进明确的不可信数据边界,防止把用户问题中的指令当系统指令。

P2:上游 URL/响应大小与中间件防线仍可加强

  • DifyChatService 的 URL 校验允许 http 和任意主机/IPserver/app/common/service/DifyChatService.php:287-303。虽然 URL 只来自服务端配置,不是请求参数,因此不是直接请求型 SSRF,但生产误配会把患者资料发往明文或非批准主机。建议 HTTPS-only + 域名 allowlist;若确有内网模型,使用独立显式配置开关并拒绝重定向到私网/环回地址。
  • 两个上游客户端都没有在传输阶段设置响应体最大字节数。DifyChatService 阻塞请求先整段 RETURNTRANSFER:329-354,解析器的 32/64 KiB 限制发生在收完之后;流式路径也持续累积内容。建议在 write callback 中按字节中止,并区分“响应过大”错误。
  • AuthMiddleware 对不在全局菜单 URI 集合中的路由直接放行:server/app/adminapi/http/middleware/AuthMiddleware.php:73-83。当前诊单/患者 AI 逻辑自带权限校验,故没有形成直接越权;但新 AI 路由若忘记逻辑层校验会失守。最小修复是对 /ai* 或配置的敏感控制器 fail-closed,并保留逻辑层二次校验。

P2:未接线的 DailyBloodCare 代码会在接线后立即失败

DailyBloodCareAiLogic 调用不存在的 DailyDietAiLogic::getPatientContext(),且自身没有患者归属校验。当前无路由所以不构成线上入口;未来若启用,应先改为共享的公开 context builder,并在控制器进入逻辑前复用 ensurePatientOwnsDiagnosis()。不要只修方法名后直接暴露。

6. 字段、时间范围和条数限制清单

路径 当前限制 审计判断
诊单 AI 助手 task 枚举;医生 prompt 500 字;上下文字段逐项最多 800 字 输入边界明确,但 500 字中还混入客户端上下文,医生问题会被截短;字段截断无 manifest。证据:server/app/adminapi/validate/tcm/DiagnosisValidate.php:49-53server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:1653-1661
诊单 AI 分析 case 最多 16,000 字,响应最多 32,768 bytes,建议字段/风险条数均有限 输出校验较好;仍只看当前诊单且截断不告知模型/UI。证据:server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:21-58:1025-1048
患者纵向报告 数据库查询全历史、无行数上限;每片 120,000 bytes;综合 180,000 bytes;最多 8 轮归并;响应 65,536 bytes 不静默漏源,但数据库内存、延迟和费用无硬上限。证据:server/app/adminapi/logic/tcm/PatientAiReportLogic.php:36-51:630-729
患者端饮食推荐 血糖 30 天;近 7 天最多 7 条明细;问答 80 字;缓存推荐 1 天、问答 1 小时 时间窗合理,但 refresh=1 可反复绕过推荐缓存,未见限流;30 天查询无显式行数 cap。证据:server/app/api/logic/tcm/DailyDietAiLogic.php:28-109:291-326
处方库 AI 最多 80 味有效药材;待生成列表最多 500 条 属于非患者入口,边界基本清晰。证据:server/app/adminapi/logic/tcm/PrescriptionLibraryAiLogic.php:83-120:604-625
Dify 上游 profile 仅 qwen/openaitimeout 1300 秒 正向控制;缺传输级响应大小上限与生产域名 allowlist。证据:server/app/common/service/DifyChatService.php:16-20:305-307

7. 建议的最小修复顺序

  1. 当天可改:恢复 AiChatService TLS 校验;饮食 prompt 去掉患者姓名;为上游响应设置硬字节上限。
  2. 第一批契约修复:桌面端读取 blood_records;处方 helper 同时支持 Prescription 对象;把 notes 显式加入或由服务端统一拼装;补覆盖真实远端类型/字段名的测试。
  3. 服务端临床完整性:给 DiagnosisAiLogic::CASE_FIELDS 补真实 prescriptiondoctor_advice;接入最近医生/跟踪备注、视频转写、日常记录和经 canViewPrescription() 过滤的正式处方;不要把附件 URL 当内容,若有 OCR 则以独立、带来源的文本字段加入。
  4. 统一 builder:让 DiagnosisAi、PatientAiReport、DailyDiet 复用同一数据访问/脱敏/来源清单层,只在 profile 的时间窗和字段最小化上不同。
  5. 资源与审计:患者报告增加查询/调用/总字节上限、幂等锁和限流;调整原始 source_snapshot 的加密与保留策略;所有结果返回上下文版本、来源数量和截断信息。

8. 最终判断

目前“诊单 AI 助手会自动拿到患者完整资料”的说法不成立。服务端只自动拿当前诊单摘要;桌面端确实尝试补视频、血糖、处方等,但 320 字预算及两个契约错误使其远达不到完整上下文。患者纵向 AI 报告是唯一真正覆盖视频转写、备注、聊天和日常记录的入口,却没有正式处方/处方病历,并且全历史、无限行的实现带来显著资源与隐私保留风险。

建议把“统一上下文”定义为统一的数据访问、鉴权、脱敏、来源清单和截断协议,而不是要求所有入口发送同样多的数据。饮食问答应最小化;纵向报告可更完整;诊单助手应在可控时间窗内补齐临床关键项。这样才能同时解决答案一致性、隐私最小化和成本边界。