This commit is contained in:
gr
2026-09-24 09:45:44 +08:00
parent dbf474ddd7
commit bd22e5f476
38 changed files with 19152 additions and 0 deletions
+332
View File
@@ -0,0 +1,332 @@
<?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());
}
}
}
}