15 KiB
处方 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 的配置管理范式。
建议仅存一条对象(名字为候选,不代表已定路由):
{
"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、处方与来源范围 |
具体易漏点:
models:285的数据库摘要列不仅score,还包括herb_score、比较状态/原因与版本。models:302-315现为array_merge(task,result,body,progress)。加一条unset($model['score'])不够:解密 body 里的comparison含同一分数,并可能包含历史未知字段或顶层别名;原始 body 不应不加选择地 merge 到对外模型。Store::complete:348当前加密 body 的固定键是report,candidate,comparison,coverage,usage,knowledge_source,ancient_book_sources,不是必须输出的完整 API 对象。建议按这些已知业务字段构造响应,单独按能力投影 comparison。这样历史 body 即使含score,raw,result,response,body,comparison_dump等未知附加键,也不会直接公开。Comparison::compare:126的完整 comparison 除分数外还包含rows、药量 ratio/贡献、matched_count、doctor/candidate_count、usage_differences和normalization中的 denominator/contribution_sum,足以反推指标。未授权应移除整个 comparison 对象。models:310的扁平reason取自body.comparison.reason,也是比较派生信息;无结果任务的reason则是生成错误信息。应区别处理或在统一投影中使用保留的error_message展示模型失败原因,避免比较 reason 保留下来。formatBatch:348还输出comparison_type,baseline_eligible,baseline_exclusion_reasons,也应从未授权响应中移除。coverage_status/source_summary/missing属于报告资料覆盖信息,可保留。- 不把
body_cipher/context_cipher/progress_cipher/ 原始 AI 调用响应增加到任何返回值。现有 Progress 采用固定字段投影并本地生成 label/notice,可继续复用;比较阶段名称本身不包含指标结果。 - 受保护字段应该被完全省略,而非伪装为 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. 可复用测试模式(本次未运行)
- 纯函数/契约测试:
server/tests/PrescriptionAiProgressTest.php。直接 require autoload、不初始化 App,用固定 fixture 检查允许字段;策略匹配、规范化、投影最适合此方式。 - 无环境隔离桩:
server/tests/DiagnosisValidateEditSceneTest.php:9创建new think\App()但不 initialize;在真实类加载前以 class_alias 安装内存模型/DB/Auth double。适合验证真实入口会拒绝未授权 statistics 与配置写入,避免初始化业务 .env。 - 真实加密/数据库 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 多角色及账号。 - 迁移幂等:QueueTest 重放 migration 两次并检查菜单数,可直接复用新菜单和空策略迁移的验证方式。
- 多角色缓存边界:
AdminMultiRoleRegressionTest.php说明角色 ID 去重排序及撤销角色后 token/cache 失效;新指标策略应按实时 role_id 与新策略判断,不从旧 token 缓存白名单结果。 - 本地配置文件测试:
PrescriptionAiConfigurationTest.php使用自定义 env fixture,绝不 bootstrap。可用于确认本次没有意外改变模型启用/生成配置。
最低必要用例:默认空/非法策略全部不可见(包括 root);只选角色/只选用户/任一匹配/多角色并集/其它账号不匹配;禁用或软删除账号、删除角色、实时撤销;未授权仍能读既有报告内容但 statuses/reports/detail 任何层均不存在比较字段或未知原始 body;授权用户数值保持不变;未授权 statistics 拒绝而不聚合;非管理者不能读名单、改名单或列选项;root 可管理但不会因此获得 view;已有处方/来源权限撤销仍拒绝报告。加入带唯一哨兵的嵌套 comparison/unknown raw body fixture,序列化整个响应检验哨兵不存在,不能只验证 UI 字段或顶层 score。
不建议直接运行会 App::initialize() 的旧测试(例如 PrescriptionAiSecretSafetyTest)来证明本特性;本功能可以完全使用上述隔离模式完成有效验证。