Files
zyt/server/app/mcp/service/ConsoleService.php
T
2026-09-24 16:18:52 +08:00

95 lines
5.1 KiB
PHP
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.
<?php
declare(strict_types=1);
namespace app\mcp\service;
use app\adminapi\service\AdminTokenService;
use app\common\cache\AdminTokenCache;
use app\common\model\auth\AdminSession;
use think\cache\driver\File as FileCache;
/**
* AI 后台浏览器会话:用已绑定的 AI 授权换一个“AI 浏览器”专用终端的后台登录,供行知服务器上的内置浏览器
* 打开本后台页面(不用输入密码)。
* - 独立终端(terminal=8):与电脑(1)、手机(2)、企微客服(7) 的登录互不影响,不会把员工自己的后台挤下线;
* admin_session 按“账号 + 终端”唯一,同一账号的 AI 浏览器共用一个会话。
* - 前提:AI 授权有效(未撤销、未过期、账号可用、仍有 ai.mcp/access)且账号有 ai.mcp/console(默认不授予任何角色)。
* - 创建时不写登录缓存:后台按“登录 IP”校验请求,缓存由浏览器的第一次请求建立,记录的就是浏览器实际的出口 IP。
* - 行知关闭浏览器、空闲超时、AI 授权被撤销时立即作废(改到期时间并清缓存)。
* 这是完整的后台登录(按账号自身的菜单权限和数据范围),不经过 MCP 的只读保护和脱敏;
* 行知对其中每一个会修改数据的请求都先请用户审批,后台自己的操作日志也能按终端区分出 AI 浏览器。
*/
class ConsoleService
{
/** 后台登录终端:AI 浏览器(系统已用 1 电脑、2 手机、7 企微客服桌面端) */
public const TERMINAL = 8;
public const PERMISSION = 'ai.mcp/console';
/** 后台前端把登录令牌存在 localStorage 的这个键里(admin/src/utils/cache.ts:前缀 like_admin_ + token) */
private const STORAGE_KEY = 'like_admin_token';
public static function open(Identity $identity): array
{
if (!McpConfig::consoleEnabled()) {
throw new McpException('后台浏览器未启用', 'console_disabled', 403);
}
if (!$identity->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);
}
}