Files
zyt/server/tests/AiMcpPerfTest.php
T
2026-09-24 11:48:20 +08:00

292 lines
23 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);
/**
* AI 助手(MCP)业绩工具测试:zyt_perf_assistants / zyt_perf_doctors / zyt_stats_performance / zyt_perf_trend。
* 覆盖:金额口径(取消单不计、部分退款单医助计入而医生不计)、排名与合计、数据范围(经理看本部门、医助只看自己)、
* 部门名称解析、趋势按日/周/月归并、图表输出、权限点未登记时回退到 Tab 权限、params 里写分页参数。
*
* 需要与 AiMcpHttpContractTest 相同的一次性测试库(库名以 _test 结尾)和指向它的运行实例:
* AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpPerfTest.php
* 夹具 ID 段:账号 92001-92009、部门 9921-9923、角色 197-199、诊单 95011-95013、处方 8101-8104、订单号 PERFT*(2031 年 2 月)。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use think\App;
use think\facade\Db;
function aiMcpPerfExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$base = rtrim((string) getenv('AI_MCP_TEST_BASE_URL'), '/');
if (getenv('AI_MCP_TEST_MYSQL') !== '1' || $base === '') {
echo "AiMcpPerfTest SKIP (set AI_MCP_TEST_MYSQL=1, AI_MCP_TEST_BASE_URL and PHP_DATABASE_* for a disposable *_test database)\n";
exit(0);
}
$app = new App(dirname(__DIR__) . DIRECTORY_SEPARATOR);
$app->initialize();
$database = (string) config('database.connections.' . config('database.default') . '.database');
aiMcpPerfExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)");
aiMcpPerfExpect((int) Db::name('system_menu')->where('perms', 'ai.mcp/access')->count() === 1, 'run 2026_09_24_ai_mcp.sql on the test database first');
// ---------------------------------------------------------------- 夹具
$now = time();
$pwd = create_password('Test@123456', (string) config('project.unique_identification'));
$roles = [197 => ['业绩经理', 2], 198 => ['业绩受限经理', 2], 199 => ['业绩医助附加', 4]];
Db::name('system_role')->whereIn('id', array_keys($roles))->delete();
foreach ($roles as $id => [$name, $scope]) {
Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-perf-test', 'sort' => 0, 'data_scope' => $scope, 'create_time' => $now, 'update_time' => $now]);
}
Db::name('dept')->whereIn('id', [9921, 9922, 9923])->delete();
foreach ([9921 => ['AI业绩总部', 0], 9922 => ['AI业绩一中心', 9921], 9923 => ['AI业绩三中心', 9921]] as $id => [$name, $pid]) {
Db::name('dept')->insert(['id' => $id, 'name' => $name, 'pid' => $pid, 'sort' => 0, 'leader' => '', 'mobile' => '', 'status' => 1, 'create_time' => $now, 'update_time' => $now]);
}
// 医助、医生必须用系统角色 2 / 1:医助排行榜和医生统计按这两个角色取人
$admins = [
92001 => ['p_mgr', '业绩经理甲', [197], 9921], 92002 => ['p_doc_a', '业绩张医生', [1], 9922], 92003 => ['p_doc_b', '业绩李医生', [1], 9923],
92004 => ['p_asst_c', '业绩王医助', [2, 199], 9922], 92005 => ['p_lim', '业绩受限乙', [198], 9921], 92008 => ['p_root', '业绩超管', [], 9921],
92009 => ['p_asst_g', '业绩钱医助', [2], 9923],
];
Db::name('admin')->whereIn('id', array_keys($admins))->delete();
Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('admin_dept')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete();
foreach ($admins as $id => [$account, $name, $roleIds, $dept]) {
Db::name('admin')->insert(['id' => $id, 'root' => $account === 'p_root' ? 1 : 0, 'name' => $name, 'avatar' => '', 'account' => $account, 'password' => $pwd, 'multipoint_login' => 1,
'is_paw' => 1, 'work_wechat_userid' => '', 'disable' => 0, 'phone' => '1370000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]);
foreach ($roleIds as $roleId) {
Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $roleId]);
}
Db::name('admin_dept')->insert(['admin_id' => $id, 'dept_id' => $dept]);
}
$menuId = static function (string $perm) use ($now): int {
$id = (int) Db::name('system_menu')->where('perms', $perm)->value('id');
return $id ?: (int) Db::name('system_menu')->insertGetId(['pid' => 0, 'type' => 'A', 'name' => 'AI测试 ' . $perm, 'icon' => '', 'sort' => 0, 'perms' => $perm,
'paths' => '', 'component' => '', 'selected' => '', 'params' => '', 'is_cache' => 0, 'is_show' => 0, 'is_disable' => 0, 'create_time' => $now, 'update_time' => $now]);
};
$leaderboardMenu = $menuId('stats.yejiStats/leaderboard');
Db::name('system_menu')->where('id', $leaderboardMenu)->update(['is_disable' => 0]);
$grantsByRole = [
197 => ['ai.mcp/access', 'stats.yejiStats/leaderboard', 'stats.doctorDailyStats/overview', 'stats.yejiStats/overview', 'tcm.prescriptionOrder/lists'],
198 => ['ai.mcp/access', 'stats.yejiStats/overview', 'stats.yejiStats/tabLeaderboard'],
199 => ['ai.mcp/access', 'stats.yejiStats/leaderboard'],
];
Db::name('system_role_menu')->whereIn('role_id', array_keys($grantsByRole))->delete();
foreach ($grantsByRole as $role => $perms) {
foreach ($perms as $perm) {
Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $menuId($perm)]);
}
}
Db::name('tcm_diagnosis')->whereIn('id', [95011, 95012, 95013])->delete();
foreach ([95011 => ['业一', 92004], 95012 => ['业二', 92004], 95013 => ['业三', 92009]] as $id => [$name, $assistant]) {
Db::name('tcm_diagnosis')->insert(['id' => $id, 'patient_id' => $id + 1000, 'patient_name' => $name, 'phone' => '1382222' . substr((string) $id, -4),
'id_card' => '', 'gender' => 1, 'age' => 40, 'status' => 1, 'assistant_id' => $assistant, 'create_time' => $now, 'update_time' => $now]);
}
Db::name('tcm_prescription')->whereIn('id', [8101, 8102, 8103, 8104])->delete();
foreach ([8101 => [95011, 92002], 8102 => [95012, 92003], 8103 => [95013, 92003], 8104 => [95012, 92002]] as $id => [$diag, $doctor]) {
Db::name('tcm_prescription')->insert(['id' => $id, 'sn' => 'PERFRX' . $id, 'diagnosis_id' => $diag, 'creator_id' => $doctor, 'prescription_date' => '2031-02-01']);
}
Db::name('tcm_prescription_order')->whereLike('order_no', 'PERFT%')->delete();
$orders = [
// [处方, 诊单, 创建人(医助), 金额, 履约状态, 退款, 创建时间]
['PERFT1', 8101, 95011, 92004, 1000, 3, 0, '2031-02-03 10:00:00'],
['PERFT2', 8101, 95011, 92004, 500, 4, 0, '2031-02-04 10:00:00'], // 已取消:都不计
['PERFT3', 8102, 95012, 92004, 700, 5, 0, '2031-02-10 09:00:00'],
['PERFT4', 8103, 95013, 92009, 2500, 3, 300, '2031-02-10 15:00:00'], // 部分退款:医助诊金计入,医生成交金额不计
['PERFT5', 8104, 95012, 92009, 400, 1, 0, '2031-02-20 11:00:00'],
];
foreach ($orders as [$no, $rx, $diag, $creator, $amount, $status, $refund, $time]) {
Db::name('tcm_prescription_order')->insert(['order_no' => $no, 'prescription_id' => $rx, 'diagnosis_id' => $diag, 'creator_id' => $creator,
'amount' => $amount, 'fulfillment_status' => $status, 'refund_amount' => $refund, 'create_time' => strtotime($time)]);
}
// 已完成的挂号(面诊完成数,后台列名“接诊单数”):王医助 2 人次、钱医助 1 人次——与订单数无关
Db::name('doctor_appointment')->whereIn('id', [96201, 96202, 96203])->delete();
foreach ([96201 => [95011, 92002, 92004, '2031-02-03'], 96202 => [95012, 92003, 92004, '2031-02-10'], 96203 => [95013, 92003, 92009, '2031-02-10']] as $id => [$diag, $doctor, $assistant, $day]) {
Db::name('doctor_appointment')->insert(['id' => $id, 'patient_id' => $diag, 'doctor_id' => $doctor, 'assistant_id' => $assistant, 'roster_id' => 0,
'appointment_date' => $day, 'period' => 'morning', 'appointment_time' => '09:00:00', 'appointment_type' => 'video', 'status' => 3,
'remark' => '', 'channel_source' => '', 'create_time' => $now, 'update_time' => $now]);
}
\think\facade\Cache::clear();
// ---------------------------------------------------------------- HTTP 工具
function aiMcpPerfHttp(string $url, array $body, array $headers): array
{
$ch = curl_init($url);
$lines = ['Content-Type: application/json'];
foreach ($headers as $k => $v) {
$lines[] = $k . ': ' . $v;
}
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_TIMEOUT => 120,
CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE)]);
$raw = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$status, json_decode($raw, true)];
}
$grant = static function (string $account) use ($base): string {
[, $body] = aiMcpPerfHttp($base . '/mcp/auth/grant', ['account' => $account, 'password' => 'Test@123456', 'client' => 'xingzhi', 'client_instance' => 'perf-test'], []);
aiMcpPerfExpect(($body['code'] ?? null) === 1, "grant for {$account}: " . json_encode($body, JSON_UNESCAPED_UNICODE));
return (string) $body['data']['token'];
};
$rpc = static function (string $token, string $method, array $params = []) use ($base): array {
static $id = 0;
[$status, $body] = aiMcpPerfHttp($base . '/mcp', ['jsonrpc' => '2.0', 'id' => ++$id, 'method' => $method, 'params' => $params],
['Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json, text/event-stream', 'X-Xingzhi-Task-Id' => 'perf-task']);
aiMcpPerfExpect($status === 200 && isset($body['result']), "{$method} should return a result, got HTTP {$status}: " . json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['result'];
};
$toolNames = static fn (string $token): array => array_column($rpc($token, 'tools/list')['tools'] ?? [], 'name');
$call = static function (string $token, string $name, array $args) use ($rpc): array {
return $rpc($token, 'tools/call', ['name' => $name, 'arguments' => $args]);
};
$ok = static function (string $token, string $name, array $args) use ($call): array {
$result = $call($token, $name, $args);
aiMcpPerfExpect(empty($result['isError']), "{$name} " . json_encode($args, JSON_UNESCAPED_UNICODE) . ' should succeed: ' . ($result['content'][0]['text'] ?? ''));
return $result;
};
$byId = static fn (array $rows): array => array_column($rows, null, 'admin_id');
$feb = ['start_date' => '2031-02-01', 'end_date' => '2031-02-28'];
$mgr = $grant('p_mgr');
$asst = $grant('p_asst_c');
$lim = $grant('p_lim');
$root = $grant('p_root');
// ---------------------------------------------------------------- 工具列表
$names = $toolNames($mgr);
foreach (['zyt_perf_assistants', 'zyt_perf_doctors', 'zyt_stats_performance', 'zyt_perf_trend'] as $name) {
aiMcpPerfExpect(in_array($name, $names, true), "manager sees {$name}");
}
foreach ($rpc($mgr, 'tools/list')['tools'] as $definition) {
aiMcpPerfExpect(($definition['annotations']['readOnlyHint'] ?? false) === true, $definition['name'] . ' is annotated read-only');
}
$names = $toolNames($asst);
aiMcpPerfExpect(in_array('zyt_perf_assistants', $names, true) && !in_array('zyt_perf_doctors', $names, true), 'assistant sees only the tools its permissions allow');
// ---------------------------------------------------------------- 医助排行
$result = $ok($mgr, 'zyt_perf_assistants', $feb);
$data = $result['structuredContent'];
$rows = $byId($data['rows']);
aiMcpPerfExpect(array_keys($rows) === [92009, 92004], 'manager ranks its two assistants by fee: ' . json_encode(array_keys($rows)));
aiMcpPerfExpect($rows[92009]['fee_amount'] == 2900 && $rows[92009]['deal_order_count'] === 2 && $rows[92009]['rank'] === 1, 'partially refunded order counts for the assistant fee');
aiMcpPerfExpect($rows[92004]['fee_amount'] == 1700 && $rows[92004]['deal_order_count'] === 2, 'cancelled order is excluded from the assistant fee');
aiMcpPerfExpect($data['totals']['fee_amount'] == 4600 && $data['totals']['deal_order_count'] === 4 && $data['assistants'] === 2, 'totals add up: ' . json_encode($data['totals']));
aiMcpPerfExpect(($data['chart']['type'] ?? '') === 'bar' && $data['chart']['labels'] === ['业绩钱医助', '业绩王医助'] && $data['chart']['series'][0]['data'] == [2900, 1700], 'ranking chart: ' . json_encode($data['chart'] ?? null, JSON_UNESCAPED_UNICODE));
aiMcpPerfExpect(str_contains($result['content'][0]['text'], "```chart\n{") && str_contains($result['content'][0]['text'], '业绩钱医助'), 'text result carries the chart block');
$data = $ok($mgr, 'zyt_perf_assistants', $feb + ['name' => '王'])['structuredContent'];
aiMcpPerfExpect(count($data['rows']) === 1 && $data['rows'][0]['admin_id'] === 92004 && $data['rows'][0]['rank'] === 2 && !isset($data['chart']), 'name filter keeps the overall rank and draws no one-bar chart');
$data = $ok($mgr, 'zyt_perf_assistants', $feb + ['dept' => 'AI业绩一中心'])['structuredContent'];
aiMcpPerfExpect(array_column($data['rows'], 'admin_id') === [92004] && $data['totals']['fee_amount'] == 1700, 'department name filter');
$data = $ok($mgr, 'zyt_perf_assistants', $feb + ['sort_by' => 'deal_order_count', 'top' => 1])['structuredContent'];
aiMcpPerfExpect(count($data['rows']) === 1 && $data['rows'][0]['admin_id'] === 92009, 'sort_by with tie falls back to fee; top limits rows');
$data = $ok($root, 'zyt_perf_assistants', $feb + ['dept' => 'AI业绩一中心,AI业绩三中心'])['structuredContent'];
$ids = array_column($data['rows'], 'admin_id');
sort($ids);
aiMcpPerfExpect($ids === [92004, 92009], 'root with two department names sees both assistants');
$data = $ok($asst, 'zyt_perf_assistants', $feb)['structuredContent'];
aiMcpPerfExpect(array_column($data['rows'], 'admin_id') === [92004] && $data['totals']['fee_amount'] == 1700, 'assistant with self data scope only sees itself');
// 指标名称:后台“接诊单数”是面诊完成人次,不能被说成订单数(线上把 96 人次面诊说成了 96 单)
$result = $ok($mgr, 'zyt_perf_assistants', $feb + ['sort_by' => 'consult_count']);
$data = $result['structuredContent'];
aiMcpPerfExpect(str_contains($data['columns']['consult_count'], '面诊完成数') && str_contains($data['columns']['consult_count'], '接诊单数')
&& str_contains($data['columns']['deal_order_count'], '成交订单数') && str_contains($data['definitions']['consult_count'], '不是订单数'), 'metric names say what is counted: ' . json_encode($data['columns'], JSON_UNESCAPED_UNICODE));
aiMcpPerfExpect(($data['chart']['title'] ?? '') === '医助面诊完成数排行' && $data['chart']['unit'] === '人次' && $data['chart']['series'][0]['data'] == [2, 1]
&& str_contains($data['chart']['note'], '不是订单数'), 'completed consultations chart: ' . json_encode($data['chart'] ?? null, JSON_UNESCAPED_UNICODE));
aiMcpPerfExpect(str_contains($result['content'][0]['text'], '不是订单数') && str_contains($result['content'][0]['text'], '成交订单 4 单'), 'summary warns that consultations are not orders');
// 只看一个人:附上处方业务订单列表(按医助筛选)的数字,列表含已取消的 1 单,业绩不含
$result = $ok($mgr, 'zyt_perf_assistants', $feb + ['name' => '王']);
$check = $result['structuredContent']['order_list'] ?? null;
aiMcpPerfExpect($check !== null && $check['total'] === 3 && $check['amount'] == 2200 && $check['performance_amount'] == 1700 && $check['not_counted'] === 1,
'single assistant carries the order-list reconciliation: ' . json_encode($check, JSON_UNESCAPED_UNICODE));
aiMcpPerfExpect(str_contains($result['content'][0]['text'], '对账') && str_contains($result['content'][0]['text'], '成交订单是 2 单'), 'reconciliation is spelled out in the text');
$data = $ok($mgr, 'zyt_perf_doctors', $feb + ['name' => '张'])['structuredContent'];
aiMcpPerfExpect(!isset($data['order_list']), 'doctor reconciliation is skipped for accounts that only see their own orders in the list');
$check = $ok($root, 'zyt_perf_doctors', $feb + ['name' => '业绩张'])['structuredContent']['order_list'] ?? null;
aiMcpPerfExpect($check !== null && $check['total'] === 3 && $check['not_counted'] === 1, 'doctor reconciliation for accounts that see all orders: ' . json_encode($check, JSON_UNESCAPED_UNICODE));
// ---------------------------------------------------------------- 医生排行
$data = $ok($mgr, 'zyt_perf_doctors', $feb)['structuredContent'];
$rows = $byId($data['rows']);
aiMcpPerfExpect(array_keys($rows) === [92002, 92003], 'manager sees the two doctors of its department, ranked: ' . json_encode(array_keys($rows)));
aiMcpPerfExpect($rows[92002]['deal_amount'] == 1400 && $rows[92002]['deal_order_count'] === 2, 'doctor amount excludes cancelled orders');
aiMcpPerfExpect($rows[92003]['deal_amount'] == 700 && $rows[92003]['deal_order_count'] === 1, 'doctor amount excludes refunded orders');
aiMcpPerfExpect(($data['chart']['labels'] ?? []) === ['业绩张医生', '业绩李医生'] && ($data['chart']['unit'] ?? '') === '元', 'doctor ranking chart');
// ---------------------------------------------------------------- 部门业绩看板
$data = $ok($mgr, 'zyt_stats_performance', $feb)['structuredContent'];
$amounts = array_column($data['rows'], 'performance_amount', 'dept_id');
aiMcpPerfExpect(($amounts[9922] ?? null) == 1700 && ($amounts[9923] ?? null) == 2900, 'department board: ' . json_encode($amounts));
aiMcpPerfExpect(($data['chart']['series'][0]['data'] ?? []) == [2900, 1700], 'department chart sorted by amount');
// ---------------------------------------------------------------- 业绩走势
$data = $ok($mgr, 'zyt_perf_trend', $feb + ['names' => '业绩王医助,业绩钱医助', 'granularity' => 'day'])['structuredContent'];
aiMcpPerfExpect(count($data['periods']) === 28 && $data['periods'][0] === '02-01' && count($data['series']) === 2, 'daily buckets for February');
[$wang, $qian] = $data['series'];
aiMcpPerfExpect($wang['admin_id'] === 92004 && $wang['amount'][2] == 1000 && $wang['amount'][9] == 700 && $wang['total_amount'] == 1700 && $wang['total_orders'] === 2, 'assistant daily trend: ' . json_encode($wang));
aiMcpPerfExpect($qian['amount'][9] == 2500 && $qian['amount'][19] == 400 && $qian['total_amount'] == 2900, 'second series');
aiMcpPerfExpect(($data['chart']['type'] ?? '') === 'line' && count($data['chart']['series']) === 2 && count($data['chart']['labels']) === 28, 'trend chart is a two-series line');
$data = $ok($mgr, 'zyt_perf_trend', $feb)['structuredContent'];
aiMcpPerfExpect(count($data['series']) === 1 && $data['series'][0]['total_amount'] == 4600 && $data['granularity'] === 'day', 'trend without names sums the visible scope');
$data = $ok($mgr, 'zyt_perf_trend', $feb + ['names' => '业绩王医助', 'granularity' => 'week'])['structuredContent'];
aiMcpPerfExpect($data['periods'] === ['02-01~02-02', '02-03~02-09', '02-10~02-16', '02-17~02-23', '02-24~02-28'] && $data['series'][0]['amount'] == [0, 1000, 700, 0, 0], 'weekly buckets start on Monday: ' . json_encode($data['periods']));
$data = $ok($mgr, 'zyt_perf_trend', $feb + ['granularity' => 'month', 'metric' => 'orders'])['structuredContent'];
aiMcpPerfExpect($data['periods'] === ['2月'] && ($data['chart']['type'] ?? '') === 'column' && $data['chart']['series'][0]['data'] == [4], 'single month becomes a column chart of order counts');
$data = $ok($mgr, 'zyt_perf_trend', $feb + ['by' => 'doctor'])['structuredContent'];
aiMcpPerfExpect($data['series'][0]['total_amount'] == 2100 && $data['series'][0]['total_orders'] === 3, 'doctor trend uses the doctor amount rule');
$data = $ok($asst, 'zyt_perf_trend', $feb)['structuredContent'];
aiMcpPerfExpect($data['series'][0]['name'] === '业绩王医助' && $data['series'][0]['total_amount'] == 1700, 'assistant trend is named after itself');
$result = $call($asst, 'zyt_perf_trend', $feb + ['names' => '业绩钱医助']);
aiMcpPerfExpect(!empty($result['isError']) && str_contains($result['content'][0]['text'], '找不到'), 'assistant cannot look at a colleague outside its scope');
$result = $call($mgr, 'zyt_perf_trend', $feb + ['names' => 'a,b,c,d,e']);
aiMcpPerfExpect(!empty($result['isError']) && str_contains($result['content'][0]['text'], '最多'), 'at most four people');
// ---------------------------------------------------------------- 参数校验
foreach ([['sort_by' => 'nope'], ['period' => 'someday'], ['start_date' => '2031-02-10', 'end_date' => '2031-02-01'], ['start_date' => '2031/02/01'], ['dept' => '不存在的部门']] as $bad) {
$result = $call($mgr, 'zyt_perf_assistants', $bad);
aiMcpPerfExpect(!empty($result['isError']), 'invalid arguments are rejected: ' . json_encode($bad, JSON_UNESCAPED_UNICODE));
}
$data = $ok($mgr, 'zyt_perf_assistants', ['period' => 'this_month'])['structuredContent'];
aiMcpPerfExpect($data['period']['start_date'] === date('Y-m-01') && $data['period']['end_date'] === date('Y-m-d'), 'period shortcut resolves on the server');
// ---------------------------------------------------------------- 权限:未登记时回退到 Tab 权限
aiMcpPerfExpect(!in_array('zyt_perf_assistants', $toolNames($lim), true), 'tab permission alone does not open the leaderboard while its own permission is registered');
$result = $call($lim, 'zyt_perf_assistants', $feb);
aiMcpPerfExpect(!empty($result['isError']) && str_contains($result['content'][0]['text'], '无权限'), 'calling it anyway is denied');
Db::name('system_menu')->where('id', $leaderboardMenu)->update(['is_disable' => 1]);
try {
aiMcpPerfExpect(in_array('zyt_perf_assistants', $toolNames($lim), true), 'with the leaderboard permission unregistered, the tab permission applies');
$data = $ok($lim, 'zyt_perf_assistants', $feb)['structuredContent'];
aiMcpPerfExpect(count($data['rows']) === 2, 'fallback permission returns the same data scope');
aiMcpPerfExpect(!in_array('zyt_perf_doctors', $toolNames($lim), true), 'no doctor tools without doctor permissions');
} finally {
Db::name('system_menu')->where('id', $leaderboardMenu)->update(['is_disable' => 0]);
}
// ---------------------------------------------------------------- params 里写分页
$result = $ok($root, 'zyt_query', ['resource' => 'doctor.appointment/lists', 'params' => ['start_date' => '2026-01-01', 'end_date' => '2026-12-31', 'page_size' => 2, 'page' => 1]]);
aiMcpPerfExpect(($result['structuredContent']['page_size'] ?? 0) === 2, 'page_size inside params is treated as paging');
// 自带分页的统计明细:外层 page/page_size 生效(以前被忽略,翻页永远拿到第一页,看起来像同一批记录反复出现)
$lines = static fn (int $page) => $ok($root, 'zyt_query', ['resource' => 'stats.yejiStats/appointmentLines', 'params' => $feb + ['assistant_id' => 92004], 'page' => $page, 'page_size' => 1])['structuredContent']['result'];
[$first, $second] = [$lines(1), $lines(2)];
aiMcpPerfExpect(($first['paging'] ?? null) == ['total' => 2, 'page' => 1, 'page_size' => 1, 'has_more' => true] && ($second['paging']['has_more'] ?? null) === false,
'report paging follows the outer page arguments: ' . json_encode([$first['paging'] ?? null, $second['paging'] ?? null]));
aiMcpPerfExpect(count($first['lists']) === 1 && count($second['lists']) === 1 && $first['lists'][0]['id'] !== $second['lists'][0]['id'], 'page 2 returns the next record, not page 1 again');
// ---------------------------------------------------------------- 审计
$logged = Db::name('ai_access_log')->where('admin_id', 92001)->where('tool', 'zyt_perf_assistants')->where('client_task_id', 'perf-task')->order('id', 'desc')->find();
aiMcpPerfExpect($logged && $logged['resource'] === 'stats.yejiStats/leaderboard' && $logged['status'] === 'ok', 'performance calls are audited');
echo "AiMcpPerfTest OK\n";