更新
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\mcp\controller;
|
||||
|
||||
use app\BaseController;
|
||||
use app\mcp\service\AuditLogger;
|
||||
use app\mcp\service\ConsoleService;
|
||||
use app\mcp\service\GrantService;
|
||||
use app\mcp\service\Guard;
|
||||
use app\mcp\service\McpConfig;
|
||||
use app\mcp\service\McpException;
|
||||
use think\Response;
|
||||
|
||||
/**
|
||||
* AI 后台浏览器(供行知服务器上的内置浏览器调用,都要求 Bearer AI 授权令牌):
|
||||
* POST /mcp/console/open 换取“AI 浏览器”专用终端的后台登录(令牌只给行知服务器,写进浏览器,不给模型)
|
||||
* POST /mcp/console/close 作废该会话(行知关闭浏览器或空闲超时时调用;授权已失效时也照样注销)
|
||||
* 失败时 data.reason 为 feature_disabled / console_disabled / no_console_permission / ip_not_allowed 等。
|
||||
*/
|
||||
class ConsoleController extends BaseController
|
||||
{
|
||||
public function open(): Response
|
||||
{
|
||||
$blocked = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user