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";