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()); } } } }