Files
zyt/server/app/mcp/service/Dispatcher.php
T
2026-09-24 09:45:44 +08:00

333 lines
16 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 think\exception\HttpResponseException;
use think\facade\Db;
use think\facade\Log;
use think\Response;
/**
* 在当前进程内“以调用账号身份”执行后台原有接口代码,保证 AI 与后台页面看到的数据一致:
* - 构造一个只含白名单参数的 GET 请求,挂上与登录中间件相同的 adminInfo/adminId;
* - 控制器、列表类、Logic 全部复用原代码,数据范围逻辑原样生效;
* - 整个调用包在只读事务里,结束后一律回滚:任何写库都会报错并被撤销,AI 查询不会改动数据;
* - 设置单条 SQL 超时,避免拖慢业务库。
* 不经过 adminapi 的 Login/Auth 中间件:权限由 Catalog/Identity 以“默认拒绝”方式在调用前判断。
*/
class Dispatcher
{
private const SQL_TIMEOUT_SECONDS = 10;
/**
* 执行一个资源。返回后台接口的原始信封 ['code' => 1|0, 'msg' => ..., 'data' => ...]。
*/
public static function call(Identity $identity, array $resource, array $params): array
{
$app = app();
$original = $app->request;
$namespace = $app->getNamespace();
$httpName = $app->http->getName();
[$dotted, $action] = self::route($resource);
$request = self::makeRequest($original, $identity, $dotted, $action, $params, strtoupper((string) ($resource['http'] ?? 'GET')));
$app->instance('request', $request);
$app->setNamespace('app\\adminapi');
$app->http->name('adminapi');
$readOnly = self::begin();
try {
if (!empty($resource['guard']) && $resource['guard'] !== 'builtin') {
$denied = self::checkGuard($identity, $resource, $params);
if ($denied !== null) {
return ['code' => 0, 'msg' => $denied, 'data' => []];
}
}
try {
if (!empty($resource['handler']['logic'])) {
return self::callLogic($identity, (array) $resource['handler'], $params);
}
if (!empty($resource['handler']['table'])) {
return self::callTable($identity, (array) $resource['handler'], $params);
}
$response = $app->make($resource['controller'], [], true)->{$action}();
} catch (HttpResponseException $e) {
$response = $e->getResponse();
}
return self::unwrap($response);
} catch (\think\exception\ValidateException $e) {
return ['code' => 0, 'msg' => (string) $e->getError(), 'data' => []];
} catch (\Throwable $e) {
Log::error(sprintf('[ai_mcp] %s 执行失败: %s @ %s:%d', $resource['key'] ?? '?', $e->getMessage(), $e->getFile(), $e->getLine()));
return ['code' => 0, 'msg' => self::describe($e), 'data' => []];
} finally {
self::end($readOnly);
$app->instance('request', $original);
$app->setNamespace($namespace);
$app->http->name($httpName);
}
}
/**
* 直接调用 Logic(用于控制器里夹带写操作的只读接口,如详情页顺手“标记已读”):
* handler = ['logic' => [类, 方法], 'args' => ['params','admin_id','admin_info','id'], 'validate' => [验证器类, 场景], 'error' => [类, 'getError']]
*/
private static function callLogic(Identity $identity, array $handler, array $params): array
{
if (!empty($handler['validate'])) {
[$class, $scene] = $handler['validate'];
$params = array_merge($params, (new $class())->goCheck($scene));
}
$args = [];
foreach ((array) ($handler['args'] ?? ['params']) as $arg) {
$args[] = match ($arg) {
'params' => $params,
'admin_id' => $identity->adminId,
'admin_info' => $identity->adminInfo,
'id' => (int) ($params['id'] ?? 0),
default => $params[$arg] ?? null,
};
}
$result = call_user_func_array($handler['logic'], $args);
if ($result === false || $result === null || $result === []) {
$message = !empty($handler['error']) && is_callable($handler['error']) ? (string) call_user_func($handler['error']) : '';
return ['code' => 0, 'msg' => $message ?: '记录不存在或无权访问', 'data' => []];
}
return ['code' => 1, 'msg' => '', 'data' => $result];
}
/**
* 后台没有页面的业务表:按审核配置只读查询。
* handler = ['table' => 表名(不含前缀), 'columns' => [可返回列], 'filters' => [列 => '='|'like'|'in'], 'date' => 时间列,
* 'date_type' => 'int'|'datetime', 'order' => 'id desc', 'soft_delete' => 'delete_time',
* 'scope' => 'root' | ['owner' => [属主列, …]]]
* 属主列按调用账号的角色数据范围过滤(与后台列表的 DataScope 规则相同);'root' 表示只对超级管理员开放。
*/
private static function callTable(Identity $identity, array $spec, array $params): array
{
$scope = $spec['scope'] ?? 'root';
if ($scope === 'root' && !$identity->root) {
return ['code' => 0, 'msg' => '该数据表只对超级管理员开放', 'data' => []];
}
$quote = static fn (string $column): string => '`' . str_replace('`', '', $column) . '`';
$query = Db::name((string) $spec['table'])->field(implode(',', array_map($quote, (array) ($spec['columns'] ?? ['id']))));
if (!empty($spec['soft_delete'])) {
$query->where(static fn ($q) => $q->whereNull($spec['soft_delete'])->whereOr($spec['soft_delete'], 0));
}
foreach ((array) ($spec['filters'] ?? []) as $column => $operator) {
$value = $params[$column] ?? null;
if ($value === null || $value === '' || $value === []) {
continue;
}
if ($operator === 'like') {
$query->whereLike($column, '%' . $value . '%');
} elseif ($operator === 'in') {
$query->whereIn($column, is_array($value) ? $value : explode(',', (string) $value));
} else {
$query->where($column, '=', $value);
}
}
if (!empty($spec['date'])) {
$toValue = static fn (string $date, bool $end) => ($spec['date_type'] ?? 'int') === 'datetime'
? $date . ($end ? ' 23:59:59' : ' 00:00:00') : strtotime($date . ($end ? ' 23:59:59' : ' 00:00:00'));
if (!empty($params['start_date']) && strtotime((string) $params['start_date'])) {
$query->where($spec['date'], '>=', $toValue((string) $params['start_date'], false));
}
if (!empty($params['end_date']) && strtotime((string) $params['end_date'])) {
$query->where($spec['date'], '<=', $toValue((string) $params['end_date'], true));
}
}
if (is_array($scope) && !empty($scope['owner'])) {
$visible = \app\common\service\DataScope\DataScopeService::getVisibleAdminIds($identity->adminId, $identity->adminInfo);
if ($visible === []) {
return ['code' => 1, 'msg' => '', 'data' => ['lists' => [], 'count' => 0]];
}
if (is_array($visible)) {
$owners = array_values((array) $scope['owner']);
$query->where(static function ($q) use ($owners, $visible) {
foreach ($owners as $i => $owner) {
$i === 0 ? $q->whereIn($owner, $visible) : $q->whereOr($owner, 'in', $visible);
}
});
}
}
$page = max(1, (int) ($params['page_no'] ?? 1));
$size = max(1, min(McpConfig::maxPageSize(), (int) ($params['page_size'] ?? McpConfig::defaultPageSize())));
$count = (clone $query)->count();
$order = (string) ($spec['order'] ?? '');
if ($order !== '' && preg_match('/^[\w`.]+( (asc|desc))?$/i', $order)) {
$query->orderRaw($order);
}
$rows = $query->page($page, $size)->select()->toArray();
return ['code' => 1, 'msg' => '', 'data' => ['lists' => $rows, 'count' => $count, 'page_no' => $page, 'page_size' => $size]];
}
/** 资源标识 tcm.diagnosis/lists → [tcm.diagnosis, lists];审核文件可用 route 指定 */
private static function route(array $resource): array
{
$key = (string) ($resource['route'] ?? $resource['key']);
$pos = strrpos($key, '/');
return [substr($key, 0, $pos), (string) ($resource['action'] ?? substr($key, $pos + 1))];
}
private static function makeRequest($original, Identity $identity, string $dotted, string $action, array $params, string $method)
{
$request = \app\Request::__make(app());
$server = $original->server();
foreach (['CONTENT_TYPE', 'CONTENT_LENGTH', 'HTTP_CONTENT_TYPE', 'HTTP_CONTENT_LENGTH', 'HTTP_AUTHORIZATION', 'HTTP_TOKEN', 'QUERY_STRING'] as $k) {
unset($server[$k]);
}
$server['REQUEST_METHOD'] = $method;
$request->withServer($server)
->withHeader(['host' => (string) $original->host(), 'user-agent' => 'zyt-mcp/' . McpConfig::SERVER_VERSION])
->withCookie([])
->withInput('')
->withGet($method === 'GET' ? $params : [])
->withPost($method === 'POST' ? $params : [])
->setMethod($method);
$request->setController($dotted);
$request->setAction($action);
$request->adminInfo = $identity->adminInfo;
$request->adminId = $identity->adminId;
return $request;
}
/** 详情类资源的逐条校验 */
private static function checkGuard(Identity $identity, array $resource, array $params): ?string
{
$guard = $resource['guard'];
$idParam = (string) ($guard['param'] ?? 'id');
$id = $params[$idParam] ?? null;
if ($id === null || $id === '') {
return '缺少参数 ' . $idParam;
}
if (!is_scalar($id) || (is_string($id) && !preg_match('/^[\w\-]{1,64}$/', $id))) {
return '参数 ' . $idParam . ' 必须是单个记录 ID';
}
if (isset($guard['callable'])) {
$args = [];
foreach ((array) ($guard['args'] ?? ['id', 'admin_id', 'admin_info']) as $arg) {
$args[] = match ($arg) {
'id' => (int) $id,
'admin_id' => $identity->adminId,
'admin_info' => $identity->adminInfo,
'params' => $params,
default => $params[$arg] ?? null,
};
}
$ok = (bool) call_user_func_array($guard['callable'], $args);
return $ok ? null : '无权限:该记录不在当前账号的数据范围内';
}
if (isset($guard['via'])) {
// 用列表资源的数据范围判断:按 id 过滤列表,列表里查得到才放行
$list = Catalog::get((string) $guard['via']);
if (!$list) {
return '资源配置错误:缺少校验用的列表资源';
}
$filter = array_merge((array) ($list['force'] ?? []), [(string) ($guard['filter'] ?? $idParam) => $id, 'page_no' => 1, 'page_size' => 50, 'page_type' => 1]);
$request = self::makeRequest(app()->request, $identity, ...array_merge(self::route($list), [$filter, 'GET']));
$previous = app()->request;
app()->instance('request', $request);
try {
$controller = app()->make($list['controller'], [], true);
$action = self::route($list)[1];
try {
$envelope = self::unwrap($controller->{$action}());
} catch (HttpResponseException $e) {
$envelope = self::unwrap($e->getResponse());
}
} finally {
app()->instance('request', $previous);
}
$match = (string) ($guard['match'] ?? 'id');
foreach ((array) ($envelope['data']['lists'] ?? []) as $row) {
if (is_array($row) && (string) ($row[$match] ?? '') === (string) $id) {
return null;
}
}
return '无权限:该记录不在当前账号的数据范围内';
}
return '资源缺少逐条权限校验配置';
}
private static function unwrap($response): array
{
$data = $response instanceof Response ? $response->getData() : $response;
if (is_string($data)) {
$decoded = json_decode($data, true);
$data = is_array($decoded) ? $decoded : null;
}
if (!is_array($data) || !array_key_exists('code', $data)) {
return ['code' => 0, 'msg' => '接口没有返回标准数据', 'data' => []];
}
return ['code' => (int) $data['code'], 'msg' => (string) ($data['msg'] ?? ''), 'data' => $data['data'] ?? []];
}
private static function describe(\Throwable $e): string
{
$message = $e->getMessage();
if (stripos($message, 'READ ONLY') !== false || stripos($message, 'read-only') !== false || str_contains($message, '25006') || str_contains($message, '1792')) {
return '该查询会写入数据,已被只读保护拦截。请联系管理员把这个资源标记为不开放或改用只读接口';
}
if (stripos($message, 'max_statement_time') !== false || stripos($message, 'maximum statement execution time') !== false || str_contains($message, '3024') || str_contains($message, '1969')) {
return '查询超时,请缩小时间范围或增加筛选条件';
}
// 业务代码用普通异常抛出的中文提示(如“请传入有效的结算月”)原样给出;数据库和程序错误不外露
$isDbOrBug = $e instanceof \PDOException || $e instanceof \think\db\exception\DbException || $e instanceof \Error;
if (!$isDbOrBug && mb_strlen($message) < 200 && preg_match('/\p{Han}/u', $message) && !preg_match('/SQLSTATE|SELECT|INSERT|UPDATE|\.php/i', $message)) {
return $message;
}
return '查询失败(' . (new \ReflectionClass($e))->getShortName() . '),请换个条件或联系管理员查看服务器日志';
}
/** 开启只读事务 + SQL 超时 */
private static function begin(): bool
{
$readOnly = true;
try {
Db::execute('SET SESSION TRANSACTION READ ONLY');
} catch (\Throwable $e) {
$readOnly = false;
Log::warning('[ai_mcp] 数据库不支持只读事务,改为事务回滚保护: ' . $e->getMessage());
}
foreach (['SET SESSION max_execution_time = ' . (self::SQL_TIMEOUT_SECONDS * 1000), 'SET SESSION max_statement_time = ' . self::SQL_TIMEOUT_SECONDS] as $sql) {
try {
Db::execute($sql);
break;
} catch (\Throwable $e) {
}
}
Db::startTrans();
return $readOnly;
}
/** 回滚本次调用里的一切(包括被调用代码自己开的嵌套事务),恢复会话设置 */
private static function end(bool $readOnly): void
{
try {
$pdo = Db::connect()->getPdo();
for ($i = 0; $i < 10 && $pdo && $pdo->inTransaction(); $i++) {
Db::rollback();
}
if ($pdo && $pdo->inTransaction()) {
$pdo->rollBack();
}
} catch (\Throwable $e) {
Log::error('[ai_mcp] 回滚失败: ' . $e->getMessage());
}
foreach (['SET SESSION max_execution_time = 0', 'SET SESSION max_statement_time = 0'] as $sql) {
try {
Db::execute($sql);
break;
} catch (\Throwable $e) {
}
}
if ($readOnly) {
try {
Db::execute('SET SESSION TRANSACTION READ WRITE');
} catch (\Throwable $e) {
Log::error('[ai_mcp] 恢复读写会话失败: ' . $e->getMessage());
}
}
}
}