Files
zyt/artifacts/prescription-ai-visibility-backend-research.md
T
2026-10-17 16:51:48 +08:00

119 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 处方 AI 一致度授权:后端只读研究
日期:2026-10-17。范围:D:/web/zyt 本地源码;没有调用生产接口、连接数据库、调用模型或运行会初始化业务配置的测试。只新增本报告,未修改业务代码。
已读取根 AGENTS.md;工作区不存在 `.trellis/`。本文最初保留“仅一致度 / 整个 AI 模块”两种边界,随后按主代理明确的实现方向整理:**仅一致度独立授权,保留 AI 报告;角色或单个后台用户命中即可;默认空名单;root 可管理,但查看一致度也必须命中白名单。**
## 1. 现有权限模型
- `server/app/adminapi/logic/auth/AuthLogic.php:91`:`getAuthByAdminId` 经 `admin_role -> system_role_menu -> system_menu` 取权限,忽略 `is_disable=1` 的菜单。多角色权限取并集。
- `server/app/common/model/auth/Admin.php:52`:`role_id` 是 `AdminRole` 关联查询结果,不是单值;新策略必须支持任一角色命中。
- 没有发现通用“用户直接拥有菜单权限”表或管理机制。`AuthLogic:109` 会追加 `QywxPromotionOperatorAccess` 专用共享页面权限,是业务特例,不宜为了一个字段的可见性扩展全局 RBAC。
- `server/app/common/service/qywx/QywxPromotionOperatorAccess.php:38`:可参考其服务端动态单用户判断、软删除过滤、菜单启用检查;不应复用该业务表。
- `server/app/common/cache/AdminAuthCache.php:73`:菜单权限缓存最长 3600 秒;`:122` 清理具体账号缓存键。若本功能仅独立策略读取而不往 AuthLogic 注入权限,便不需要变更该全局缓存;每次请求实时读取策略可立即撤销。
- `server/app/adminapi/http/middleware/AuthMiddleware.php:129` 附近:未注册为菜单的 URI 有默认放行逻辑。因此**新配置接口必须自带服务端管理鉴权**,不能只加菜单或依赖前端 `v-perms`。
一致度的最终允许条件应是:账号有效 AND 现有该动作 RBAC 权限通过 AND 原有处方/患者/来源快照数据范围通过 AND 一致度白名单命中。名单只增加指标可见资格,不授予患者或报告访问权限。root 仍按既有逻辑拥有普通 AI 动作权限,但不能绕过新的指标白名单。
## 2. 可直接采用的存储与接口形态
`server/app/common/service/ConfigService.php:32` 支持将数组 JSON 存到 `config` 表;`:65` 读取并反序列化。已有 `setting/DesktopWorkstationController.php` 与 `DesktopWorkstationLogic.php:39` 展示独立 getConfig/setConfig 的配置管理范式。
建议仅存一条对象(名字为候选,不代表已定路由):
```json
{
"role_ids": [1, 7],
"admin_ids": [23, 91]
}
```
- 候选存储:`type=prescription_ai_visibility`, `name=agreement_policy`。
- 缺失、格式错误、空名单均解释为不可查看一致度,不回退到“全部可见”。对象包含两个键,避免 ConfigService 将空数组视为假值并走默认配置的语义造成意外。
- 写入要求两个字段均为数组;元素须正整数/规范十进制整数字符串,拒绝对象、浮点、布尔、负数及字符串拼接;去重排序,验证角色/账号实际有效;限制数量及拒绝未知字段。不要静默把任意字符串 `intval` 后授予权限。
- 一次覆盖单 JSON,避免分别保存角色、用户导致半更新。`config` 安装表没有 `(type,name)` 唯一索引(`server/public/install/db/like.sql:142`),可由幂等迁移预置这一条空策略,避免首次并发创建;不要为本功能顺带对全表增加唯一约束。
- 不存模型密钥、不新增业务表,不修改 `prescription_analysis.enabled` 或后台工作队列设置。
候选 API:
| 接口 | 返回/接收字段 | 必须执行的服务端鉴权 |
| --- | --- | --- |
| GET `tcm.prescriptionAi/capabilities` | `{agreement_visible:boolean, can_manage_agreement_visibility:boolean}` | 登录且实时有效账号;只返回当前账号布尔能力,不泄露名单 |
| GET 专用 `getVisibilityConfig` | `{role_ids:number[], admin_ids:number[]}` | root 或独立管理权限 |
| POST 专用 `setVisibilityConfig` | 严格上述两数组;返回规范化后的策略 | 同一管理鉴权;必须 POST |
| GET 专用 `visibilityOptions` | `roles:[{id,name}]`, `admins:[{id,name,account,role_ids}]`,可增加有界搜索/分页 | 同一管理鉴权;只投影必要字段 |
配置 API 既可设在 `PrescriptionAiController`,也可采用 `setting.prescriptionAiVisibility/getConfig|setConfig|options`;关键是最终 action/perms 一致且逻辑层鉴权。若采用一个专用“管理一致度授权”权限,所有 get/set/options 均调用共同 `requireManage`,默认不向普通角色自动授予。不要把名单加到 `ConfigController::getConfig`:该控制器 `server/app/adminapi/controller/ConfigController.php:27` 明确 `notNeedLogin=['getConfig','dict']`。
## 3. 角色和用户选项:已有 API 与 active 字段
| 对象 | 当前可用 API | 实现与约束 |
| --- | --- | --- |
| 系统角色 | GET `/auth.role/all`,前端 `roleAll()` | `RoleController:118` -> `RoleLogic:181`;`SystemRole` 使用 SoftDelete,自动排除软删除;按 sort/id 降序 |
| 后台用户 | GET `/auth.admin/lists?exclude_disabled=1&page_no=1&page_size=...`,前端 `adminLists()` | `AdminLists:165` 排除 disable;返回 `lists/count` 的列表封装,支持 name/account 模糊搜索 |
| 后台全部用户 | 前端有 `adminAll()` wrapper `/auth.admin/all` | **后台 AdminController 没有 all action,不可直接使用此 wrapper** |
模型事实:
- `Admin`:`delete_time IS NULL` 且 `disable=0` 才是活跃账号,见 `PrescriptionAiAccess::actor` 第 15 行;必要字段 `id/name/account/root`;角色经 `admin_role` 关联。
- `SystemRole`:`delete_time IS NULL`,没有发现 `disable` 或 `status` 字段;`server/public/install/db/like.sql:655` 及现有迁移仅见额外 `data_scope`。**不要对角色添加 `disable=0` 条件。**
- `system_menu.is_disable` 是菜单状态,与系统角色不同。
- `AdminLists` 返回手机号、登录 IP、头像等较多字段,授权下拉建议在新管理接口中直接复用模型查询,并只投影 id/name/account/role_ids。
- `AdminLists::queryWhere` 一般默认不套 data_scope,只有显式 `apply_data_scope=1` 才启用;管理授权选项须由共同管理鉴权保护。其 role_id 过滤在该角色无成员时不会强制空集,因此若需角色联动筛选,专用 options 应正确处理空集。
- `BaseDataLists.php:102`:`page_type=1` 表示分页,其它值只是返回最大条数,不是真正无限制;不能假定一次 `page_type=0` 可取完所有后台用户。
- 即使保存时验证过 ID,查看时也只匹配实时有效账号与有效角色;被禁用/软删除账号不可使用旧 token,角色变更不可依赖 token 的历史 role_id。
## 4. 现有处方 AI 数据保护与必须覆盖的出口
`server/app/adminapi/controller/tcm/PrescriptionAiController.php:71` 的 handle 拒绝未知入参,校验 GET/POST,调用 `PrescriptionAiAccess::actor` 重新加载有效账号,不直接信任请求内角色。`PrescriptionAiAccess.php` 还分别校验处方、诊单及来源诊单范围。应保留这些保护,并在新增 capabilities/配置入口也使用同等实时账号检查。
`PrescriptionAiLogic.php` 的重要出口:
| 入口 | 当前信息 | 仅一致度授权时的处理 |
| --- | --- | --- |
| `statuses:22` | 列表轮询调用 `models(...,false)`,有 `score/herb_score/comparison_status/comparison_reason_code/reason/algorithm_version/dictionary_version` | 返回 `agreement_visible`;未授权时使用统一投影去掉所有比较字段,保留模型生成状态/进度 |
| `reports:62` | 历史列表同样 `models(...,false)`,不能只保护 statuses | 同上;继续先按 visibleBatch 过滤再分页,不泄漏隐藏来源计数 |
| `detail:107` | `models(...,true)` 解密正文,又包含医生原方快照 | 未授权仍保留报告、候选方及医生原方(按保留报告的当前范围),统一去掉比较数据;不要返回原始解密 body |
| `statistics:206` | 均值、中位数、直方图、版本分层、paired_count 等全是这一指标的衍生信息 | 新白名单检查失败时直接抛 DomainException;必须在扫描/聚合前拒绝;不以 mean=null 等方式继续返回统计 |
| `regenerate/retry/review` | 返回批次状态或 saved,没有返回比较正文 | 仅指标限制不应阻止既有报告操作;仍保留现有动作 RBAC、处方与来源范围 |
具体易漏点:
1. `models:285` 的数据库摘要列不仅 `score`,还包括 `herb_score`、比较状态/原因与版本。
2. `models:302-315` 现为 `array_merge(task,result,body,progress)`。加一条 `unset($model['score'])` 不够:解密 body 里的 `comparison` 含同一分数,并可能包含历史未知字段或顶层别名;原始 body 不应不加选择地 merge 到对外模型。
3. `Store::complete:348` 当前加密 body 的固定键是 `report,candidate,comparison,coverage,usage,knowledge_source,ancient_book_sources`,不是必须输出的完整 API 对象。建议按这些已知业务字段构造响应,单独按能力投影 comparison。这样历史 body 即使含 `score`, `raw`, `result`, `response`, `body`, `comparison_dump` 等未知附加键,也不会直接公开。
4. `Comparison::compare:126` 的完整 comparison 除分数外还包含 `rows`、药量 ratio/贡献、`matched_count`、doctor/candidate_count、`usage_differences` 和 `normalization` 中的 denominator/contribution_sum,足以反推指标。未授权应移除**整个 comparison 对象**。
5. `models:310` 的扁平 `reason` 取自 `body.comparison.reason`,也是比较派生信息;无结果任务的 `reason` 则是生成错误信息。应区别处理或在统一投影中使用保留的 `error_message` 展示模型失败原因,避免比较 reason 保留下来。
6. `formatBatch:348` 还输出 `comparison_type`, `baseline_eligible`, `baseline_exclusion_reasons`,也应从未授权响应中移除。`coverage_status/source_summary/missing` 属于报告资料覆盖信息,可保留。
7. 不把 `body_cipher` / `context_cipher` / `progress_cipher` / 原始 AI 调用响应增加到任何返回值。现有 Progress 采用固定字段投影并本地生成 label/notice,可继续复用;比较阶段名称本身不包含指标结果。
8. 受保护字段应该被完全省略,而非伪装为 0 或“未生成”;布尔 `agreement_visible=false` 告诉 UI 不渲染指标功能。
保留医生原方与候选方案意味着用户仍可人工比较药味;本需求保护的是系统计算的一致度与相关比较/统计数据。若用户后续改为整个模块不可见,则应在所有 AI 动作的业务入口一并拒绝,前端隐藏全部入口;不能把当前字段裁剪方案误当作整模块授权。
## 5. MCP 与其它旁路
`server/app/mcp/catalog/review/tcm.php:185` 已开放 `detail/reports/statuses/statistics`,并说明依赖各入口内置数据范围保护。必须将授权/投影放在共享业务层,HTTP 控制器最终输出前才过滤会遗漏直接 Logic 测试或其它调用通路。新管理配置接口不应自动加入开放 MCP catalog。
对 `server/app` 的引用检索未发现这四个入口之外直接返回 `prescription_ai_result/body_cipher` 的业务出口;Store/Worker 为写入与后台计算。仍应在实现完成后检索新增调用与 MCP generated 元数据,确认统计内置保护说明匹配新规则。
## 6. menu 迁移惯例
- 当前处方 AI 迁移 `server/database/migrations/2026_09_09_prescription_ai_analysis.sql:132`:通过现有处方列表 perms 查父级;`INSERT ... SELECT ... WHERE NOT EXISTS(perms)` 幂等创建 type A 动作。
- 原有 7 个动作分别注册 statuses/reports/detail/regenerate/retry/review/statistics;读权限继承自 patientAiReports,写权限继承自 generatePatientAiReport。**本需求不应把管理授权能力自动继承给这些读写角色。**
- 现有设置页范式 `2026_08_21_desktop_workstation_update_menu.sql`:查询系统设置 type M 父级;type C 页面使用 getConfig perms/path/component,子级 type A 保存动作;时间戳 UNIX_TIMESTAMP,空 path/component 给动作,is_show=1/is_disable=0,事务包裹。
- 可参考其幂等菜单写法,但本需求管理菜单默认 root 可用;非 root 只有明确授予独立管理权限后可管理。不要复制“所有设置角色自动继承”的授予部分。
- 新 capabilities 仅返回当前身份能力且必须登录,是否注册菜单权限需与中间件设计一致;避免将自查接口变成必须先拥有一致度权限才可调用的循环依赖。
## 7. 可复用测试模式(本次未运行)
1. **纯函数/契约测试**:`server/tests/PrescriptionAiProgressTest.php`。直接 require autoload、不初始化 App,用固定 fixture 检查允许字段;策略匹配、规范化、投影最适合此方式。
2. **无环境隔离桩**:`server/tests/DiagnosisValidateEditSceneTest.php:9` 创建 `new think\App()` 但不 initialize;在真实类加载前以 class_alias 安装内存模型/DB/Auth double。适合验证真实入口会拒绝未授权 statistics 与配置写入,避免初始化业务 .env。
3. **真实加密/数据库 API 集成**:`server/tests/PrescriptionAiQueueTest.php:5` 明确禁止 initialize,必须显式 `ZYT_AI_TEST_MYSQL_PORT`,只连接 `127.0.0.1` 空密码临时 MySQL,数据库名只允许 `prescription_ai_test_[a-f0-9]{12}`,显式注入隔离 DB/config 并最终清理;现有 fixture 已覆盖 statuses/reports/detail/statistics、结果 Cipher、失败模型和旧 body 注入。扩展时可添加 config/system_role 夹具、非 root 多角色及账号。
4. **迁移幂等**:QueueTest 重放 migration 两次并检查菜单数,可直接复用新菜单和空策略迁移的验证方式。
5. **多角色缓存边界**:`AdminMultiRoleRegressionTest.php` 说明角色 ID 去重排序及撤销角色后 token/cache 失效;新指标策略应按实时 role_id 与新策略判断,不从旧 token 缓存白名单结果。
6. **本地配置文件测试**:`PrescriptionAiConfigurationTest.php` 使用自定义 env fixture,绝不 bootstrap。可用于确认本次没有意外改变模型启用/生成配置。
最低必要用例:默认空/非法策略全部不可见(包括 root);只选角色/只选用户/任一匹配/多角色并集/其它账号不匹配;禁用或软删除账号、删除角色、实时撤销;未授权仍能读既有报告内容但 statuses/reports/detail 任何层均不存在比较字段或未知原始 body;授权用户数值保持不变;未授权 statistics 拒绝而不聚合;非管理者不能读名单、改名单或列选项;root 可管理但不会因此获得 view;已有处方/来源权限撤销仍拒绝报告。加入带唯一哨兵的嵌套 comparison/unknown raw body fixture,序列化整个响应检验哨兵不存在,不能只验证 UI 字段或顶层 score。
不建议直接运行会 `App::initialize()` 的旧测试(例如 PrescriptionAiSecretSafetyTest)来证明本特性;本功能可以完全使用上述隔离模式完成有效验证。