This commit is contained in:
gr
2026-09-24 09:45:44 +08:00
parent dbf474ddd7
commit bd22e5f476
38 changed files with 19152 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
# AI 助手(MCP)只读数据查询:实现与部署
日期:2026-09-24。状态:已在本地一次性测试库完成实现与测试;未部署线上、未迁移线上数据库。
对接方:行知 AI 工作助手(方案见行知项目 `docs/zyt-mcp-plan.md`)。员工在行知里用甄养堂后台账号密码绑定,之后 AI 按该账号自己的权限和数据范围只读查询甄养堂数据。
## 1. 改动范围
**只新增文件,不修改任何已有接口、控制器、Logic、中间件或配置文件。**
| 位置 | 内容 |
|---|---|
| `server/app/mcp/controller/` | `IndexController`(`POST /mcp`,MCP 端点)、`AuthController`(`/mcp/auth/grant|revoke|whoami`)、`AdminController`(后台管理页用的 `/mcp/admin/*`) |
| `server/app/mcp/service/` | 授权令牌、权限判断(默认拒绝)、数据目录、进程内调用(只读事务)、字段脱敏、审计、限流、MCP 协议、工具 |
| `server/app/mcp/catalog/` | `generated.php`(全部后台接口盘点,脚本生成)、`resources.php` + `review/*.php`(人工审核结论) |
| `server/app/mcp/cli/` | `catalog.php`(重新盘点接口)、`probe.php`(以某账号身份在只读事务里逐个试跑资源,用于审核)、`coverage.php`(逐张表检查覆盖,`--write-tables` 为没有后台页面的业务表生成数据表资源) |
| `server/database/migrations/2026_09_24_ai_mcp.sql` | 新表 `zyt_ai_grant`、`zyt_ai_access_log`;“AI 助手”菜单及权限点 |
| `server/tests/AiMcp*Test.php` | 单元测试、只读保护测试、HTTP 契约测试 |
| `admin/src/api/ai_mcp.ts`、`admin/src/views/ai_mcp/` | 后台页面:AI 授权管理、AI 访问日志、AI 数据目录 |
## 2. 工作方式
1. **授权**:`POST /mcp/auth/grant`(账号 + 密码)校验与后台登录相同的密码算法,再检查:未停用、已完成首次改密(`is_paw=1`)、企微强制绑定规则、拥有 `ai.mcp/access` 权限点。通过后签发 `zyt_ai_` 开头的随机令牌,库里只存 SHA-256。
- 与后台登录会话(`zyt_admin_session`)完全独立:不占终端、不受 IP 绑定影响,不会挤掉浏览器、医生工作站或企微客服端。
- 失败锁定按账号计(5 次 / 30 分钟),另按来源 IP 限速;账号不存在与密码错误给同样提示。
- 同一客户端实例重新绑定时旧令牌自动作废。
2. **每次调用都实时校验**:令牌有效期(默认 90 天)、闲置(默认 30 天)、账号未删除/未停用、密码未修改(签发时记录密码指纹,改密即失效)、仍有 `ai.mcp/access`。角色权限实时计算,调整角色立即生效。
3. **查询执行**:AI 只能查询“数据目录”里已开放的资源。每次查询:
- 权限点必须已在菜单登记、未停用,且该账号拥有(**默认拒绝**;不沿用后台“未登记接口任何人可访问”的规则,也不沿用 `progress_board` 等旁路);
- 参数白名单:去掉导出、关闭分页、扩大数据范围的参数,分页强制 ≤ 50 条,日期跨度 ≤ 366 天;
- 在当前进程内构造一个只含白名单参数的 GET 请求,挂上与登录中间件同结构的 `adminInfo`,调用**后台原有的控制器方法**(或审核文件指定的只读 Logic 方法),数据范围逻辑原样生效;
- 整个调用包在 `READ ONLY` 事务里,结束一律回滚:任何写库都会报错并撤销,AI 查询不会改动数据;单条 SQL 10 秒超时;
- 返回前脱敏:删除密码、盐、令牌、密钥、证书、加密字段;手机号、身份证号、住址、银行卡、附件地址按权限脱敏(拥有 `tcm.diagnosis/phonePlain` 可见明文手机号,拥有 `ai.mcp/sensitive` 可见全部);
- 写 `zyt_ai_access_log`:账号、工具、资源、参数(已脱敏)、返回记录 ID、行知任务号(`X-Xingzhi-Task-Id`)。
4. **数据目录**:`generated.php` 盘点了全部 510 个后台接口;`review/*.php` 逐个给出结论(开放 / 待整改+原因 / 不开放+原因),2026-09-24 审核 250 条:开放 137(含 19 张数据表资源)、待整改 27、不开放 86;另有 231 个写操作接口自动不开放。137 张表:67 张经接口覆盖、19 张经数据表资源覆盖(默认仅 root,权限点 `ai.mcp/tables`)、51 张为凭据/配置/日志等系统表。未审核的接口按保守规则处理:写操作、POST、免登录、系统配置/工具类一律不开放;详情类、调用外部接口、疑似写库、权限点未登记的一律待整改。后台“AI 数据目录”页可查看每个资源的状态和原因。
## 3. MCP 接口
- 端点:`POST https://admin.zhenyangtang.com.cn/mcp`,Streamable HTTP,只返回 JSON,无会话;支持协议 2025-11-25 / 2025-06-18 / 2025-03-26;`GET` 返回 405。
- 请求头:`Authorization: Bearer zyt_ai_…`(必需)、`MCP-Protocol-Version`、`X-Xingzhi-Task-Id`(可选,写入审计)。浏览器 `Origin` 不在白名单一律 403。
- 工具(全部标注 `readOnlyHint`):`zyt_whoami`、`zyt_catalog`、`zyt_describe`、`zyt_query`、`zyt_get`、`zyt_count`、`zyt_file`,以及按权限出现的快捷统计工具 `zyt_stats_appointments`、`zyt_stats_doctor_workload`、`zyt_stats_orders`、`zyt_stats_prescription_orders`、`zyt_stats_performance`、`zyt_my_patients`、`zyt_roster`。
- 授权接口:`POST /mcp/auth/grant`、`POST /mcp/auth/revoke`(Bearer)、`GET /mcp/auth/whoami`(Bearer),返回与后台一致的 `{code, show, msg, data}`;失败时 `data.reason` 为 `invalid_credentials / disabled / need_change_password / need_bind_wecom / no_ai_permission / locked / feature_disabled / ip_not_allowed / invalid_request`。
## 4. 配置(服务器私密 `server/.env`)
```ini
[AI_MCP]
ENABLED = false ; 默认关闭,验证通过后再改为 true
TOKEN_TTL_DAYS = 90
TOKEN_IDLE_DAYS = 30
ALLOWED_IPS = ; 行知服务器出口 IP,逗号分隔;为空不限制(生产建议填写)
ALLOWED_ORIGINS = ; 一般留空:服务端调用不带 Origin
RATE_PER_MINUTE = 60 ; 每个账号每分钟调用次数
DAILY_ROWS = 5000 ; 每个账号每天通过 AI 返回的最大行数
MAX_PAGE_SIZE = 50
MAX_RANGE_DAYS = 366
LOG_RETENTION_DAYS = 180 ; 访问日志保留天数(《网络安全法》要求不少于六个月)
LOCK_FAILURES = 5
LOCK_MINUTES = 30
REQUIRE_PASSWORD_CHANGED = true
```
限流和锁定使用系统缓存;线上建议 `cache.driver = redis`(文件缓存下计数为近似值)。服务在负载均衡或 CDN 之后时,需先让 `request()->ip()` 取到真实客户端 IP,`ALLOWED_IPS` 才有意义。
## 5. 部署顺序
1. 备份数据库;执行 `server/database/migrations/2026_09_24_ai_mcp.sql`(默认前缀 `zyt_`,可重复执行,只新增表和菜单)。
2. 同步 `server/app/mcp/` 与后台前端(`admin` 重新构建,新增三个页面)。代码同步后 `ENABLED` 仍为 `false`,对现有功能无影响。
3. 在“权限管理 > 角色”中给试点角色勾选“允许 AI 助手查询”(`ai.mcp/access`),管理员角色勾选“AI 授权管理 / AI 访问日志 / AI 数据目录”。默认不授予任何角色。
4. 在预发/测试库上以 root 账号运行 `php app/mcp/cli/probe.php --admin=<root 的 ID>` 与 `php app/mcp/cli/coverage.php`,确认没有 `writes`(只读保护拦截)结果、没有未覆盖的表;有的话在对应 `review/*.php` 把该资源改为待整改或改用只读 Logic,或用 `coverage.php --write-tables` 补数据表资源。
5. `.env` 设置 `[AI_MCP] ENABLED = true` 与 `ALLOWED_IPS`;nginx 对 `/mcp` 与 `/mcp/auth/grant` 加 `limit_req`,并确认不缓冲响应。
6. 在行知管理员页面配置组织连接器:MCP 地址 `https://admin.zhenyangtang.com.cn/mcp`,授权/撤销/身份接口为同域的 `/mcp/auth/grant|revoke|whoami`,工具名前缀关闭,只读工具自动放行。
新增后台接口或页面后:运行 `php app/mcp/cli/catalog.php --write` 重新盘点,并在 `review/` 给新资源写结论;`php server/tests/AiMcpUnitTest.php` 会报告尚未审核的只读接口数量。
## 6. 验证
```sh
php server/tests/AiMcpUnitTest.php
# 以下两项需要一次性测试库(库名以 _test 结尾)与指向它的运行实例
AI_MCP_TEST_MYSQL=1 php server/tests/AiMcpReadOnlyTest.php
AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpHttpContractTest.php
php app/mcp/cli/probe.php --admin=<ID> [--only=tcm.] # 默认不执行不开放的、会调外部接口的资源
php app/mcp/cli/coverage.php
```
注意:只读事务只能挡住写库,挡不住起进程、写缓存、调外部接口;这类接口在审核中一律不开放或待整改,探测脚本默认也不执行。
2026-09-24 本地结果(PHP 8.2.34 + MariaDB 10.11.19,表结构由仓库 SQL 重建):三个测试全部通过;契约测试覆盖授权门禁与锁定、协议协商、401/403/405、医生/医助/经理/root 各自的数据范围、脱敏与明文权限、扩大范围参数拦截、撤销/改密/停用/闲置/去权限后立即失效、审计记录与后台管理接口。与行知的端到端联调(真实行知后端与任务引擎 + 本模块)通过:两名行知用户分别绑定医生、医助账号,各自任务只拿到自己数据范围内的挂号记录,手机号已脱敏,审计日志记录了行知任务号和返回的记录 ID。
## 7. 回退
把 `.env` 的 `[AI_MCP] ENABLED` 改为 `false`:`/mcp` 与授权接口立即返回 503,行知侧查询自动失败并提示。新表和菜单保留即可,不需要回滚数据库。需要彻底停用时,在“AI 授权管理”撤销全部授权。
## 8. 已知限制与后续
- 后台部分接口本身缺少逐条权限校验或存在扩大范围的参数(见行知方案文档“zyt 安全前置整改”一节)。MCP 已按“默认拒绝 + 参数白名单 + 行守卫”规避,但后台网页仍受影响,建议另行修复。
- 未登记为菜单权限点的接口在 MCP 中一律不开放;如需开放,先按 `2026_08_12_call_transcription_permissions.sql` 的做法登记权限点。
- 阶段三可选:接入 IAM(Keycloak)授权码 + PKCE 绑定,密码不再经过行知。