diff --git a/docs/plans/ai-mcp-2026-09-24.md b/docs/plans/ai-mcp-2026-09-24.md index 4feb5258a..707dd5888 100644 --- a/docs/plans/ai-mcp-2026-09-24.md +++ b/docs/plans/ai-mcp-2026-09-24.md @@ -1,6 +1,6 @@ # AI 助手(MCP)只读数据查询:实现与部署 -日期:2026-09-24。状态:已在本地一次性测试库完成实现与测试;未部署线上、未迁移线上数据库。 +日期:2026-09-24。状态:只读查询与业绩工具已上线;AI 后台浏览器(第 3 节末、第 5 节第 7 步)已在本地一次性测试库完成实现与测试,待同步上线并执行 `2026_09_24_ai_mcp_console.sql`。 对接方:行知 AI 工作助手(方案见行知项目 `docs/zyt-mcp-plan.md`)。员工在行知里用甄养堂后台账号密码绑定,之后 AI 按该账号自己的权限和数据范围只读查询甄养堂数据。 @@ -17,6 +17,7 @@ | `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 数据目录 | +| `server/app/mcp/controller/ConsoleController.php`、`service/ConsoleService.php`、`server/database/migrations/2026_09_24_ai_mcp_console.sql` | AI 后台浏览器会话(`/mcp/console/open|close`)与权限点 `ai.mcp/console`(同日增补,见第 3 节末) | ## 2. 工作方式 @@ -62,6 +63,19 @@ - 同时修复:`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`。 +### AI 后台浏览器(2026-09-24 增补,`controller/ConsoleController.php`、`service/ConsoleService.php`) + +MCP 工具只能查数据目录里开放的资源。为了让 AI 也能看后台网页上才有的内容,并在成员逐次批准下代为操作,行知在自己的服务器上运行一个内置浏览器(无界面 Edge),用成员已绑定的 AI 授权免密码登录本后台。本模块只负责换取和收回这个登录: + +- `POST /mcp/console/open`(`Authorization: Bearer zyt_ai_…`,可带 `X-Xingzhi-Task-Id`):为该账号签发“AI 浏览器”专用终端(`terminal = 8`)的后台登录,返回 `token`、`expire_time`、`local_storage`(后台前端读取的 `like_admin_token`)和 `start_path`。令牌只写进行知服务器上的浏览器,不给模型、不进任务记录、不回传网页。 + - 沿用后台原有登录体系(`AdminTokenService::setToken` 与 `zyt_admin_session`),不改任何中间件;独立终端,不会挤掉该账号在电脑、手机、企微客服端的登录(关闭“多处登录”时也一样);同一账号的 AI 浏览器共用一个会话,已有有效会话时直接沿用。 + - 有效期默认 2 小时(`CONSOLE_TTL_MINUTES`);签发时清掉登录缓存,由浏览器第一次请求按它自己的出口 IP 重建,后台的登录 IP 校验照常生效。 + - 前提:AI 授权有效(与 MCP 相同的实时校验),且账号有新权限点 `ai.mcp/console`“允许 AI 使用后台浏览器”(默认不授予任何角色,root 自动拥有)。没有该权限返回 `code = 0`、`data.reason = no_console_permission`,不建会话;授权无效返回 401。 +- `POST /mcp/console/close`:作废该会话(到期时间改为过去并清登录缓存;文件缓存下连后台应用自己的缓存目录 `runtime/adminapi/cache` 一并清理)。行知关闭浏览器、空闲 15 分钟、打开满 2 小时、成员解除或重新绑定时调用;授权已撤销、过期或失去权限时也照样注销(只要令牌确实由本系统签发过)。 +- 自动收回:AI 授权被撤销或失效(后台撤销、改密、停用、删除、过期)时,同时作废该账号的 AI 浏览器会话;账号失去 `ai.mcp/access` 或需要先绑定企微时,授权保留,但会话立即作废。 +- 审计:`zyt_ai_access_log` 记录 `console.open`(成功或拒绝及原因、行知任务号)和 `console.close`。浏览器里的操作走后台自己的接口和操作日志,登录终端为 8,可与员工本人的操作区分。 +- 这是完整的后台登录(按账号自己的菜单权限和数据范围),**不经过** MCP 的只读事务和脱敏。行知侧的保护:只允许打开本后台域名;AI 触发的每个写请求(POST/PUT/PATCH/DELETE 等)先在任务里请成员批准(只能“允许本次”);成员在实时画面里亲自操作的直接提交;无人触发的后台提交、跳到其他网站、WebSocket、下载一律拦截;组织可把后台设为只读(任何写请求都拦截);给模型的页面文字隐藏手机号和身份证号;截图只给成员看。 + ## 4. 配置(服务器私密 `server/.env`) ```ini @@ -79,6 +93,8 @@ LOG_RETENTION_DAYS = 180 ; 访问日志保留天数(《网络安全法》要 LOCK_FAILURES = 5 LOCK_MINUTES = 30 REQUIRE_PASSWORD_CHANGED = true +CONSOLE_ENABLED = true ; AI 后台浏览器总开关(另需角色勾选 ai.mcp/console);紧急停用设为 false +CONSOLE_TTL_MINUTES = 120 ; AI 浏览器后台会话有效期(10–480 分钟) ``` 限流和锁定使用系统缓存;线上建议 `cache.driver = redis`(文件缓存下计数为近似值)。服务在负载均衡或 CDN 之后时,需先让 `request()->ip()` 取到真实客户端 IP,`ALLOWED_IPS` 才有意义。 @@ -91,6 +107,7 @@ REQUIRE_PASSWORD_CHANGED = true 4. 在预发/测试库上以 root 账号运行 `php app/mcp/cli/probe.php --admin=` 与 `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`,工具名前缀关闭,只读工具自动放行。 +7. **AI 后台浏览器(同日增补)**:同步 `server/app/mcp/`(新增 `controller/ConsoleController.php`、`service/ConsoleService.php`,改动 `service/{GrantService,McpConfig}.php`);执行 `server/database/migrations/2026_09_24_ai_mcp_console.sql`(只新增一个按钮权限点,可重复执行);在“权限管理 > 角色”给需要的角色勾选“允许 AI 使用后台浏览器”(`ai.mcp/console`)。行知侧已配置好组织连接器的“管理后台”:地址 `https://admin.zhenyangtang.com.cn/admin/`,免登录接口 `/mcp/console/open`,退出接口 `/mcp/console/close`,修改逐次审批。 新增后台接口或页面后:运行 `php app/mcp/cli/catalog.php --write` 重新盘点,并在 `review/` 给新资源写结论;`php server/tests/AiMcpUnitTest.php` 会报告尚未审核的只读接口数量。 @@ -102,6 +119,7 @@ php server/tests/AiMcpUnitTest.php 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 # 业绩工具:口径、排名、数据范围、走势、图表、权限回退 +AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpConsoleTest.php # AI 后台浏览器(需先执行 2026_09_24_ai_mcp_console.sql) php app/mcp/cli/probe.php --admin= [--only=tcm.] # 默认不执行不开放的、会调外部接口的资源 php app/mcp/cli/coverage.php ``` @@ -112,12 +130,18 @@ php app/mcp/cli/coverage.php 业绩工具(同日增补):`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`,四个测试全部通过。 +AI 后台浏览器(同日增补):`AiMcpConsoleTest` 通过——无效授权 401;没有 `ai.mcp/console` 被拒且不建会话;签发 terminal=8 会话、有效期 2 小时,关闭“多处登录”时电脑端登录不受影响;换来的令牌能直接访问后台接口;再次打开沿用同一会话;关闭后令牌立即失效、重复关闭无害、重新打开换新令牌;撤销授权同时作废会话;授权失效后仍能注销,未知令牌不能;失去 `ai.mcp/access` 时会话立即作废而授权保留;审计含行知任务号和拒绝记录。原有四个测试无回归。行知联调:行知服务器上的内置浏览器(Playwright 驱动 Edge)用本接口免密码打开本地实例的后台页面并读取内容,AI 触发的写请求停在审批(本地联调时后台前端的生产构建写死了线上接口地址,用请求改写指向本地实例)。 + ## 7. 回退 把 `.env` 的 `[AI_MCP] ENABLED` 改为 `false`:`/mcp` 与授权接口立即返回 503,行知侧查询自动失败并提示。新表和菜单保留即可,不需要回滚数据库。需要彻底停用时,在“AI 授权管理”撤销全部授权。 +只停用 AI 后台浏览器:`.env` 设 `[AI_MCP] CONSOLE_ENABLED = false`(不能再打开新会话),或在角色里取消“允许 AI 使用后台浏览器”。已打开的会话在行知空闲 15 分钟或到期(最长 2 小时)时收回;需要立即收回时,在“AI 授权管理”撤销相关成员的授权。 + ## 8. 已知限制与后续 - 后台部分接口本身缺少逐条权限校验或存在扩大范围的参数(见行知方案文档“zyt 安全前置整改”一节)。MCP 已按“默认拒绝 + 参数白名单 + 行守卫”规避,但后台网页仍受影响,建议另行修复。 - 未登记为菜单权限点的接口在 MCP 中一律不开放;如需开放,先按 `2026_08_12_call_transcription_permissions.sql` 的做法登记权限点。 - 阶段三可选:接入 IAM(Keycloak)授权码 + PKCE 绑定,密码不再经过行知。 +- AI 后台浏览器按请求方法区分读写:行知只拦截非 GET/HEAD/OPTIONS 请求。后台若有用 GET 修改数据的接口,无法被审批拦住,建议这类接口改为 POST(导出下载在行知浏览器里已禁用)。 +- 取消角色的 `ai.mcp/console` 只阻止新开会话,不会立刻结束已打开的会话(见第 7 节)。 diff --git a/server/app/mcp/controller/ConsoleController.php b/server/app/mcp/controller/ConsoleController.php new file mode 100644 index 000000000..94feeb389 --- /dev/null +++ b/server/app/mcp/controller/ConsoleController.php @@ -0,0 +1,83 @@ +blocked(); + if ($blocked) { + return $blocked; + } + try { + $identity = GrantService::authenticate($this->request); + } catch (McpException $e) { + return Guard::envelope(-1, $e->getMessage(), ['reason' => $e->reason], 401); + } + $entry = ['grant_id' => $identity->grant['id'], 'admin_id' => $identity->adminId, 'tool' => 'console.open', 'resource' => 'console', + 'client_task_id' => (string) $this->request->header('x-xingzhi-task-id', ''), 'ip' => $this->request->ip()]; + try { + $data = ConsoleService::open($identity); + AuditLogger::log($entry + ['status' => 'ok']); + return Guard::envelope(1, '', $data); + } catch (McpException $e) { + AuditLogger::log($entry + ['status' => 'denied', 'message' => $e->reason]); + return Guard::envelope(0, $e->getMessage(), ['reason' => $e->reason], 200, 1); + } + } + + public function close(): Response + { + $blocked = $this->blocked(); + if ($blocked) { + return $blocked; + } + try { + $identity = GrantService::authenticate($this->request); + [$grantId, $adminId, $note] = [(int) $identity->grant['id'], $identity->adminId, '']; + } catch (McpException $e) { + // 授权已撤销、过期或账号失去权限时也照样注销它换来的后台会话:收回登录不需要授权仍然有效 + $grant = GrantService::findByToken($this->request); + if (!$grant) { + return Guard::envelope(-1, $e->getMessage(), ['reason' => $e->reason], 401); + } + [$grantId, $adminId, $note] = [(int) $grant['id'], (int) $grant['admin_id'], ' (grant ' . $e->reason . ')']; + } + $closed = ConsoleService::closeForAdmin($adminId); + AuditLogger::log(['grant_id' => $grantId, 'admin_id' => $adminId, 'tool' => 'console.close', 'resource' => 'console', + 'status' => 'ok', 'message' => ($closed ? 'closed' : 'none') . $note, 'ip' => $this->request->ip()]); + return Guard::envelope(1, $closed ? '已关闭' : '没有需要关闭的会话', ['closed' => $closed]); + } + + private function blocked(): ?Response + { + if (!McpConfig::enabled()) { + return Guard::envelope(0, 'AI 助手接口未启用', ['reason' => 'feature_disabled'], 503, 1); + } + if ($this->request->method(true) !== 'POST') { + return response('', 405)->header(['Allow' => 'POST']); + } + $guard = Guard::check($this->request); + if ($guard !== null) { + return Guard::envelope(0, $guard[1], ['reason' => $guard[2]], 200, 1); + } + return null; + } +} diff --git a/server/app/mcp/service/ConsoleService.php b/server/app/mcp/service/ConsoleService.php new file mode 100644 index 000000000..7b57c53fd --- /dev/null +++ b/server/app/mcp/service/ConsoleService.php @@ -0,0 +1,94 @@ +can(self::PERMISSION)) { + throw new McpException('当前账号没有“允许 AI 使用后台浏览器”权限(ai.mcp/console),请联系管理员在角色里勾选', 'no_console_permission', 403); + } + // 第三个参数 1:同一终端已有未过期的会话就沿用,不轮换令牌(不影响其他终端) + AdminTokenService::setToken($identity->adminId, self::TERMINAL, 1); + $session = AdminSession::where(['admin_id' => $identity->adminId, 'terminal' => self::TERMINAL])->findOrEmpty(); + if ($session->isEmpty()) { + throw new McpException('后台会话创建失败,请稍后再试', 'console_failed', 500); + } + $now = time(); + $session->expire_time = $now + McpConfig::consoleTtlMinutes() * 60; + $session->update_time = $now; + $session->save(); + // setToken 按本次请求的 IP 写了登录缓存;删掉,让浏览器第一次请求时按它自己的 IP 重建 + self::forgetLogin((string) $session->token); + return [ + 'token' => (string) $session->token, + 'expire_time' => (int) $session->expire_time, + 'terminal' => self::TERMINAL, + 'local_storage' => [self::STORAGE_KEY => json_encode(['expire' => '', 'value' => (string) $session->token])], + 'start_path' => '/admin/', + ]; + } + + /** 作废该账号的 AI 浏览器会话;没有有效会话时返回 false */ + public static function closeForAdmin(int $adminId): bool + { + $session = AdminSession::where(['admin_id' => $adminId, 'terminal' => self::TERMINAL])->findOrEmpty(); + if ($session->isEmpty() || (int) $session->expire_time <= time()) { + return false; + } + // 到期时间记为上一秒:setToken 只在“已过期”(expire_time < 当前秒) 时换新令牌,同一秒内重新打开也不会复用旧令牌 + $session->expire_time = time() - 1; + $session->update_time = time(); + $session->save(); + self::forgetLogin((string) $session->token); + return true; + } + + /** + * 清掉后台对这个令牌的登录缓存(后台先查缓存、查不到才回表看到期时间,所以作废会话必须清缓存)。 + * 文件缓存按应用分目录(config/cache.php 的 path 为空):后台请求用 runtime/adminapi/cache, + * 本模块在 runtime/mcp/cache,所以文件缓存时要按后台的目录再删一次;redis 等共享缓存删一次即可。 + */ + private static function forgetLogin(string $token): void + { + (new AdminTokenCache())->deleteAdminInfo($token); + if ((string) config('cache.default') !== 'file') { + return; + } + $options = (array) config('cache.stores.file'); + if (($options['path'] ?? '') !== '') { + return; // 配了固定目录:所有应用共用,上面已经删过 + } + $options['path'] = app()->getRootPath() . 'runtime' . DIRECTORY_SEPARATOR . 'adminapi' . DIRECTORY_SEPARATOR . 'cache'; + (new FileCache(app(), $options))->delete('token_admin_' . $token); + } +} diff --git a/server/app/mcp/service/GrantService.php b/server/app/mcp/service/GrantService.php index 7a4017925..30bc7ee80 100644 --- a/server/app/mcp/service/GrantService.php +++ b/server/app/mcp/service/GrantService.php @@ -136,6 +136,9 @@ class GrantService } catch (McpException $e) { if ($e->reason === 'disabled') { self::close((int) $grant['id'], self::STATUS_REVOKED, 'admin_disabled'); + } else { + // 授权保留(补上权限或绑定企微后可继续用),但它换来的 AI 浏览器后台会话立即作废 + self::endConsole((int) $grant['admin_id']); } throw new McpException($e->getMessage(), $e->reason, 401); } @@ -154,13 +157,34 @@ class GrantService public static function close(int $grantId, int $status, string $reason, int $by = 0): void { $now = time(); - Db::name('ai_grant')->where(['id' => $grantId, 'status' => self::STATUS_ACTIVE])->update([ + $closed = Db::name('ai_grant')->where(['id' => $grantId, 'status' => self::STATUS_ACTIVE])->update([ 'status' => $status, 'revoke_time' => $now, 'revoke_by' => $by, 'revoke_reason' => substr($reason, 0, 64), 'update_time' => $now, ]); + // 授权失效时,用它换来的 AI 浏览器后台会话一并作废 + if ($closed) { + self::endConsole((int) Db::name('ai_grant')->where('id', $grantId)->value('admin_id')); + } + } + + /** 按令牌找授权记录,不论是否仍有效;只用于注销 AI 浏览器会话这类“收回”操作 */ + public static function findByToken(Request $request): array + { + $token = TokenService::fromRequest($request); + return $token === '' ? [] : (Db::name('ai_grant')->where('token_hash', TokenService::hash($token))->find() ?: []); + } + + /** 作废该账号的 AI 浏览器后台会话;失败只记日志,不影响调用方 */ + private static function endConsole(int $adminId): void + { + try { + ConsoleService::closeForAdmin($adminId); + } catch (\Throwable $e) { + \think\facade\Log::error('[ai_mcp] 作废 AI 浏览器会话失败: ' . $e->getMessage()); + } } public static function publicGrant(array $grant): array diff --git a/server/app/mcp/service/McpConfig.php b/server/app/mcp/service/McpConfig.php index e1dcb1f1d..a929a6969 100644 --- a/server/app/mcp/service/McpConfig.php +++ b/server/app/mcp/service/McpConfig.php @@ -115,6 +115,18 @@ class McpConfig return self::int('max_file_bytes', 5242880, 1024, 20971520); } + /** AI 后台浏览器总开关(还需要账号有 ai.mcp/console 权限点);紧急停用时设为 false */ + public static function consoleEnabled(): bool + { + return self::bool('console_enabled', true); + } + + /** AI 浏览器后台会话的有效期(分钟);行知空闲或关闭浏览器时会提前注销 */ + public static function consoleTtlMinutes(): int + { + return self::int('console_ttl_minutes', 120, 10, 480); + } + private static function raw(string $key) { return env('ai_mcp.' . $key); diff --git a/server/database/migrations/2026_09_24_ai_mcp_console.sql b/server/database/migrations/2026_09_24_ai_mcp_console.sql new file mode 100644 index 000000000..5cde96257 --- /dev/null +++ b/server/database/migrations/2026_09_24_ai_mcp_console.sql @@ -0,0 +1,18 @@ +-- AI 后台浏览器:新增权限点 ai.mcp/console(在 2026_09_24_ai_mcp.sql 之后执行)。 +-- 只新增菜单按钮,不修改任何已有表结构或已有菜单。可重复执行。表前缀如非 zyt_ 请整体替换。 +-- +-- ai.mcp/console 允许 AI 使用后台浏览器:行知可用该账号已绑定的 AI 授权,换一个“AI 浏览器”专用终端的后台登录, +-- 在行知服务器上的内置浏览器里打开本后台(不用输入密码,也不会挤掉该账号在电脑、手机、企微客服端的登录)。 +-- 这是完整的后台登录(按账号自己的菜单权限和数据范围);行知对其中每一个会修改数据的请求都先请用户审批。 +-- 默认不授予任何角色,请在「权限管理 > 角色」中按需勾选;root 自动拥有。 + +SET @ai_grant_id = (SELECT `id` FROM `zyt_system_menu` WHERE `perms` = 'ai.grant/lists' ORDER BY `id` ASC LIMIT 1); + +INSERT INTO `zyt_system_menu` + (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) +SELECT @ai_grant_id, 'A', '允许 AI 使用后台浏览器', '', 3, 'ai.mcp/console', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @ai_grant_id IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'ai.mcp/console'); + +COMMIT; diff --git a/server/tests/AiMcpConsoleTest.php b/server/tests/AiMcpConsoleTest.php new file mode 100644 index 000000000..981c24ab3 --- /dev/null +++ b/server/tests/AiMcpConsoleTest.php @@ -0,0 +1,164 @@ +initialize(); +$database = (string) config('database.connections.' . config('database.default') . '.database'); +aiMcpConsoleExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)"); +aiMcpConsoleExpect((int) Db::name('system_menu')->where('perms', 'ai.mcp/console')->count() === 1, 'run 2026_09_24_ai_mcp_console.sql on the test database first'); + +// ---------------------------------------------------------------- 夹具 +$now = time(); +$pwd = create_password('Test@123456', (string) config('project.unique_identification')); +Db::name('system_role')->whereIn('id', [293, 294])->delete(); +foreach ([293 => '仅 AI 查询', 294 => 'AI 查询 + 后台浏览器'] as $id => $name) { + Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-console-test', 'sort' => 0, 'data_scope' => 4, 'create_time' => $now, 'update_time' => $now]); +} +Db::name('system_role_menu')->whereIn('role_id', [293, 294])->delete(); +$menu = static fn (string $perm): int => (int) Db::name('system_menu')->where('perms', $perm)->value('id'); +foreach ([293 => ['ai.mcp/access'], 294 => ['ai.mcp/access', 'ai.mcp/console']] as $role => $perms) { + foreach ($perms as $perm) { + Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $menu($perm)]); + } +} +$admins = [93001 => ['c_plain', 293], 93002 => ['c_console', 294]]; +Db::name('admin')->whereIn('id', array_keys($admins))->delete(); +Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete(); +Db::name('admin_session')->whereIn('admin_id', array_keys($admins))->delete(); +Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete(); +Db::name('ai_access_log')->whereIn('admin_id', array_keys($admins))->delete(); +foreach ($admins as $id => [$account, $role]) { + // 关闭“多处登录”:若 AI 浏览器用的是电脑端终端,就会把下面这条电脑端会话挤掉 + Db::name('admin')->insert(['id' => $id, 'root' => 0, 'name' => $account, 'avatar' => '', 'account' => $account, 'password' => $pwd, 'multipoint_login' => 0, + 'is_paw' => 1, 'work_wechat_userid' => '', 'disable' => 0, 'phone' => '1360000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]); + Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $role]); +} +$pcToken = substr(md5('console-test-pc-' . $now), 0, 32); +Db::name('admin_session')->insert(['admin_id' => 93002, 'terminal' => 1, 'token' => $pcToken, 'update_time' => $now, 'expire_time' => $now + 3600]); +\think\facade\Cache::clear(); + +// ---------------------------------------------------------------- HTTP 工具 +function aiMcpConsoleHttp(string $method, string $url, ?array $body, array $headers): array +{ + $ch = curl_init($url); + $lines = ['Content-Type: application/json']; + foreach ($headers as $k => $v) { + $lines[] = $k . ': ' . $v; + } + curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_TIMEOUT => 60]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE)); + } + $raw = (string) curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + return [$status, json_decode($raw, true)]; +} + +$grant = static function (string $account) use ($base): string { + [, $body] = aiMcpConsoleHttp('POST', $base . '/mcp/auth/grant', ['account' => $account, 'password' => 'Test@123456', 'client' => 'xingzhi', 'client_instance' => 'console-test'], []); + aiMcpConsoleExpect(($body['code'] ?? null) === 1, "grant for {$account}: " . json_encode($body, JSON_UNESCAPED_UNICODE)); + return (string) $body['data']['token']; +}; +$console = static function (string $action, string $grantToken) use ($base): array { + [, $body] = aiMcpConsoleHttp('POST', $base . '/mcp/console/' . $action, [], ['Authorization' => 'Bearer ' . $grantToken, 'X-Xingzhi-Task-Id' => 'console-task']); + return (array) $body; +}; +$backOffice = static function (string $sessionToken) use ($base): array { + [, $body] = aiMcpConsoleHttp('GET', $base . '/adminapi/auth.admin/mySelf', null, ['token' => $sessionToken]); + return (array) $body; +}; +$session = static fn (int $adminId, int $terminal) => Db::name('admin_session')->where(['admin_id' => $adminId, 'terminal' => $terminal])->find(); + +// ---------------------------------------------------------------- 门禁 +[$status] = aiMcpConsoleHttp('GET', $base . '/mcp/console/open', null, []); +aiMcpConsoleExpect($status === 405, 'console endpoints are POST only'); +$body = $console('open', 'zyt_ai_' . str_repeat('0', 64)); +aiMcpConsoleExpect(($body['code'] ?? null) === -1, 'an invalid AI grant cannot open a console session'); +$plain = $grant('c_plain'); +$body = $console('open', $plain); +aiMcpConsoleExpect(($body['code'] ?? null) === 0 && ($body['data']['reason'] ?? '') === 'no_console_permission', 'accounts without ai.mcp/console are refused: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); +aiMcpConsoleExpect($session(93001, 8) === null, 'no session is created when refused'); + +// ---------------------------------------------------------------- 签发与使用 +$grantToken = $grant('c_console'); +$body = $console('open', $grantToken); +aiMcpConsoleExpect(($body['code'] ?? null) === 1 && strlen((string) ($body['data']['token'] ?? '')) === 32 && ($body['data']['terminal'] ?? 0) === 8, 'console session issued: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); +$token = $body['data']['token']; +$stored = json_decode((string) ($body['data']['local_storage']['like_admin_token'] ?? ''), true); +aiMcpConsoleExpect(($stored['value'] ?? '') === $token, 'local storage entry is what the admin front end reads'); +$row = $session(93002, 8); +aiMcpConsoleExpect($row && $row['token'] === $token && abs((int) $row['expire_time'] - ($now + 7200)) < 120, 'session row on the AI browser terminal with a two hour lifetime'); +aiMcpConsoleExpect(($session(93002, 1)['token'] ?? '') === $pcToken, 'the account\'s own PC login is untouched even with multipoint login off'); +$me = $backOffice($token); +aiMcpConsoleExpect(($me['code'] ?? null) === 1 && (int) ($me['data']['user']['id'] ?? 0) === 93002, 'the console token works against the back office (login cache built for the caller\'s IP): ' . json_encode($me, JSON_UNESCAPED_UNICODE)); +aiMcpConsoleExpect(($console('open', $grantToken)['data']['token'] ?? '') === $token, 'opening again reuses the live session'); + +// ---------------------------------------------------------------- 关闭与撤销 +$body = $console('close', $grantToken); +aiMcpConsoleExpect(($body['code'] ?? null) === 1 && ($body['data']['closed'] ?? null) === true, 'close ends the session'); +aiMcpConsoleExpect(($backOffice($token)['code'] ?? null) === -1, 'a closed console token is rejected by the back office'); +aiMcpConsoleExpect(($console('close', $grantToken)['data']['closed'] ?? null) === false, 'closing twice is harmless'); +$second = $console('open', $grantToken)['data']['token'] ?? ''; +aiMcpConsoleExpect($second !== '' && $second !== $token && ($backOffice($second)['code'] ?? null) === 1, 'a new session gets a new token'); +[, $body] = aiMcpConsoleHttp('POST', $base . '/mcp/auth/revoke', [], ['Authorization' => 'Bearer ' . $grantToken]); +aiMcpConsoleExpect(($body['code'] ?? null) === 1, 'AI grant revoked'); +aiMcpConsoleExpect(($backOffice($second)['code'] ?? null) === -1, 'revoking the AI grant also ends its console session'); +aiMcpConsoleExpect(($session(93002, 1)['token'] ?? '') === $pcToken && (int) $session(93002, 1)['expire_time'] > time(), 'the PC login still stands after all of it'); + +// ---------------------------------------------------------------- 授权失效、失去权限后也能收回 +aiMcpConsoleExpect(($console('close', 'zyt_ai_' . str_repeat('0', 64))['code'] ?? null) === -1, 'an unknown token cannot close anything'); +$third = $grant('c_console'); +$live = $console('open', $third)['data']['token'] ?? ''; +aiMcpConsoleExpect($live !== '' && ($backOffice($live)['code'] ?? null) === 1, 'a new binding opens a new session'); +Db::name('ai_grant')->where(['admin_id' => 93002, 'status' => 1])->update(['status' => 2, 'revoke_reason' => 'test']); // 失效但没经过 close() +$body = $console('close', $third); +aiMcpConsoleExpect(($body['code'] ?? null) === 1 && ($body['data']['closed'] ?? null) === true, 'close still logs out with a grant that is no longer valid: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); +aiMcpConsoleExpect(($backOffice($live)['code'] ?? null) === -1, 'and the session is gone'); +$fourth = $grant('c_console'); +$live = $console('open', $fourth)['data']['token'] ?? ''; +aiMcpConsoleExpect($live !== '' && ($backOffice($live)['code'] ?? null) === 1, 'reopened with a fresh binding'); +Db::name('system_role_menu')->where(['role_id' => 294, 'menu_id' => $menu('ai.mcp/access')])->delete(); +$body = $console('open', $fourth); +aiMcpConsoleExpect(($body['code'] ?? null) === -1 && ($body['data']['reason'] ?? '') === 'no_ai_permission', 'losing ai.mcp/access refuses the grant: ' . json_encode($body, JSON_UNESCAPED_UNICODE)); +aiMcpConsoleExpect(($backOffice($live)['code'] ?? null) === -1, 'losing ai.mcp/access also ends the live console session'); +aiMcpConsoleExpect((int) Db::name('ai_grant')->where('admin_id', 93002)->order('id', 'desc')->value('status') === 1, 'the grant itself stays (usable again once the permission is back)'); +Db::name('system_role_menu')->insert(['role_id' => 294, 'menu_id' => $menu('ai.mcp/access')]); + +// ---------------------------------------------------------------- 审计 +$logged = Db::name('ai_access_log')->where('admin_id', 93002)->whereIn('tool', ['console.open', 'console.close'])->column('tool'); +aiMcpConsoleExpect(count(array_filter($logged, static fn ($t) => $t === 'console.open')) >= 3 && in_array('console.close', $logged, true), 'console sessions are audited'); +aiMcpConsoleExpect(Db::name('ai_access_log')->where(['admin_id' => 93002, 'tool' => 'console.open'])->where('client_task_id', 'console-task')->count() >= 1, 'the 行知 task id is recorded'); +aiMcpConsoleExpect(Db::name('ai_access_log')->where(['admin_id' => 93001, 'tool' => 'console.open', 'status' => 'denied'])->count() === 1, 'refusals are audited'); + +echo "AiMcpConsoleTest OK\n";