333 lines
16 KiB
PHP
333 lines
16 KiB
PHP
<?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());
|
||
}
|
||
}
|
||
}
|
||
}
|