Files
zyt/docs/plans/ai-mcp-2026-09-24.md
T
2026-09-24 11:48:20 +08:00

124 lines
17 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 助手(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_my_patients`、`zyt_roster`,和业绩工具 `zyt_perf_assistants`、`zyt_perf_doctors`、`zyt_stats_performance`、`zyt_perf_trend`(见下)。
### 业绩工具(2026-09-24 增补,`service/PerfTools.php`)
线上发现“查业绩”很慢:模型为了排行逐部门、逐医助、逐天调用明细接口(一次问答 40 多次调用),还因为参数写法、未登记的子权限点和并发时的缓存读写失败而中断。业绩工具一次调用给出排名、合计和口径,并附带统计图:
| 工具 | 数据来源(以调用账号身份执行后台原接口,口径与页面一致) | 权限(自身未登记时依次回退) |
|---|---|---|
| `zyt_perf_assistants` 医助业绩排行 | 业绩看板·医助排行榜 `YejiStatsLogic::assistantLeaderboards`:诊金、成交订单数(后台“接诊诊单”)、面诊完成数(后台“接诊单数”)、预约数、被指派数、进线数、每进线诊金;排名、合计、部门小计;单人时附处方业务订单列表对账 | `stats.yejiStats/leaderboard` → `stats.yejiStats/tabLeaderboard` → `fans/yeji` |
| `zyt_perf_doctors` 医生业绩排行 | 业绩看板·医生统计 `DoctorDailyStatsLogic::overview`:成交金额、成交订单数(后台“接诊诊单”)、客单价、挂号总数/面诊完成/过号/取消、挂号成交率、系统/手动开方 | `stats.doctorDailyStats/overview` → `stats.yejiStats/tabDoctor` → `fans/yeji` |
| `zyt_stats_performance` 部门业绩看板 | 业绩看板·甄养堂诊金 `YejiStatsLogic::overview`:各部门合计业绩、成交订单数、面诊完成数、预约数、进线数、被指派数、投放成本、ROI | `stats.yejiStats/overview` → `stats.yejiStats/tabZyyt` → `fans/yeji` |
| `zyt_perf_trend` 业绩走势 | 一条按日分组的只读聚合 SQL(在同样的只读事务里),按日/周/月归并,可对比最多 4 人。医助口径同排行榜诊金(订单创建人,剔除取消/拒收/退款);医生口径同医生统计成交金额(开方医生,另剔除发生过退款的订单) | 按医助同医助排行,按医生同医生统计 |
- 参数:`period`(today / yesterday / this_week / last_week / this_month / last_month / last_7_days / last_30_days,由服务器按当天计算)或 `start_date`/`end_date`;`dept` 可写部门名称(只在账号可见的部门里匹配);`sort_by`、`top`、`name`/`*_id`;走势另有 `by`、`names`/`ids`、`granularity`、`metric`。
- 数据范围:与后台一致(经理看本部门及下级、医助只看自己、医生统计只含可见医生);走势指定的人必须在账号可见范围内。
- 统计图:结果文字里带一个 ```` ```chart ```` 代码块(JSON:`type` bar/column/line、`title`、`subtitle`、`unit`、`labels`、`series`),行知把它画成统计图(可切换表格);`structuredContent.chart` 同时提供。只有一行时不画图。
- 权限回退(审核文件 `perm_fallback`):子接口自身已登记时与后台完全一致;未登记时后台对该接口不校验、只靠 Tab/页面权限控制可见,这里改用第一个已登记的 Tab/页面权限,不会比后台页面更宽。同样的回退也加在了业绩看板的部门/渠道下拉和各明细子接口上。
- 业绩看板、医生统计、提成结算、综合转化的单条 SQL 超时放宽到 30 秒(审核文件 `timeout`;后台控制器自己放宽到 120 秒)。
- **指标命名(同日第二次修正)**:线上有人问“洛阳高坤艳本月才 21 单,为什么显示 96 单”。96 是后台排行榜的“接诊单数”——面诊完成的挂号人次,不是订单;订单数是“接诊诊单”(她的处方业务订单列表共 21 条,顶部业绩 ¥25,212 对应的是扣掉拒收/退款后的订单)。工具原先照搬后台列名还标成“单”,模型就把 96 说成了 96 单。现在输出一律用说清楚“数的是什么”的名称并附后台列名对账:面诊完成数(人次,后台“接诊单数”)、成交订单数(单,后台“接诊诊单”)、预约数(后台“预约诊单”)、每进线诊金(后台“接诊率”)、挂号成交率(后台“挂号率”)等;结果带 `definitions`(每个指标的口径),按非订单指标排名时摘要和图下说明都会提示“不是订单数”。
- **单人对账**:`zyt_perf_assistants` 只匹配到一位医助时,同时按“医助(订单创建人)+ 同一时间段”查处方业务订单列表,返回 `order_list`(列表条数含全部状态、金额、其中计入业绩的金额、未计入业绩的条数),摘要写明“列表共 N 条……业绩只算未取消、未拒收、未退款的订单,所以成交订单是 M 单”。医生同理(按开方医生),但只对能看全量业务订单的账号给出,因为非全量角色在列表里按医生筛选只能看到自己创建的订单。
- **统计明细翻页**:进线明细、被指派明细等“统计类”资源自己分页,以前 `zyt_query` 的外层 `page`/`page_size` 对它们不生效,模型翻页每次都拿到第一页,看起来像“同一条记录反复出现”;现在外层参数会传给这些接口,结果带 `result.paging`(总数、页码、是否还有下一页)。另外同一页里 ID 重复的行会去重并在结果里说明。
- 同时修复:`params` 里写的 `page`/`page_size` 自动当作分页;限流与每日行数计数在缓存读写出错时放行并记日志(文件缓存下并发调用可能读到写了一半的文件,之前会让整次查询变成“内部错误”);内部错误提示带上异常类型,便于对照服务器日志。
- 授权接口:`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
AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpPerfTest.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。
业绩工具(同日增补):`AiMcpPerfTest` 通过(医助诊金计入部分退款单、医生成交金额不计;取消单都不计;经理看两名下属医助/医生、医助只看自己;部门名称解析;按日/周/月走势;图表输出;排行榜权限点停用时回退到 Tab 权限);原有三个测试与探测脚本无回归;行知端到端(真实行知后端 + MCP 客户端 + 本模块)确认模型拿到 4 个业绩工具、图表代码块完整到达并存入回答。上线只需同步 `server/app/mcp/`(新增 `service/PerfTools.php`,改动 `service/{Tools,Catalog,Dispatcher,RateLimiter,Protocol}.php`、`catalog/review/{stats.php,README.md}`),不涉及数据库。第二次修正(指标命名、单人对账、统计明细翻页)只改 `service/{PerfTools,Tools,Protocol}.php` 与 `tests/AiMcpPerfTest.php`,四个测试全部通过。
## 7. 回退
把 `.env` 的 `[AI_MCP] ENABLED` 改为 `false`:`/mcp` 与授权接口立即返回 503,行知侧查询自动失败并提示。新表和菜单保留即可,不需要回滚数据库。需要彻底停用时,在“AI 授权管理”撤销全部授权。
## 8. 已知限制与后续
- 后台部分接口本身缺少逐条权限校验或存在扩大范围的参数(见行知方案文档“zyt 安全前置整改”一节)。MCP 已按“默认拒绝 + 参数白名单 + 行守卫”规避,但后台网页仍受影响,建议另行修复。
- 未登记为菜单权限点的接口在 MCP 中一律不开放;如需开放,先按 `2026_08_12_call_transcription_permissions.sql` 的做法登记权限点。
- 阶段三可选:接入 IAM(Keycloak)授权码 + PKCE 绑定,密码不再经过行知。