Files
zyt/server/tests/AiMcpLogisticsTest.php
T
2026-09-28 10:04:24 +08:00

254 lines
20 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_logistics_order / zyt_logistics_patient。
* 覆盖:只查得到「处方业务订单」列表范围内的订单(医助只看自己创建的);已同步轨迹(状态、签收时间、最新在前、来源和更新时间);
* 收件人电话、患者电话、轨迹文字里的手机号脱敏,不返回详细地址;没有「物流轨迹」权限时不给轨迹;未填单号;
* 同名患者分开列出、按患者ID给最近一单完整轨迹;库里没有轨迹时的实时查询(本测试关闭了快递100 / 京东接口,
* 断言不写库、不外呼)及每小时次数上限;参数校验;工具中文名;审计记录。
*
* 需要一次性测试库(库名以 _test 结尾)和指向它的运行实例;运行实例请关闭物流外部接口,避免测试外呼:
* PHP_LOGISTICS_KUAIDI100_DISABLE=true PHP_JD_LOGISTICS_DISABLE=true php -S 127.0.0.1:8099 -t public public/router.php
* AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpLogisticsTest.php
* 夹具 ID 段:账号 93101-93103、角色 297-298、诊单 95101-95104、订单号 LOGT*、运单号 *LOGT*(2031 年 3 月)。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use think\App;
use think\cache\driver\File as FileCache;
use think\facade\Db;
function aiMcpLogisticsExpect(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 "AiMcpLogisticsTest 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');
aiMcpLogisticsExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)");
aiMcpLogisticsExpect((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 = [297 => ['物流医助', ['ai.mcp/access', 'tcm.prescriptionOrder/lists', 'tcm.prescriptionOrder/logisticsTrace']],
298 => ['物流无轨迹权限', ['ai.mcp/access', 'tcm.prescriptionOrder/lists']]];
Db::name('system_role')->whereIn('id', array_keys($roles))->delete();
Db::name('system_role_menu')->whereIn('role_id', array_keys($roles))->delete();
foreach ($roles as $id => [$name, $perms]) {
// data_scope 4:仅本人;角色 ID 不在 order_edit_all_roles 里,只看自己创建的订单
Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-logistics-test', 'sort' => 0, 'data_scope' => 4, 'create_time' => $now, 'update_time' => $now]);
foreach ($perms as $perm) {
Db::name('system_role_menu')->insert(['role_id' => $id, 'menu_id' => (int) Db::name('system_menu')->where('perms', $perm)->value('id')]);
}
}
$admins = [93101 => ['l_asst', 297], 93102 => ['l_other', 297], 93103 => ['l_notrace', 298]];
Db::name('admin')->whereIn('id', array_keys($admins))->delete();
Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('ai_access_log')->whereIn('admin_id', array_keys($admins))->delete();
foreach ($admins as $id => [$account, $role]) {
Db::name('admin')->insert(['id' => $id, 'root' => 0, 'name' => $account, 'avatar' => '', 'account' => $account, 'password' => $pwd, 'multipoint_login' => 1,
'is_paw' => 1, 'work_wechat_userid' => '', 'disable' => 0, 'phone' => '1360000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]);
Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $role]);
}
Db::name('tcm_diagnosis')->whereIn('id', [95101, 95102, 95103, 95104])->delete();
foreach ([95101 => ['物流甲', '13911110001', 93101], 95102 => ['物流甲', '13911110002', 93101], 95103 => ['物流乙', '13911110003', 93102],
95104 => ['物流丙', '13911110004', 93103]] as $id => [$name, $phone, $assistant]) {
Db::name('tcm_diagnosis')->insert(['id' => $id, 'patient_id' => $id + 1000, 'patient_name' => $name, 'phone' => $phone, 'id_card' => '', 'gender' => 1, 'age' => 50,
'status' => 1, 'assistant_id' => $assistant, 'create_time' => $now, 'update_time' => $now]);
}
Db::name('tcm_prescription_order')->whereLike('order_no', 'LOGT%')->delete();
$orders = [];
foreach ([
// 订单号 => [创建人, 诊单, 履约状态, 快递公司, 运单号, 创建时间]
'LOGT1' => [93101, 95101, 6, 'jd', 'JDVELOGT0001', '2031-03-01 10:00:00'],
'LOGT2' => [93101, 95101, 5, 'sf', 'SFLOGT0002', '2031-03-05 10:00:00'],
'LOGT3' => [93101, 95102, 2, '', '', '2031-03-06 10:00:00'],
'LOGT4' => [93101, 95101, 5, 'yt', 'YTLOGT0004', '2031-03-07 10:00:00'], // 库里没有轨迹 → 实时查询
'LOGT5' => [93102, 95103, 5, 'jd', 'JDVELOGT0005', '2031-03-02 10:00:00'], // 别人的订单
'LOGT6' => [93103, 95104, 5, 'jd', 'JDVELOGT0006', '2031-03-03 10:00:00'], // 没有物流轨迹权限的账号
] as $no => [$creator, $diag, $status, $company, $number, $time]) {
$orders[$no] = (int) Db::name('tcm_prescription_order')->insertGetId(['order_no' => $no, 'prescription_id' => 0, 'diagnosis_id' => $diag, 'creator_id' => $creator,
'amount' => 300, 'fulfillment_status' => $status, 'refund_amount' => 0, 'express_company' => $company, 'tracking_number' => $number,
'recipient_name' => '收件人' . $no, 'recipient_phone' => '1391111' . sprintf('%04d', $diag - 95100), 'shipping_address' => '河南省洛阳市洛龙区开元大道 88 号 3 栋',
'shipping_province' => '河南省', 'shipping_city' => '洛阳市', 'shipping_district' => '洛龙区', 'create_time' => strtotime($time)]);
}
$numbers = ['JDVELOGT0001', 'SFLOGT0002', 'YTLOGT0004', 'JDVELOGT0005', 'JDVELOGT0006'];
$old = Db::name('express_tracking')->whereIn('tracking_number', $numbers)->column('id');
Db::name('express_trace')->whereIn('tracking_id', $old ?: [0])->delete();
Db::name('express_tracking')->whereIn('tracking_number', $numbers)->delete();
Db::name('express_query_log')->whereIn('tracking_number', $numbers)->delete();
foreach ([
'JDVELOGT0001' => ['LOGT1', 'jd', '京东快递', '3', '已签收', 'jd_official', '2031-03-03 10:00:00', [
['2031-03-01 18:00:00', '您的快件已由京东快递揽收'], ['2031-03-02 08:00:00', '快件已到达洛阳分拣中心,快递员王师傅 13812345678 正在派送'],
['2031-03-03 09:30:00', '您的快件已签收,签收人:本人']]],
'SFLOGT0002' => ['LOGT2', 'sf', '顺丰速运', '5', '派件中', 'kuaidi100', '2031-03-06 12:00:00', [
['2031-03-05 20:00:00', '顺丰速运 已收取快件'], ['2031-03-06 11:00:00', '快件派送中,派件员 13900001111']]],
'JDVELOGT0005' => ['LOGT5', 'jd', '京东快递', '0', '在途', 'jd_official', '2031-03-02 12:00:00', [['2031-03-02 11:00:00', '别人订单的轨迹']]],
'JDVELOGT0006' => ['LOGT6', 'jd', '京东快递', '0', '在途', 'jd_official', '2031-03-03 12:00:00', [['2031-03-03 11:00:00', '无权限账号订单的轨迹']]],
] as $number => [$no, $company, $companyName, $state, $stateText, $source, $queried, $traces]) {
$last = end($traces);
$trackingId = (int) Db::name('express_tracking')->insertGetId(['order_id' => $orders[$no], 'order_type' => 'prescription', 'tracking_number' => $number,
'express_company' => $company, 'express_company_name' => $companyName, 'recipient_phone' => '13911119999', 'recipient_name' => '收件人',
'recipient_address' => '河南省洛阳市', 'current_state' => $state, 'current_state_text' => $stateText, 'is_signed' => $state === '3' ? 1 : 0,
'latest_trace_time' => $last[0], 'latest_trace_context' => $last[1], 'latest_location' => '洛阳', 'last_query_time' => strtotime($queried), 'query_count' => 3,
'data_source' => $source, 'auto_update' => 1, 'create_time' => $now, 'update_time' => $now]);
foreach ($traces as [$time, $context]) {
Db::name('express_trace')->insert(['tracking_id' => $trackingId, 'tracking_number' => $number, 'trace_time' => $time, 'trace_time_stamp' => strtotime($time),
'trace_context' => $context, 'status' => '', 'status_code' => '', 'location' => '', 'create_time' => $now]);
}
}
// 实时查询的每小时计数在 mcp 应用自己的缓存目录里,清掉本小时的桶,让次数上限的断言可重复
$cacheOptions = (array) config('cache.stores.file');
$cacheOptions['path'] = app()->getRootPath() . 'runtime' . DIRECTORY_SEPARATOR . 'mcp' . DIRECTORY_SEPARATOR . 'cache';
foreach (array_keys($admins) as $adminId) {
(new FileCache(app(), $cacheOptions))->delete('ai_mcp_rl_logistics_live_' . $adminId . '_' . intdiv(time(), 3600));
}
\think\facade\Cache::clear();
// ---------------------------------------------------------------- HTTP 工具
function aiMcpLogisticsHttp(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] = aiMcpLogisticsHttp($base . '/mcp/auth/grant', ['account' => $account, 'password' => 'Test@123456', 'client' => 'xingzhi', 'client_instance' => 'logistics-test'], []);
aiMcpLogisticsExpect(($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] = aiMcpLogisticsHttp($base . '/mcp', ['jsonrpc' => '2.0', 'id' => ++$id, 'method' => $method, 'params' => $params],
['Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json, text/event-stream', 'X-Xingzhi-Task-Id' => 'logistics-task']);
aiMcpLogisticsExpect($status === 200 && isset($body['result']), "{$method} should return a result, got HTTP {$status}: " . json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['result'];
};
$call = static fn (string $token, string $name, array $args): array => $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);
aiMcpLogisticsExpect(empty($result['isError']), "{$name} " . json_encode($args, JSON_UNESCAPED_UNICODE) . ' should succeed: ' . ($result['content'][0]['text'] ?? ''));
return [$result['content'][0]['text'] ?? '', (array) ($result['structuredContent'] ?? [])];
};
$asst = $grant('l_asst');
$noTrace = $grant('l_notrace');
// ---------------------------------------------------------------- 工具与中文名
$tools = array_column($rpc($asst, 'tools/list')['tools'] ?? [], null, 'name');
aiMcpLogisticsExpect(isset($tools['zyt_logistics_order'], $tools['zyt_logistics_patient']), 'logistics tools are listed for accounts that can see prescription orders');
aiMcpLogisticsExpect(($tools['zyt_logistics_order']['title'] ?? '') === '订单物流查询' && ($tools['zyt_logistics_order']['annotations']['title'] ?? '') === '订单物流查询'
&& ($tools['zyt_logistics_order']['annotations']['readOnlyHint'] ?? false) === true, 'tools carry a Chinese display title and stay read-only');
aiMcpLogisticsExpect(($tools['zyt_query']['annotations']['title'] ?? '') === '查询业务数据', 'every tool has a display title');
// ---------------------------------------------------------------- 按订单号:已同步的轨迹
[$text, $data] = $ok($asst, 'zyt_logistics_order', ['order_no' => 'LOGT1']);
$order = $data['orders'][0] ?? [];
$logistics = $order['logistics'] ?? [];
aiMcpLogisticsExpect(($data['found'] ?? 0) === 1 && ($order['order_no'] ?? '') === 'LOGT1' && ($order['fulfillment_text'] ?? '') === '已签收'
&& ($order['express_company'] ?? '') === '京东快递' && ($order['tracking_number'] ?? '') === 'JDVELOGT0001', 'order found with carrier and waybill: ' . json_encode($order, JSON_UNESCAPED_UNICODE));
aiMcpLogisticsExpect(($logistics['status'] ?? '') === '已签收' && ($logistics['delivered'] ?? null) === true && ($logistics['signed_at'] ?? '') === '2031-03-03 09:30:00'
&& ($logistics['trace_count'] ?? 0) === 3 && ($logistics['traces'][0]['time'] ?? '') === '2031-03-03 09:30:00', 'stored trace, newest first, sign time: ' . json_encode($logistics, JSON_UNESCAPED_UNICODE));
aiMcpLogisticsExpect(($logistics['source'] ?? '') === '京东物流官方接口' && ($logistics['updated_at'] ?? '') === '2031-03-03 10:00', 'source and last sync time are reported');
aiMcpLogisticsExpect(str_contains($text, '签收时间 2031-03-03 09:30:00') && str_contains($text, '数据更新于 2031-03-03 10:00'), 'summary line: ' . $text);
$all = $text . json_encode($data, JSON_UNESCAPED_UNICODE);
aiMcpLogisticsExpect(!str_contains($all, '13812345678') && str_contains($all, '138****5678'), 'phone numbers inside trace text are masked');
aiMcpLogisticsExpect(($order['recipient_phone'] ?? '') === '139****0001' && ($order['recipient_region'] ?? '') === '河南省洛阳市洛龙区' && !str_contains($all, '开元大道'),
'recipient phone masked; only the region, never the street address');
aiMcpLogisticsExpect(str_contains((string) ($order['tracking_page'] ?? ''), 'jdl.com') && str_contains((string) ($order['tracking_page'] ?? ''), 'JDVELOGT0001'), 'official tracking page link');
// ---------------------------------------------------------------- 按快递单号;范围之外;未填单号;参数校验
[, $data] = $ok($asst, 'zyt_logistics_order', ['tracking_no' => 'SFLOGT0002', 'trace_limit' => 1]);
$logistics = $data['orders'][0]['logistics'] ?? [];
aiMcpLogisticsExpect(($data['orders'][0]['order_no'] ?? '') === 'LOGT2' && ($logistics['status'] ?? '') === '派件中' && ($logistics['delivered'] ?? null) === false
&& count($logistics['traces'] ?? []) === 1 && ($logistics['trace_count'] ?? 0) === 2, 'lookup by waybill, trace_limit respected: ' . json_encode($logistics, JSON_UNESCAPED_UNICODE));
foreach ([['order_no' => 'LOGT5'], ['tracking_no' => 'JDVELOGT0005']] as $args) {
[$text, $data] = $ok($asst, 'zyt_logistics_order', $args);
aiMcpLogisticsExpect(($data['found'] ?? -1) === 0 && str_contains($text, '可见范围') && !str_contains($text, '别人订单的轨迹'), 'orders outside the list scope are not found: ' . $text);
}
[, $data] = $ok($asst, 'zyt_logistics_order', ['order_no' => 'LOGT3']);
aiMcpLogisticsExpect(($data['orders'][0]['logistics']['status'] ?? '') === '还没有快递单号', 'order without a waybill');
foreach ([[], ['order_no' => "LOGT1' OR 1=1"]] as $args) {
$result = $call($asst, 'zyt_logistics_order', $args);
aiMcpLogisticsExpect(!empty($result['isError']), 'bad arguments are refused: ' . json_encode($args, JSON_UNESCAPED_UNICODE));
}
// ---------------------------------------------------------------- 没有「物流轨迹」权限
[$text, $data] = $ok($noTrace, 'zyt_logistics_order', ['order_no' => 'LOGT6']);
aiMcpLogisticsExpect(($data['orders'][0]['logistics']['status'] ?? '') === '无权查看物流轨迹' && !isset($data['orders'][0]['logistics']['traces'])
&& !str_contains($text . json_encode($data, JSON_UNESCAPED_UNICODE), '无权限账号订单的轨迹'), 'no traces without the logistics-trace permission');
// ---------------------------------------------------------------- 库里没有轨迹:实时查询(外部接口已关闭)不写库
$liveCalls = 0;
[, $data] = $ok($asst, 'zyt_logistics_order', ['order_no' => 'LOGT4', 'live' => false]);
aiMcpLogisticsExpect(($data['orders'][0]['logistics']['status'] ?? '') === '系统里暂无这张运单的物流记录', 'live=false keeps to stored data');
[, $data] = $ok($asst, 'zyt_logistics_order', ['order_no' => 'LOGT4']);
$liveCalls++;
$logistics = $data['orders'][0]['logistics'] ?? [];
aiMcpLogisticsExpect(($logistics['status'] ?? '') === '快递公司暂无轨迹' && ($logistics['source'] ?? '') === '实时查询' && str_contains((string) ($logistics['note'] ?? ''), '快递100'),
'live query path ran (carrier APIs disabled in this test): ' . json_encode($logistics, JSON_UNESCAPED_UNICODE));
aiMcpLogisticsExpect(Db::name('express_tracking')->where('tracking_number', 'YTLOGT0004')->count() === 0
&& Db::name('express_query_log')->where('tracking_number', 'YTLOGT0004')->count() === 0, 'the live query writes nothing');
// ---------------------------------------------------------------- 按患者
[$text, $data] = $ok($asst, 'zyt_logistics_patient', ['patient' => '物流甲']);
aiMcpLogisticsExpect(count($data['patients'] ?? []) === 2 && !isset($data['latest_shipment']) && str_contains($text, '有 2 位患者匹配'), 'same-name patients are listed separately: ' . $text);
aiMcpLogisticsExpect(!str_contains($text . json_encode($data, JSON_UNESCAPED_UNICODE), '物流乙'), 'other assistants\' patients stay out');
[$text, $data] = $ok($asst, 'zyt_logistics_patient', ['patient_id' => 96101]);
$group = $data['patients'][0] ?? [];
aiMcpLogisticsExpect(count($data['patients'] ?? []) === 1 && ($group['patient_name'] ?? '') === '物流甲' && ($group['phone'] ?? '') === '139****0001'
&& array_column($group['orders'] ?? [], 'order_no') === ['LOGT4', 'LOGT2', 'LOGT1'], 'one patient, newest orders first, phone masked: ' . json_encode($group, JSON_UNESCAPED_UNICODE));
$byNo = array_column($group['orders'], null, 'order_no');
aiMcpLogisticsExpect(($byNo['LOGT2']['logistics']['status'] ?? '') === '派件中' && str_contains((string) ($byNo['LOGT2']['logistics']['latest'] ?? ''), '139****1111')
&& ($byNo['LOGT1']['logistics']['delivered'] ?? null) === true, 'each order carries its latest stored event');
aiMcpLogisticsExpect(($data['latest_shipment']['order_no'] ?? '') === 'LOGT4' && ($data['latest_shipment']['logistics']['source'] ?? '') === '实时查询'
&& str_contains($text, '最近一单的物流'), 'full trace for the latest shipped order');
$liveCalls++;
[, $data] = $ok($asst, 'zyt_logistics_patient', ['patient' => '物流甲', 'start_date' => '2031-03-05', 'end_date' => '2031-03-31']);
$inRange = array_column(array_merge(...array_column($data['patients'] ?? [], 'orders')), 'order_no');
sort($inRange);
aiMcpLogisticsExpect($inRange === ['LOGT2', 'LOGT3', 'LOGT4'], 'date range filters by order creation date: ' . json_encode($inRange));
$result = $call($asst, 'zyt_logistics_patient', ['patient' => '物流甲', 'start_date' => '2031/03/05']);
aiMcpLogisticsExpect(!empty($result['isError']), 'dates must be YYYY-MM-DD');
// ---------------------------------------------------------------- 实时查询每小时上限(默认 30 次)
$limited = false;
for ($i = $liveCalls; $i <= 31; $i++) {
[, $data] = $ok($asst, 'zyt_logistics_order', ['order_no' => 'LOGT4']);
if (str_contains((string) ($data['orders'][0]['logistics']['note'] ?? ''), '次数已用完')) {
$limited = $i === 30;
break;
}
}
aiMcpLogisticsExpect($limited, 'the 31st live query in an hour is refused');
// ---------------------------------------------------------------- 审计
$log = Db::name('ai_access_log')->where(['admin_id' => 93101, 'tool' => 'zyt_logistics_order'])->where('record_ids', (string) $orders['LOGT1'])->find();
aiMcpLogisticsExpect($log && $log['status'] === 'ok' && $log['resource'] === 'tcm.prescriptionOrder/lists' && $log['client_task_id'] === 'logistics-task', 'audited with the order id and 行知 task id');
echo "AiMcpLogisticsTest OK\n";