636 lines
38 KiB
PHP
636 lines
38 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace app\mcp\service;
|
||
|
||
/**
|
||
* MCP 工具:少量通用工具覆盖目录里的全部资源,另有几个高频统计的快捷工具;业绩类工具见 PerfTools。
|
||
* 所有工具只读;结果同时给文字摘要 + JSON(很多客户端只把 text 交给模型)。
|
||
*/
|
||
class Tools
|
||
{
|
||
private const READ_ONLY = ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false];
|
||
|
||
/** tools/list */
|
||
public static function definitions(Identity $identity): array
|
||
{
|
||
$tools = [
|
||
self::tool('zyt_whoami', '查看当前绑定的甄养堂账号:姓名、角色、数据范围、可查询的资源数量、今日已用额度。回答“我是谁/我能查什么”或排查无权限时使用。', []),
|
||
self::tool('zyt_catalog', '列出当前账号可以查询的甄养堂数据资源(按业务分组)。先用它找到资源标识 resource,再用 zyt_describe 看参数,用 zyt_query / zyt_get / zyt_count 查询。', [
|
||
'domain' => ['type' => 'string', 'description' => '只看某个业务分组,如“诊单与处方”“订单与收款”'],
|
||
'keyword' => ['type' => 'string', 'description' => '按名称或标识过滤,如“处方”“排班”“订单”'],
|
||
'include_closed' => ['type' => 'boolean', 'description' => '同时列出暂未开放的资源及原因'],
|
||
]),
|
||
self::tool('zyt_describe', '查看某个数据资源的说明:可用查询参数及含义、类型(列表/详情/统计)、口径说明。', [
|
||
'resource' => ['type' => 'string', 'description' => '资源标识,来自 zyt_catalog,如 doctor.appointment/lists'],
|
||
], ['resource']),
|
||
self::tool('zyt_query', '查询列表或统计类资源,结果与该账号在甄养堂后台看到的一致(按其权限和数据范围)。翻页一律用外层 page/page_size(列表默认每页 20 条、最多 50 条;自带分页的统计明细最多 100 条),结果里有 total 和 has_more(统计明细在 result.paging)。', [
|
||
'resource' => ['type' => 'string', 'description' => '资源标识,如 tcm.diagnosis/lists'],
|
||
'params' => ['type' => 'object', 'description' => '查询参数,名称见 zyt_describe;日期用 YYYY-MM-DD', 'additionalProperties' => true],
|
||
'page' => ['type' => 'integer', 'minimum' => 1, 'description' => '页码,从 1 开始'],
|
||
'page_size' => ['type' => 'integer', 'minimum' => 1, 'maximum' => McpConfig::maxPageSize(), 'description' => '每页条数'],
|
||
'fields' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => '只返回这些字段(可选,减少篇幅)'],
|
||
], ['resource']),
|
||
self::tool('zyt_get', '查询详情类资源的一条记录(如某个诊单、处方、订单的详情)。会校验这条记录是否在当前账号的数据范围内。', [
|
||
'resource' => ['type' => 'string', 'description' => '详情类资源标识,如 tcm.diagnosis/readonlyDetail'],
|
||
'id' => ['type' => 'string', 'description' => '记录 ID(数字写成字符串也可以)'],
|
||
'params' => ['type' => 'object', 'description' => '其他参数(可选)', 'additionalProperties' => true],
|
||
], ['resource', 'id']),
|
||
self::tool('zyt_count', '只统计某个列表资源在给定条件下的总条数(不返回明细),适合“有多少”“几个”类问题。', [
|
||
'resource' => ['type' => 'string', 'description' => '列表类资源标识'],
|
||
'params' => ['type' => 'object', 'description' => '查询参数', 'additionalProperties' => true],
|
||
], ['resource']),
|
||
self::tool('zyt_file', '读取某条记录里的附件(舌象照片、检查报告等图片或 PDF)。结果里显示“[附件×N…]”时用它读取第 index 个附件。', [
|
||
'resource' => ['type' => 'string', 'description' => '附件所在的详情或列表资源标识'],
|
||
'id' => ['type' => 'string', 'description' => '记录 ID(数字写成字符串也可以)'],
|
||
'field' => ['type' => 'string', 'description' => '附件字段名,如 tongue_images'],
|
||
'index' => ['type' => 'integer', 'minimum' => 0, 'description' => '第几个附件,从 0 开始'],
|
||
], ['resource', 'id', 'field']),
|
||
];
|
||
foreach (self::presets() as $name => $preset) {
|
||
$resource = Catalog::get($preset['resource']);
|
||
if ($resource && Catalog::denialFor($identity, $resource) === null) {
|
||
$tools[] = self::tool($name, $preset['description'], $preset['args'], $preset['required']);
|
||
}
|
||
}
|
||
return array_merge($tools, PerfTools::definitions($identity));
|
||
}
|
||
|
||
/** tools/call,返回 CallToolResult */
|
||
public static function call(Identity $identity, string $name, array $args, array $context): array
|
||
{
|
||
$started = microtime(true);
|
||
$audit = ['grant_id' => $identity->grant['id'] ?? 0, 'admin_id' => $identity->adminId, 'tool' => $name,
|
||
'arguments' => $args, 'client_task_id' => $context['task_id'] ?? '', 'ip' => $context['ip'] ?? ''];
|
||
try {
|
||
$presets = self::presets();
|
||
$result = match (true) {
|
||
$name === 'zyt_whoami' => self::whoami($identity),
|
||
$name === 'zyt_catalog' => self::catalog($identity, $args),
|
||
$name === 'zyt_describe' => self::describe($identity, $args),
|
||
$name === 'zyt_query' => self::query($identity, $args, $audit),
|
||
$name === 'zyt_get' => self::get($identity, $args, $audit),
|
||
$name === 'zyt_count' => self::count($identity, $args, $audit),
|
||
$name === 'zyt_file' => self::file($identity, $args, $audit),
|
||
PerfTools::handles($name) => PerfTools::call($identity, $name, $args, $audit),
|
||
isset($presets[$name]) => self::preset($identity, $presets[$name], $args, $audit),
|
||
default => throw new McpException('没有这个工具:' . $name, 'unknown_tool'),
|
||
};
|
||
$audit['status'] = $audit['status'] ?? 'ok';
|
||
} catch (McpException $e) {
|
||
$audit['status'] = in_array($e->reason, ['denied', 'limited', 'invalid'], true) ? $e->reason : 'error';
|
||
$audit['message'] = $e->getMessage();
|
||
$result = self::error($e->getMessage());
|
||
} catch (\Throwable $e) {
|
||
\think\facade\Log::error('[ai_mcp] 工具执行异常 ' . $name . ': ' . $e->getMessage());
|
||
$audit['status'] = 'error';
|
||
$type = (new \ReflectionClass($e))->getShortName();
|
||
$audit['message'] = '内部错误 ' . $type;
|
||
$result = self::error('查询失败(内部错误 ' . $type . '),请稍后再试;多次出现请联系管理员查看服务器日志');
|
||
}
|
||
$audit['duration_ms'] = (int) round((microtime(true) - $started) * 1000);
|
||
if (!in_array($name, ['zyt_whoami', 'zyt_catalog', 'zyt_describe'], true) || $audit['status'] !== 'ok') {
|
||
AuditLogger::log($audit);
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
private static function whoami(Identity $identity): array
|
||
{
|
||
$open = Catalog::openFor($identity);
|
||
$data = [
|
||
'account' => $identity->publicProfile(),
|
||
'data_scope' => $identity->dataScopeText(),
|
||
'full_phone_visible' => $identity->seesPhone(),
|
||
'full_sensitive_visible' => $identity->seesSensitive(),
|
||
'resources_open' => count($open),
|
||
'rows_today' => RateLimiter::rowsToday($identity->adminId),
|
||
'rows_daily_limit' => McpConfig::dailyRows(),
|
||
'grant_expire_at' => date('Y-m-d H:i', (int) $identity->grant['expire_time']),
|
||
];
|
||
$summary = sprintf('当前账号:%s(%s),数据范围:%s,可查询资源 %d 个。',
|
||
$data['account']['name'], implode('/', $data['account']['roles']) ?: '无角色', $data['data_scope'], $data['resources_open']);
|
||
return self::ok($summary, $data);
|
||
}
|
||
|
||
private static function catalog(Identity $identity, array $args): array
|
||
{
|
||
$domain = trim((string) ($args['domain'] ?? ''));
|
||
$keyword = trim((string) ($args['keyword'] ?? ''));
|
||
$includeClosed = !empty($args['include_closed']);
|
||
$groups = [];
|
||
$closed = [];
|
||
foreach (Catalog::all() as $key => $r) {
|
||
if ($domain !== '' && mb_strpos($r['domain'], $domain) === false) {
|
||
continue;
|
||
}
|
||
if ($keyword !== '' && mb_stripos($r['name'] . ' ' . $key, $keyword) === false) {
|
||
continue;
|
||
}
|
||
$denied = Catalog::denialFor($identity, $r);
|
||
if ($denied === null) {
|
||
$groups[$r['domain']][] = ['resource' => $key, 'name' => $r['name'], 'kind' => self::kindText($r['kind'])];
|
||
} elseif ($includeClosed && $r['status'] !== Catalog::EXCLUDED && ($r['status'] !== Catalog::OPEN || !$r['registered'] || $identity->can($r['perm']))) {
|
||
$closed[] = ['resource' => $key, 'name' => $r['name'], 'reason' => $r['reason'] ?: '无权限'];
|
||
}
|
||
}
|
||
ksort($groups);
|
||
$count = array_sum(array_map('count', $groups));
|
||
$data = ['domains' => $groups, 'total' => $count];
|
||
if ($includeClosed) {
|
||
$data['not_open'] = array_slice($closed, 0, 200);
|
||
}
|
||
return self::ok('可查询的数据资源 ' . $count . ' 个' . ($domain || $keyword ? '(已按条件过滤)' : '') . '。用 zyt_describe 查看参数。', $data);
|
||
}
|
||
|
||
private static function describe(Identity $identity, array $args): array
|
||
{
|
||
$resource = self::resource($identity, (string) ($args['resource'] ?? ''));
|
||
$data = [
|
||
'resource' => $resource['key'],
|
||
'name' => $resource['name'],
|
||
'domain' => $resource['domain'],
|
||
'kind' => self::kindText($resource['kind']),
|
||
'use' => $resource['kind'] === 'detail' ? 'zyt_get' : (in_array($resource['kind'], ['list', 'table'], true) ? 'zyt_query 或 zyt_count' : 'zyt_query'),
|
||
'params' => Catalog::paramDocs($resource),
|
||
'fixed_params' => (array) ($resource['force'] ?? []),
|
||
'note' => (string) ($resource['note'] ?? ''),
|
||
'limits' => ['page_size_max' => McpConfig::maxPageSize(), 'date_range_days_max' => McpConfig::maxRangeDays()],
|
||
];
|
||
if ($resource['kind'] === 'detail') {
|
||
$data['id_param'] = (string) ($resource['guard']['param'] ?? $resource['id_param'] ?? 'id');
|
||
}
|
||
return self::ok('「' . $resource['name'] . '」的查询说明。', $data);
|
||
}
|
||
|
||
private static function query(Identity $identity, array $args, array &$audit): array
|
||
{
|
||
$resource = self::resource($identity, (string) ($args['resource'] ?? ''));
|
||
$audit['resource'] = $resource['key'];
|
||
if ($resource['kind'] === 'detail') {
|
||
throw new McpException('「' . $resource['name'] . '」是详情资源,请用 zyt_get 并提供 id', 'invalid');
|
||
}
|
||
$params = self::params($resource, self::liftPaging($resource, (array) ($args['params'] ?? []), $args));
|
||
if (in_array($resource['kind'], ['list', 'table'], true)) {
|
||
return self::runList($identity, $resource, $params, (int) ($args['page'] ?? 1), (int) ($args['page_size'] ?? McpConfig::defaultPageSize()), (array) ($args['fields'] ?? []), $audit);
|
||
}
|
||
return self::runReport($identity, $resource, self::sinkPaging($resource, $params, $args), $audit);
|
||
}
|
||
|
||
/**
|
||
* 自带分页的统计明细(参数叫 page/page_no 和 page_size,如进线明细、被指派明细):外层的 page/page_size 放进参数。
|
||
* 以前只看 params,模型照工具说明用外层 page 翻页时每次拿到的都是第一页,看起来像同一批记录反复出现。
|
||
*/
|
||
private static function sinkPaging(array $resource, array $params, array $args): array
|
||
{
|
||
$allowed = array_flip(Catalog::allowedParams($resource));
|
||
$pageKey = isset($allowed['page']) ? 'page' : (isset($allowed['page_no']) ? 'page_no' : null);
|
||
if ($pageKey !== null && !isset($params[$pageKey])) {
|
||
$params[$pageKey] = max(1, (int) ($args['page'] ?? 1));
|
||
}
|
||
if (isset($allowed['page_size']) && !isset($params['page_size'])) {
|
||
$params['page_size'] = max(1, min(100, (int) ($args['page_size'] ?? McpConfig::defaultPageSize())));
|
||
}
|
||
return $params;
|
||
}
|
||
|
||
/** 同一页里 id 相同的行只留一条(后台列表的联表偶尔会把一条记录拆成多行);有行没有 id 时原样返回 */
|
||
private static function dropDuplicateIds(array $rows): array
|
||
{
|
||
$seen = [];
|
||
$out = [];
|
||
foreach ($rows as $row) {
|
||
if (!is_array($row) || !isset($row['id']) || !is_scalar($row['id'])) {
|
||
return [$rows, 0];
|
||
}
|
||
if (!isset($seen[(string) $row['id']])) {
|
||
$seen[(string) $row['id']] = true;
|
||
$out[] = $row;
|
||
}
|
||
}
|
||
return [$out, count($rows) - count($out)];
|
||
}
|
||
|
||
private static function get(Identity $identity, array $args, array &$audit): array
|
||
{
|
||
$resource = self::resource($identity, (string) ($args['resource'] ?? ''));
|
||
$audit['resource'] = $resource['key'];
|
||
if ($resource['kind'] !== 'detail') {
|
||
throw new McpException('「' . $resource['name'] . '」不是详情资源,请用 zyt_query', 'invalid');
|
||
}
|
||
$id = $args['id'] ?? null;
|
||
if (!is_scalar($id) || (string) $id === '') {
|
||
throw new McpException('请提供记录 id', 'invalid');
|
||
}
|
||
$idParam = (string) ($resource['guard']['param'] ?? $resource['id_param'] ?? 'id');
|
||
$params = self::params($resource, self::liftPaging($resource, (array) ($args['params'] ?? []), $args));
|
||
$params[$idParam] = is_numeric($id) ? (int) $id : (string) $id;
|
||
self::assertQuota($identity, 1);
|
||
$envelope = Dispatcher::call($identity, $resource, $params);
|
||
if ($envelope['code'] !== 1) {
|
||
throw new McpException(self::failText($resource, $envelope), 'denied');
|
||
}
|
||
$policy = FieldPolicy::forIdentity($identity, 20000);
|
||
$record = $policy->apply($envelope['data']);
|
||
RateLimiter::addRows($identity->adminId, 1);
|
||
$audit['result_rows'] = 1;
|
||
$audit['record_ids'] = [(string) $id];
|
||
return self::ok('「' . $resource['name'] . '」ID ' . $id . ' 的详情' . self::maskNote($policy) . '。',
|
||
self::fit(['resource' => $resource['key'], 'id' => $id, 'record' => $record, 'masked' => $policy->maskedFields()]));
|
||
}
|
||
|
||
private static function count(Identity $identity, array $args, array &$audit): array
|
||
{
|
||
$resource = self::resource($identity, (string) ($args['resource'] ?? ''));
|
||
$audit['resource'] = $resource['key'];
|
||
if (!in_array($resource['kind'], ['list', 'table'], true)) {
|
||
throw new McpException('zyt_count 只用于列表资源', 'invalid');
|
||
}
|
||
$params = self::params($resource, self::liftPaging($resource, (array) ($args['params'] ?? []), $args));
|
||
$envelope = Dispatcher::call($identity, $resource, self::listParams($resource, $params, 1, 1));
|
||
if ($envelope['code'] !== 1) {
|
||
throw new McpException(self::failText($resource, $envelope), 'denied');
|
||
}
|
||
$total = (int) ($envelope['data']['count'] ?? 0);
|
||
$policy = FieldPolicy::forIdentity($identity, 2000);
|
||
$data = ['resource' => $resource['key'], 'total' => $total, 'params' => $params];
|
||
if (!empty($envelope['data']['extend'])) {
|
||
$data['extend'] = $policy->apply($envelope['data']['extend']);
|
||
}
|
||
return self::ok('「' . $resource['name'] . '」符合条件的共 ' . $total . ' 条。', $data);
|
||
}
|
||
|
||
private static function file(Identity $identity, array $args, array &$audit): array
|
||
{
|
||
$resource = self::resource($identity, (string) ($args['resource'] ?? ''));
|
||
$audit['resource'] = $resource['key'];
|
||
$id = $args['id'] ?? null;
|
||
$field = (string) ($args['field'] ?? '');
|
||
$index = max(0, (int) ($args['index'] ?? 0));
|
||
if (!is_scalar($id) || $field === '') {
|
||
throw new McpException('请提供 id 和附件字段名 field', 'invalid');
|
||
}
|
||
if ($resource['kind'] === 'detail') {
|
||
$idParam = (string) ($resource['guard']['param'] ?? $resource['id_param'] ?? 'id');
|
||
$envelope = Dispatcher::call($identity, $resource, array_merge([$idParam => $id], (array) ($resource['force'] ?? [])));
|
||
$record = $envelope['code'] === 1 ? (array) $envelope['data'] : [];
|
||
} else {
|
||
$filter = !empty($resource['handler']['table']) ? [] : ['id' => $id];
|
||
$envelope = Dispatcher::call($identity, $resource, self::listParams($resource, $filter, 1, 50));
|
||
$record = [];
|
||
foreach ((array) ($envelope['data']['lists'] ?? []) as $row) {
|
||
if ((string) ($row['id'] ?? '') === (string) $id) {
|
||
$record = $row;
|
||
}
|
||
}
|
||
}
|
||
if ($envelope['code'] !== 1 || $record === []) {
|
||
throw new McpException('找不到这条记录,或它不在当前账号的数据范围内', 'denied');
|
||
}
|
||
$urls = FileFetcher::urls(self::dig($record, $field));
|
||
if (!isset($urls[$index])) {
|
||
throw new McpException('字段 ' . $field . ' 没有第 ' . $index . ' 个附件(共 ' . count($urls) . ' 个)', 'invalid');
|
||
}
|
||
$audit['record_ids'] = [(string) $id];
|
||
$audit['result_rows'] = 1;
|
||
return FileFetcher::content($urls[$index], $resource['name'] . ' #' . $id . ' ' . $field . '[' . $index . ']');
|
||
}
|
||
|
||
private static function preset(Identity $identity, array $preset, array $args, array &$audit): array
|
||
{
|
||
foreach ($preset['required'] as $required) {
|
||
if (!isset($args[$required]) || $args[$required] === '') {
|
||
throw new McpException('缺少参数 ' . $required, 'invalid');
|
||
}
|
||
}
|
||
$resource = self::resource($identity, $preset['resource']);
|
||
$audit['resource'] = $resource['key'];
|
||
$params = self::params($resource, ($preset['map'])($args), true);
|
||
if (($preset['mode'] ?? '') === 'count') {
|
||
$envelope = Dispatcher::call($identity, $resource, self::listParams($resource, $params, 1, 1));
|
||
if ($envelope['code'] !== 1) {
|
||
throw new McpException(self::failText($resource, $envelope), 'denied');
|
||
}
|
||
$policy = FieldPolicy::forIdentity($identity, 2000);
|
||
$data = ['total' => (int) ($envelope['data']['count'] ?? 0), 'extend' => $policy->apply($envelope['data']['extend'] ?? []), 'params' => $params];
|
||
return self::ok($preset['summary'] . ':共 ' . $data['total'] . ' 条。' . ($preset['note'] ?? ''), $data);
|
||
}
|
||
if ($resource['kind'] === 'list') {
|
||
return self::runList($identity, $resource, $params, (int) ($args['page'] ?? 1), (int) ($args['page_size'] ?? McpConfig::defaultPageSize()), [], $audit);
|
||
}
|
||
return self::runReport($identity, $resource, $params, $audit);
|
||
}
|
||
|
||
private static function runList(Identity $identity, array $resource, array $params, int $page, int $size, array $fields, array &$audit): array
|
||
{
|
||
$page = max(1, $page);
|
||
$size = max(1, min(McpConfig::maxPageSize(), $size ?: McpConfig::defaultPageSize()));
|
||
self::assertQuota($identity, $size);
|
||
$envelope = Dispatcher::call($identity, $resource, self::listParams($resource, $params, $page, $size));
|
||
if ($envelope['code'] !== 1) {
|
||
throw new McpException(self::failText($resource, $envelope), 'denied');
|
||
}
|
||
[$rows, $duplicates] = self::dropDuplicateIds(array_values((array) ($envelope['data']['lists'] ?? [])));
|
||
$total = (int) ($envelope['data']['count'] ?? count($rows));
|
||
if (count($rows) > $size) {
|
||
// 个别列表不分页、总是返回全部行:在这里按页切片,避免超出篇幅和每日额度
|
||
$rows = array_slice($rows, ($page - 1) * $size, $size);
|
||
$total = max($total, (int) ($envelope['data']['count'] ?? 0));
|
||
}
|
||
$policy = FieldPolicy::forIdentity($identity, 2000);
|
||
$rows = $policy->apply($rows);
|
||
if ($fields) {
|
||
$keep = array_flip(array_map('strval', $fields));
|
||
$rows = array_map(static fn ($row) => is_array($row) ? array_intersect_key($row, $keep + ['id' => 1]) : $row, $rows);
|
||
}
|
||
RateLimiter::addRows($identity->adminId, count($rows));
|
||
$audit['result_rows'] = count($rows);
|
||
$audit['record_ids'] = AuditLogger::recordIds($rows);
|
||
$data = ['resource' => $resource['key'], 'name' => $resource['name'], 'total' => $total, 'page' => $page, 'page_size' => $size,
|
||
'has_more' => $page * $size < $total, 'rows' => $rows, 'masked' => $policy->maskedFields()];
|
||
if ($duplicates > 0) {
|
||
$data['duplicates_removed'] = $duplicates;
|
||
}
|
||
if (!empty($envelope['data']['extend'])) {
|
||
$data['extend'] = $policy->apply($envelope['data']['extend']);
|
||
}
|
||
if (!empty($resource['note'])) {
|
||
$data['note'] = $resource['note'];
|
||
}
|
||
$data = self::fit($data);
|
||
$summary = sprintf('「%s」共 %d 条,本页第 %d 页 %d 条%s%s%s。', $resource['name'], $total, $page, count($data['rows']),
|
||
$data['has_more'] ? ',还有更多(page=' . ($page + 1) . ')' : '', $duplicates > 0 ? ';后台返回的重复记录 ' . $duplicates . ' 条已去掉' : '', self::maskNote($policy));
|
||
return self::ok($summary, $data);
|
||
}
|
||
|
||
private static function runReport(Identity $identity, array $resource, array $params, array &$audit): array
|
||
{
|
||
self::assertQuota($identity, 1);
|
||
$envelope = Dispatcher::call($identity, $resource, $params);
|
||
if ($envelope['code'] !== 1) {
|
||
throw new McpException(self::failText($resource, $envelope), 'denied');
|
||
}
|
||
$policy = FieldPolicy::forIdentity($identity, 5000);
|
||
$result = $policy->apply($envelope['data']);
|
||
$extra = '';
|
||
$hasLists = is_array($result) && isset($result['lists']) && is_array($result['lists']);
|
||
if ($hasLists) {
|
||
[$result['lists'], $duplicates] = self::dropDuplicateIds(array_values($result['lists']));
|
||
if ($duplicates > 0) {
|
||
$result['duplicates_removed'] = $duplicates;
|
||
$extra .= ';后台返回的重复记录 ' . $duplicates . ' 条已去掉';
|
||
}
|
||
// 自带分页的明细:告诉模型总数和下一页怎么取
|
||
$total = $result['count'] ?? $result['total'] ?? null;
|
||
$pageKey = isset($params['page']) ? 'page' : (isset($params['page_no']) ? 'page_no' : null);
|
||
if (is_numeric($total) && $pageKey !== null && isset($params['page_size'])) {
|
||
$page = (int) $params[$pageKey];
|
||
$hasMore = $page * (int) $params['page_size'] < (int) $total;
|
||
$result['paging'] = ['total' => (int) $total, 'page' => $page, 'page_size' => (int) $params['page_size'], 'has_more' => $hasMore];
|
||
$extra .= sprintf(';共 %d 条,本页第 %d 页 %d 条%s', (int) $total, $page, count($result['lists']), $hasMore ? ',还有更多(page=' . ($page + 1) . ')' : '');
|
||
}
|
||
}
|
||
$rows = $hasLists ? count($result['lists']) : 1;
|
||
RateLimiter::addRows($identity->adminId, $rows);
|
||
$audit['result_rows'] = $rows;
|
||
if ($hasLists) {
|
||
$audit['record_ids'] = AuditLogger::recordIds($result['lists']);
|
||
}
|
||
$data = self::fit(['resource' => $resource['key'], 'name' => $resource['name'], 'params' => $params, 'result' => $result,
|
||
'masked' => $policy->maskedFields(), 'note' => (string) ($resource['note'] ?? '')]);
|
||
return self::ok('「' . $resource['name'] . '」统计结果' . $extra . self::maskNote($policy) . '。', $data);
|
||
}
|
||
|
||
/**
|
||
* 模型常把分页写进 params(如 {"page_size": 50}):列表资源一律提到外层的 page/page_size;
|
||
* 其他资源若本身不支持这些参数就忽略,避免因为这个白白失败一次。
|
||
*/
|
||
private static function liftPaging(array $resource, array $input, array &$args): array
|
||
{
|
||
$isList = in_array($resource['kind'], ['list', 'table'], true);
|
||
$allowed = array_flip(Catalog::allowedParams($resource));
|
||
$names = ['page' => 'page', 'page_no' => 'page', 'pageNo' => 'page', 'page_size' => 'page_size', 'pageSize' => 'page_size', 'limit' => 'page_size', 'size' => 'page_size'];
|
||
foreach ($names as $name => $target) {
|
||
if (!array_key_exists($name, $input)) {
|
||
continue;
|
||
}
|
||
// 非列表资源自己支持的分页参数(如部分统计接口的 page_no/page_size)、以及列表里名为 limit/size 的真实筛选条件原样保留
|
||
if (isset($allowed[$name]) && (!$isList || in_array($name, ['limit', 'size'], true))) {
|
||
continue;
|
||
}
|
||
if (!isset($args[$target]) && is_numeric($input[$name])) {
|
||
$args[$target] = (int) $input[$name];
|
||
}
|
||
unset($input[$name]);
|
||
}
|
||
return $input;
|
||
}
|
||
|
||
/** 取资源并检查开放状态与权限 */
|
||
public static function resource(Identity $identity, string $key): array
|
||
{
|
||
$resource = Catalog::get(trim($key));
|
||
$denied = Catalog::denialFor($identity, $resource);
|
||
if ($denied !== null) {
|
||
throw new McpException($denied, 'denied');
|
||
}
|
||
return $resource;
|
||
}
|
||
|
||
/** 参数白名单 + 类型清洗 + 日期跨度检查 */
|
||
public static function params(array $resource, array $input, bool $trusted = false): array
|
||
{
|
||
$allowed = array_flip(Catalog::allowedParams($resource));
|
||
$forbidden = array_merge(Catalog::GLOBAL_FORBID, (array) ($resource['forbid'] ?? []));
|
||
$clean = [];
|
||
$rejected = [];
|
||
foreach ($input as $name => $value) {
|
||
$name = (string) $name;
|
||
// 快捷统计工具的参数由代码拼好(trusted),可超出白名单,但仍不能带全局或资源禁用的参数
|
||
if (!isset($allowed[$name]) && !($trusted && !in_array($name, $forbidden, true))) {
|
||
$rejected[] = $name;
|
||
continue;
|
||
}
|
||
if (is_bool($value)) {
|
||
$value = $value ? 1 : 0;
|
||
}
|
||
if (is_array($value)) {
|
||
$value = array_values(array_filter($value, 'is_scalar'));
|
||
$value = array_map(static fn ($v) => is_string($v) ? mb_substr(trim($v), 0, 200) : $v, array_slice($value, 0, 100));
|
||
} elseif (is_string($value)) {
|
||
$value = mb_substr(trim($value), 0, 200);
|
||
} elseif (!is_int($value) && !is_float($value) && $value !== null) {
|
||
continue;
|
||
}
|
||
$clean[$name] = $value;
|
||
}
|
||
if ($rejected) {
|
||
throw new McpException('「' . $resource['name'] . '」不支持参数:' . implode('、', $rejected) . '。可用参数:' . (implode('、', array_keys($allowed)) ?: '无') . '(用 zyt_describe 查看说明)', 'invalid');
|
||
}
|
||
foreach ([['start_date', 'end_date'], ['start_time', 'end_time'], ['create_time_start', 'create_time_end'], ['begin_date', 'end_date']] as [$from, $to]) {
|
||
if (!empty($clean[$from]) && !empty($clean[$to]) && is_string($clean[$from]) && is_string($clean[$to])) {
|
||
$a = strtotime($clean[$from]);
|
||
$b = strtotime($clean[$to]);
|
||
if ($a !== false && $b !== false && ($b - $a) / 86400 > McpConfig::maxRangeDays()) {
|
||
throw new McpException('时间范围超过 ' . McpConfig::maxRangeDays() . ' 天,请缩小范围', 'invalid');
|
||
}
|
||
}
|
||
}
|
||
return array_merge($clean, (array) ($resource['force'] ?? []));
|
||
}
|
||
|
||
public static function listParams(array $resource, array $params, int $page, int $size): array
|
||
{
|
||
return array_merge($params, ['page_no' => $page, 'page_size' => $size, 'page_type' => 1], (array) ($resource['force'] ?? []));
|
||
}
|
||
|
||
public static function assertQuota(Identity $identity, int $rows): void
|
||
{
|
||
if (!RateLimiter::hit('calls_' . $identity->adminId, McpConfig::ratePerMinute(), 60)) {
|
||
throw new McpException('调用太频繁,请稍后再试(每分钟最多 ' . McpConfig::ratePerMinute() . ' 次)', 'limited');
|
||
}
|
||
if (RateLimiter::rowsToday($identity->adminId) + $rows > McpConfig::dailyRows()) {
|
||
throw new McpException('今日通过 AI 查询的数据已达上限(' . McpConfig::dailyRows() . ' 条),如需批量数据请使用后台导出', 'limited');
|
||
}
|
||
}
|
||
|
||
public static function failText(array $resource, array $envelope): string
|
||
{
|
||
$msg = trim($envelope['msg']) ?: '查询失败';
|
||
return '「' . $resource['name'] . '」:' . $msg;
|
||
}
|
||
|
||
private static function maskNote(FieldPolicy $policy): string
|
||
{
|
||
return $policy->maskedFields() ? '(部分个人信息已按权限脱敏:' . implode('、', array_slice($policy->maskedFields(), 0, 8)) . ')' : '';
|
||
}
|
||
|
||
/** 控制返回体积:超出上限时截掉尾部行或长字段 */
|
||
public static function fit(array $data): array
|
||
{
|
||
$limit = McpConfig::maxResponseBytes();
|
||
$size = strlen((string) json_encode($data, JSON_UNESCAPED_UNICODE));
|
||
if ($size <= $limit) {
|
||
return $data;
|
||
}
|
||
if (isset($data['rows']) && is_array($data['rows'])) {
|
||
while ($data['rows'] && strlen((string) json_encode($data, JSON_UNESCAPED_UNICODE)) > $limit) {
|
||
array_pop($data['rows']);
|
||
}
|
||
$data['truncated'] = '内容过长,只返回了前 ' . count($data['rows']) . ' 条;请减小 page_size 或用 fields 指定字段';
|
||
return $data;
|
||
}
|
||
$json = (string) json_encode($data['record'] ?? $data['result'] ?? $data, JSON_UNESCAPED_UNICODE);
|
||
$key = isset($data['record']) ? 'record' : (isset($data['result']) ? 'result' : 'data');
|
||
$data[$key] = mb_strcut($json, 0, $limit - 2000) . '…';
|
||
$data['truncated'] = '内容过长,已截断为文本;请增加筛选条件';
|
||
return $data;
|
||
}
|
||
|
||
private static function dig(array $record, string $field)
|
||
{
|
||
if (array_key_exists($field, $record)) {
|
||
return $record[$field];
|
||
}
|
||
foreach ($record as $value) {
|
||
if (is_array($value)) {
|
||
$found = self::dig($value, $field);
|
||
if ($found !== null) {
|
||
return $found;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static function kindText(string $kind): string
|
||
{
|
||
return ['list' => '列表', 'detail' => '详情', 'report' => '统计/查询', 'table' => '数据表', 'other' => '查询'][$kind] ?? '查询';
|
||
}
|
||
|
||
public static function tool(string $name, string $description, array $properties, array $required = []): array
|
||
{
|
||
$schema = ['type' => 'object', 'properties' => $properties ?: new \stdClass(), 'additionalProperties' => false];
|
||
if ($required) {
|
||
$schema['required'] = $required;
|
||
}
|
||
return ['name' => $name, 'description' => $description, 'inputSchema' => $schema, 'annotations' => self::READ_ONLY];
|
||
}
|
||
|
||
private static function ok(string $summary, array $data): array
|
||
{
|
||
return [
|
||
'content' => [['type' => 'text', 'text' => $summary . "\n" . json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]],
|
||
'structuredContent' => $data ?: new \stdClass(),
|
||
'isError' => false,
|
||
];
|
||
}
|
||
|
||
private static function error(string $message): array
|
||
{
|
||
return ['content' => [['type' => 'text', 'text' => $message]], 'isError' => true];
|
||
}
|
||
|
||
/**
|
||
* 高频统计的快捷工具:固定资源 + 友好参数。只有账号能用对应资源时才出现在工具列表里。
|
||
*/
|
||
private static function presets(): array
|
||
{
|
||
$date = ['type' => 'string', 'description' => '日期 YYYY-MM-DD'];
|
||
return [
|
||
'zyt_stats_appointments' => [
|
||
'resource' => 'doctor.appointment/lists', 'mode' => 'count', 'summary' => '挂号/接诊记录',
|
||
'description' => '统计一段日期内的挂号/接诊数量,并按状态(已预约/已取消/已完成/已过号)分组计数,可按医生筛选。医生账号自动只统计本人,医助只统计自己的患者。',
|
||
'args' => ['start_date' => $date, 'end_date' => $date, 'doctor_id' => ['type' => 'integer', 'description' => '医生ID(可选)'],
|
||
'status' => ['type' => 'integer', 'description' => '只统计某状态:1 已预约、2 已取消、3 已完成、4 已过号(可选)']],
|
||
'required' => ['start_date', 'end_date'],
|
||
'note' => 'extend.status_count 为各状态数量(1 已预约、2 已取消、3 已完成、4 已过号),按预约日期统计。',
|
||
'map' => static fn (array $a) => array_filter(['start_date' => $a['start_date'] ?? null, 'end_date' => $a['end_date'] ?? null,
|
||
'doctor_id' => $a['doctor_id'] ?? null, 'status' => $a['status'] ?? null, 'include_status_counts' => 1], static fn ($v) => $v !== null && $v !== ''),
|
||
],
|
||
'zyt_stats_doctor_workload' => [
|
||
'resource' => 'doctor.statistics/lists', 'summary' => '医生工作量',
|
||
'description' => '按医生统计一段时间的挂号总数、已完成、过号、取消、接诊患者数、成交(开方)数。',
|
||
'args' => ['start_date' => $date, 'end_date' => $date, 'doctor_id' => ['type' => 'integer', 'description' => '只看某位医生(可选)']],
|
||
'required' => ['start_date', 'end_date'],
|
||
'map' => static fn (array $a) => array_filter(['time_type' => 'custom', 'start_date' => $a['start_date'] ?? null, 'end_date' => $a['end_date'] ?? null,
|
||
'doctor_id' => $a['doctor_id'] ?? null], static fn ($v) => $v !== null && $v !== ''),
|
||
],
|
||
'zyt_stats_orders' => [
|
||
'resource' => 'order.order/orderStats', 'summary' => '收款订单统计',
|
||
'description' => '统计截至某日的最近 N 天(1–90)已支付收款订单金额与笔数;order_type:-1 全部已支付、0 退款、1–8 为各费用类型。',
|
||
'args' => ['end_date' => $date, 'days' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 90, 'description' => '最近多少天'],
|
||
'order_type' => ['type' => 'integer', 'description' => '-1 全部已支付(默认)、0 退款、1–8 费用类型']],
|
||
'required' => ['end_date', 'days'],
|
||
'map' => static fn (array $a) => ['end_time' => ($a['end_date'] ?? date('Y-m-d')) . ' 23:59:59', 'days' => max(1, min(90, (int) ($a['days'] ?? 7))),
|
||
'order_type' => (int) ($a['order_type'] ?? -1)],
|
||
],
|
||
'zyt_stats_prescription_orders' => [
|
||
'resource' => 'tcm.prescriptionOrder/lists', 'mode' => 'count', 'summary' => '处方业务订单',
|
||
'description' => '统计一段时间内处方业务订单的数量和金额(extend 中的 stats_* 字段),可按医生、医助筛选。',
|
||
'args' => ['start_date' => $date, 'end_date' => $date, 'doctor_id' => ['type' => 'integer', 'description' => '医生ID(可选)'],
|
||
'assistant_id' => ['type' => 'integer', 'description' => '医助ID(可选)']],
|
||
'required' => ['start_date', 'end_date'],
|
||
'note' => '金额口径以 extend 中 stats_* 字段为准(与后台处方订单列表顶部统计一致)。',
|
||
'map' => static fn (array $a) => array_filter(['start_time' => ($a['start_date'] ?? '') . ' 00:00:00', 'end_time' => ($a['end_date'] ?? '') . ' 23:59:59',
|
||
'doctor_id' => $a['doctor_id'] ?? null, 'assistant_id' => $a['assistant_id'] ?? null], static fn ($v) => $v !== null && $v !== ''),
|
||
],
|
||
'zyt_my_patients' => [
|
||
'resource' => 'firstvisit.myPatient/lists', 'summary' => '我的患者',
|
||
'description' => '按姓名/手机号关键字查找“我的患者”(医生看自己接诊过的,医助看自己负责的),返回诊单ID、最近就诊和下次预约。',
|
||
'args' => ['keyword' => ['type' => 'string', 'description' => '姓名或手机号(可选)'], 'page' => ['type' => 'integer', 'minimum' => 1]],
|
||
'required' => [],
|
||
'map' => static fn (array $a) => array_filter(['keyword' => $a['keyword'] ?? null], static fn ($v) => $v !== null && $v !== ''),
|
||
],
|
||
'zyt_roster' => [
|
||
'resource' => 'doctor.roster/lists', 'summary' => '医生排班',
|
||
'description' => '查询医生排班:日期、时段、出诊状态(1 出诊、2 停诊、3 休息、4 请假)、号源与已约数。',
|
||
'args' => ['start_date' => $date, 'end_date' => $date, 'doctor_id' => ['type' => 'integer', 'description' => '医生ID(可选)']],
|
||
'required' => ['start_date', 'end_date'],
|
||
'map' => static fn (array $a) => array_filter(['start_date' => $a['start_date'] ?? null, 'end_date' => $a['end_date'] ?? null,
|
||
'doctor_id' => $a['doctor_id'] ?? null], static fn ($v) => $v !== null && $v !== ''),
|
||
],
|
||
];
|
||
}
|
||
}
|