getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1); }); $manager = new think\DbManager(); $manager->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [ 'type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'test_', 'fields_strict' => true, ]]]); Container::getInstance()->instance('think\DbManager', $manager); think\Model::setDb($manager); $config = new think\Config(); $config->set(['enabled' => true, 'profile' => 'qwen', 'timeout' => 120, 'daily_job_limit' => 100, 'max_source_rows' => 200, 'max_source_chars' => 30000, 'max_manual_chars' => 10000, 'max_knowledge_rows' => 40], 'assistant_service'); $config->set(['enable' => true, 'base_url' => 'https://example.invalid', 'models' => [ 'qwen' => ['name' => 'synthetic', 'api_key' => 'synthetic-no-network']]], 'prescription_ai'); $config->set(['data_scope' => ['enabled' => true, 'no_dept_fallback_self' => true]], 'project'); Container::getInstance()->instance('config', $config); $tables = [ 'admin' => 'id INTEGER PRIMARY KEY,name TEXT,root INTEGER DEFAULT 0,disable INTEGER DEFAULT 0,delete_time INTEGER NULL', 'admin_role' => 'admin_id INTEGER,role_id INTEGER', 'admin_dept' => 'admin_id INTEGER,dept_id INTEGER', 'dept' => 'id INTEGER PRIMARY KEY,pid INTEGER,status INTEGER DEFAULT 1,delete_time INTEGER NULL', 'system_role' => 'id INTEGER PRIMARY KEY,name TEXT,data_scope INTEGER,delete_time INTEGER NULL', 'system_role_menu' => 'role_id INTEGER,menu_id INTEGER', 'system_menu' => 'id INTEGER PRIMARY KEY,perms TEXT,is_disable INTEGER DEFAULT 0', 'doctor_appointment' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,doctor_id INTEGER,status INTEGER', 'tcm_diagnosis' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,assistant_id INTEGER,patient_name TEXT,phone TEXT,status INTEGER DEFAULT 1,delete_time INTEGER NULL', 'tracking_note' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,admin_id INTEGER,note_date TEXT,content TEXT,create_time INTEGER,update_time INTEGER,delete_time INTEGER NULL', 'tcm_im_chat_message' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,diagnosis_id INTEGER,from_account TEXT,to_account TEXT,doctor_peer_account TEXT,msg_type TEXT,text TEXT,msg_time INTEGER', 'tcm_diagnosis_todo' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,content TEXT,remind_time INTEGER,status INTEGER,creator_id INTEGER,creator_name TEXT,create_time INTEGER,update_time INTEGER,notified_at INTEGER DEFAULT 0,cancelled_at INTEGER DEFAULT 0,cancelled_by INTEGER DEFAULT 0,delete_time INTEGER NULL', 'assistant_service_record' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,actor_id INTEGER,content TEXT,record_type TEXT,speaker_confirmed INTEGER,occurred_at INTEGER,created_at INTEGER,delete_time INTEGER NULL', 'assistant_service_job' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,patient_id INTEGER,actor_id INTEGER,type TEXT,scenario TEXT,days INTEGER,status TEXT,version INTEGER,dedupe_key TEXT UNIQUE,source_hash TEXT,scope_hash TEXT,payload_json TEXT,result_json TEXT,claim_token TEXT DEFAULT "",lease_until INTEGER DEFAULT 0,error_code TEXT DEFAULT "",error_message TEXT DEFAULT "",upstream_id TEXT DEFAULT "",created_at INTEGER,updated_at INTEGER,finished_at INTEGER DEFAULT 0', 'assistant_service_review' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,job_id INTEGER,item_key TEXT,reviewer_id INTEGER,decision TEXT,note TEXT,created_at INTEGER', 'assistant_service_action' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,diagnosis_id INTEGER,job_id INTEGER,action_key TEXT,todo_id INTEGER,assignee_id INTEGER,content TEXT,due_at INTEGER,business_state TEXT,completion_note TEXT DEFAULT "",completed_at INTEGER DEFAULT 0,created_at INTEGER,updated_at INTEGER,UNIQUE(job_id,action_key)', 'assistant_service_knowledge' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,question TEXT,answer TEXT,category TEXT,source_note TEXT,status TEXT,version INTEGER,created_by INTEGER,published_by INTEGER,created_at INTEGER,updated_at INTEGER', ]; foreach ($tables as $name => $schema) { Db::execute('CREATE TABLE test_' . $name . ' (' . $schema . ')'); } Db::name('admin')->insertAll([ ['id' => 1, 'name' => 'root', 'root' => 1], ['id' => 11, 'name' => 'assistant', 'root' => 0], ['id' => 12, 'name' => 'other', 'root' => 0], ['id' => 13, 'name' => 'team', 'root' => 0], ['id' => 14, 'name' => 'doctor', 'root' => 0], ]); Db::name('system_role')->insertAll([ ['id' => 1, 'name' => '医生', 'data_scope' => 1], ['id' => 2, 'name' => '医助', 'data_scope' => 4], ['id' => 3, 'name' => '经理', 'data_scope' => 3], ]); Db::name('admin_role')->insertAll([['admin_id' => 11, 'role_id' => 2], ['admin_id' => 12, 'role_id' => 2], ['admin_id' => 13, 'role_id' => 3], ['admin_id' => 14, 'role_id' => 1]]); Db::name('dept')->insertAll([['id' => 10, 'pid' => 0], ['id' => 20, 'pid' => 0]]); Db::name('admin_dept')->insertAll([['admin_id' => 11, 'dept_id' => 10], ['admin_id' => 12, 'dept_id' => 20], ['admin_id' => 13, 'dept_id' => 10]]); foreach (Access::PERMISSIONS as $i => $permission) { Db::name('system_menu')->insert(['id' => $i + 1, 'perms' => 'tcm.assistantService/' . $permission]); foreach ([1, 2, 3] as $role) { Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $i + 1]); } } Db::name('tcm_diagnosis')->insertAll([ ['id' => 101, 'patient_id' => 201, 'assistant_id' => 11, 'patient_name' => '合成甲', 'phone' => '13800138000'], ['id' => 102, 'patient_id' => 202, 'assistant_id' => 12, 'patient_name' => '合成乙', 'phone' => '13900139000'], ['id' => 103, 'patient_id' => 0, 'assistant_id' => 11, 'patient_name' => '未映射', 'phone' => ''], ]); Db::name('doctor_appointment')->insert(['id' => 1, 'patient_id' => 101, 'doctor_id' => 14, 'status' => 1]); $now = time(); Db::name('tracking_note')->insert(['id' => 1, 'diagnosis_id' => 101, 'admin_id' => 11, 'note_date' => date('Y-m-d'), 'content' => '患者要求周六确认预约', 'create_time' => $now, 'update_time' => $now]); foreach ([ [1, 201, 'patient_201', 'doctor_11', 'text', '请帮我核对预约,电话13800138000'], [2, 201, 'doctor_11', 'patient_201', 'text', '我将在明天回复可预约时段'], [3, 201, 'doctor_12', 'patient_201', 'text', '其他部门私有沟通'], [4, 201, 'doctor_11', 'patient_999', 'text', '错误患者映射'], [5, 201, 'doctor_11', 'patient_201', 'image', '不可读图片'], [6, 201, 'doctor_999', 'patient_201', 'text', '不存在的员工'], ] as [$id, $patient, $from, $to, $type, $text]) { Db::name('tcm_im_chat_message')->insert(['id' => $id, 'patient_id' => $patient, 'diagnosis_id' => 999, 'from_account' => $from, 'to_account' => $to, 'doctor_peer_account' => 'doctor_11', 'msg_type' => $type, 'text' => $text, 'msg_time' => $now - 100]); } $checks = 0; $ok = static function (bool $condition, string $label) use (&$checks): void { if (!$condition) { throw new RuntimeException($label); } $checks++; }; $temporary = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'assistant-auto-test-' . bin2hex(random_bytes(8)); $clock = time(); $automation = new Automation($temporary, static function () use (&$clock): int { return $clock; }); $tick = static function (int $times = 1) use ($automation, &$clock): array { $result = []; for ($i = 0; $i < $times; $i++) { $clock += 61; $result = $automation->tick(); } return $result; }; $manual = static function (int $diagnosis, int $patient, int $assistant = 11, ?int $at = null) use ($now): int { return (int) Db::name('assistant_service_record')->insertGetId(['diagnosis_id' => $diagnosis, 'patient_id' => $patient, 'actor_id' => $assistant, 'content' => '合成服务记录 ' . $diagnosis, 'record_type' => 'service_note', 'speaker_confirmed' => 0, 'occurred_at' => $at ?? $now - 1000, 'created_at' => $now - 1000]); }; $diagnosis = static function (int $id, int $assistant = 11): void { Db::name('tcm_diagnosis')->insert(['id' => $id, 'patient_id' => $id + 1000, 'assistant_id' => $assistant, 'patient_name' => '合成测试 ' . $id, 'phone' => '']); }; $jobCount = static fn (): int => (int) Db::name('assistant_service_job')->count(); $clear = static function () use ($temporary): void { foreach (['assistant_service_job', 'assistant_service_record', 'tracking_note', 'tcm_im_chat_message'] as $table) { Db::name($table)->where('id', '>', 0)->delete(); } $scan = $temporary . DIRECTORY_SEPARATOR . 'scan.json'; if (is_file($scan)) { unlink($scan); } }; try { $ok(Automation::status($temporary)['state'] === 'disabled' && !is_dir($temporary), 'disabled status is read-only and needs no runtime directory'); $ok($automation->tick()['state'] === 'disabled' && !is_dir($temporary), 'default disabled tick never discovers candidates'); $config->set(['auto_enabled' => true, 'auto_actor_id' => 0, 'auto_interval_seconds' => 60, 'auto_quiet_seconds' => 300, 'auto_batch_size' => 10], 'assistant_service'); $ok($tick()['state'] === 'error' && $jobCount() === 0, 'unbound actor does not choose existing root'); $config->set(['auto_actor_id' => 11], 'assistant_service'); $ok($tick()['state'] === 'error', 'analyze-enabled frontline account is not a scheduler manager'); $config->set(['auto_actor_id' => 13], 'assistant_service'); Db::name('admin')->where('id', 13)->update(['disable' => 1]); $ok($tick()['state'] === 'error', 'disabled configured manager rejected'); Db::name('admin')->where('id', 13)->update(['disable' => 0]); Db::name('system_role_menu')->where('role_id', 3)->where('menu_id', 2)->delete(); $ok($tick()['state'] === 'error', 'configured manager must retain analyze permission'); Db::name('system_role_menu')->insert(['role_id' => 3, 'menu_id' => 2]); $config->set(['enabled' => false], 'assistant_service'); $ok($tick()['state'] === 'error', 'model feature switch respected'); $config->set(['enabled' => true], 'assistant_service'); $config->set(['enable' => false], 'prescription_ai'); $ok($tick()['state'] === 'error', 'incomplete model settings do not enqueue'); $config->set(['enable' => true], 'prescription_ai'); $clear(); $ok($tick()['enqueued'] === 0 && $jobCount() === 0, 'assigned empty diagnoses never manufacture tasks'); $ok(str_contains(Automation::status($temporary, $clock)['reason'], '没有新增待分析来源'), 'empty scan explicitly waits for sources'); $ok(Automation::status($temporary, $clock)['state'] === 'waiting', 'scheduler alone is not falsely reported fully running'); Automation::workerHeartbeat('idle', $temporary, $clock); $ok(Automation::status($temporary, $clock)['state'] === 'running', 'real completed worker polling plus scan heartbeat is running'); $ok(Automation::status($temporary, $clock + 181)['state'] === 'waiting', 'expired scanner heartbeat is waiting'); Automation::workerHeartbeat('stopped', $temporary, $clock); $ok(Automation::status($temporary, $clock)['state'] === 'waiting', 'stopped worker not reported active'); $ok($automation->tick()['scanned'] === 0, 'persisted interval prevents frequent external ticks'); // 真正第二进程持锁:主进程不能进入扫描,锁释放后恢复。 $pipes = []; $child = proc_open([PHP_BINARY, '-r', '$h=fopen($argv[1],"c+b");flock($h,LOCK_EX);echo "LOCKED\n";fflush(STDOUT);fgets(STDIN);fclose($h);', $temporary . DIRECTORY_SEPARATOR . 'scan.lock'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); $ok(is_resource($child) && trim((string) fgets($pipes[1])) === 'LOCKED', 'independent process owns real flock'); $blocked = $tick(); $ok($blocked['scanned'] === 0 && str_contains($blocked['reason'], '另一'), 'second process is excluded from scanning'); fwrite($pipes[0], "release\n"); fclose($pipes[0]); fclose($pipes[1]); fclose($pipes[2]); $ok(proc_close($child) === 0 && $tick()['state'] === 'running', 'process lock releases without stale lease'); // 来源身份只能由可靠账号对/登记作者决定,不能借当前归属推断历史员工。 $manual(101, 201); $manual(102, 202, 12); $manual(103, 999); // 旧患者绑定。 $manual(103, 0, 11, $now - 40 * 86400); Db::name('tcm_im_chat_message')->insert(['id' => 20, 'patient_id' => 201, 'diagnosis_id' => 101, 'from_account' => 'doctor_11', 'to_account' => 'patient_999', 'msg_type' => 'text', 'text' => '错误映射', 'msg_time' => $now - 1000]); $first = $tick(); $ok($first['enqueued'] === 1 && $jobCount() === 1, 'first scan covers existing 30-day manual source but excludes cross-scope, old and mismapped'); $stored = Db::name('assistant_service_job')->order('id', 'asc')->find(); $payload = json_decode($stored['payload_json'], true); $ok((int) $stored['actor_id'] === 13 && $payload['context']['subject_admin_id'] === 11 && $payload['trigger'] === 'automatic', 'actual configured actor and explicit subject are preserved'); $ok(!str_contains(json_encode($payload['context']['sources']), '错误映射'), 'false IM mapping never enters context'); $tick(3); $ok($jobCount() === 1, 'queued snapshot never repeats across cursor cycles'); Db::name('assistant_service_job')->where('id', $stored['id'])->update(['status' => 'failed', 'error_code' => 'INTERNAL_ERROR']); $tick(3); $ok($jobCount() === 1 && Db::name('assistant_service_job')->where('id', $stored['id'])->value('status') === 'failed', 'automatic scan never resets even safely retryable failure'); $manual(101, 201); $tick(3); $ok($jobCount() === 2, 'new content may create a new snapshot after definite terminal failure'); $latest = Db::name('assistant_service_job')->order('id', 'desc')->find(); Db::name('assistant_service_job')->where('id', $latest['id'])->update(['status' => 'needs_reconciliation']); $manual(101, 201); $tick(3); $ok($jobCount() === 2, 'unknown earlier result blocks new snapshot even when new content exists'); Db::name('assistant_service_job')->where('id', $latest['id'])->update(['status' => 'ready']); $tick(3); $ok($jobCount() === 3, 'resolved terminal allows newly arrived content'); Db::name('assistant_service_job')->where('status', 'queued')->update(['status' => 'ready']); $oldestRecord = Db::name('assistant_service_record')->where('diagnosis_id', 101)->order('id', 'asc')->value('id'); Db::name('assistant_service_record')->where('id', $oldestRecord)->delete(); $tick(3); $ok($jobCount() === 3, 'shrinking source set does not regenerate merely because hash changed'); $newestRecord = Db::name('assistant_service_record')->where('diagnosis_id', 101)->order('id', 'desc')->value('id'); Db::name('assistant_service_record')->where('id', $newestRecord)->update(['content' => '合成登记内容已修订']); $tick(3); $ok($jobCount() === 4, 'changed actual source text receives a new snapshot'); $clear(); $diagnosis(110); Db::name('admin')->insert(['id' => 15, 'name' => '无菜单目标医助', 'root' => 0]); Db::name('system_role')->insert(['id' => 22, 'name' => '医助', 'data_scope' => 4]); Db::name('admin_role')->insert(['admin_id' => 15, 'role_id' => 22]); Db::name('admin_dept')->insert(['admin_id' => 15, 'dept_id' => 10]); Db::name('tcm_diagnosis')->insert(['id' => 112, 'patient_id' => 1110, 'assistant_id' => 11, 'patient_name' => '相同患者新诊单', 'phone' => '']); Db::name('tcm_im_chat_message')->insert(['id' => 30, 'patient_id' => 1110, 'diagnosis_id' => 999, 'from_account' => 'patient_1110', 'to_account' => 'doctor_15', 'msg_type' => 'text', 'text' => '历史医助可靠收信', 'msg_time' => $now - 1000]); $ok($tick()['enqueued'] === 1, 'reliable incoming IM discovers historical target without menu or current assignment'); $payload = json_decode(Db::name('assistant_service_job')->value('payload_json'), true); $ok($payload['context']['subject_admin_id'] === 15 && $payload['context']['sources'][0]['speaker'] === 'patient', 'incoming patient words are never attributed to employee'); $tick(3); $ok($jobCount() === 1 && (int) Db::name('assistant_service_job')->value('diagnosis_id') === 112, 'same patient IM is queued only for latest authorized active diagnosis'); Db::name('assistant_service_job')->where('status', 'queued')->update(['status' => 'ready']); Db::name('tcm_diagnosis')->insert(['id' => 113, 'patient_id' => 1110, 'assistant_id' => 11, 'patient_name' => '又一新诊单', 'phone' => '']); $tick(3); $ok($jobCount() === 1, 'new diagnosis alone cannot replay already analyzed identical patient conversation'); $clear(); $diagnosis(111); Db::name('tracking_note')->insert(['id' => 40, 'diagnosis_id' => 111, 'admin_id' => 15, 'note_date' => date('Y-m-d', $now - 86400), 'content' => '历史目标跟踪备注', 'create_time' => $now - 1000, 'update_time' => $now - 1000]); $ok($tick()['enqueued'] === 1, 'authored tracking alone is discovered for historical target'); $payload = json_decode(Db::name('assistant_service_job')->value('payload_json'), true); $ok($payload['context']['subject_admin_id'] === 15 && !$payload['context']['sources'][0]['quality_eligible'], 'tracking remains non-attributable quality evidence'); $clear(); $clock = time(); $manualId = $manual(101, 201); Db::name('assistant_service_record')->where('id', $manualId)->update(['created_at' => $clock]); $quiet = $tick(); $ok($quiet['quiet'] === 1 && $jobCount() === 0, 'backdated manual record respects real insertion quiet window'); $tick(7); $ok($jobCount() === 1, 'quiet candidates return after cursor wrap and eventually enqueue'); $clear(); Db::name('tracking_note')->insert(['id' => 41, 'diagnosis_id' => 101, 'admin_id' => 11, 'note_date' => date('Y-m-d', $now - 86400), 'content' => '最近追加到旧日期', 'create_time' => $now - 1000, 'update_time' => $clock]); $ok($tick()['quiet'] === 1 && $jobCount() === 0, 'tracking update time participates in quiet window'); $tick(7); $ok($jobCount() === 1, 'tracking candidate resumes after quiet window'); $clear(); $manual(101, 201); Db::name('admin_dept')->where('admin_id', 11)->update(['dept_id' => 20]); $ok($tick()['enqueued'] === 0 && $jobCount() === 0, 'scope revocation effective on next scan'); Db::name('admin_dept')->where('admin_id', 11)->update(['dept_id' => 10]); $ok($tick()['enqueued'] === 1, 'restored authorized sources remain discoverable'); $clear(); $manual(101, 201); Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => $now]); $ok($tick()['enqueued'] === 0, 'deleted diagnosis excluded at discovery'); Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => null]); $clear(); $config->set(['auto_batch_size' => 1], 'assistant_service'); for ($i = 200; $i < 240; $i++) { $diagnosis($i); $manual($i, $i + 1000); } $first = $tick(); $ok($first['enqueued'] === 1 && $first['scanned'] <= 30, 'one-round queue and source scan are bounded'); for ($i = 0; $i < 60; $i++) { $tick(); } $ids = array_unique(Db::name('assistant_service_job')->column('diagnosis_id')); $ok(count($ids) === 40 && $jobCount() === 40, 'persistent keyset cursor reaches every source beyond first page without duplicates'); $replacement = new Automation($temporary, static function () use (&$clock): int { return $clock; }); $clock += 61; $ok($replacement->tick()['enqueued'] === 0 && $jobCount() === 40, 'restart reuses durable cursor and queue dedupe'); $clear(); $config->set(['auto_batch_size' => 10, 'daily_job_limit' => 1], 'assistant_service'); $manual(200, 1200); $manual(201, 1201); $limited = $tick(); $ok($limited['enqueued'] === 1 && $limited['state'] === 'waiting' && str_contains($limited['reason'], '上限'), 'daily allowance stops explicitly after accepted work'); $config->set(['daily_job_limit' => 100], 'assistant_service'); $ok($tick()['enqueued'] === 1 && $jobCount() === 2, 'quota-blocked source cursor is preserved for next allowed scan'); $clear(); $config->set(['max_source_rows' => 1], 'assistant_service'); $manual(200, 1200); $manual(200, 1200); $manual(201, 1201); $over = $tick(); $ok($over['source_limit'] === 1 && $over['enqueued'] === 1 && $jobCount() === 1, 'oversized source set is skipped while later valid candidates continue'); Automation::workerHeartbeat('idle', $temporary, $clock); $ok(str_contains(Automation::status($temporary, $clock)['reason'], '超出分析上限'), 'persistent skipped source limit is clearly surfaced in health reason'); $tick(); Automation::workerHeartbeat('idle', $temporary, $clock); $ok(str_contains(Automation::status($temporary, $clock)['reason'], '超出分析上限'), 'empty cursor page does not hide unresolved source limit warning'); $config->set(['max_source_rows' => 200], 'assistant_service'); $clear(); Db::execute('DROP TABLE test_tcm_im_chat_message'); $manual(200, 1200); $partial = $tick(); $ok($partial['unavailable_channels'] === 1 && $partial['enqueued'] === 1, 'missing optional IM archive still processes verified manual sources'); $ok(str_contains($partial['reason'], '来源暂不可用'), 'unavailable source is reported without fabricated zero compliance'); $encoded = json_encode(Automation::status($temporary, $clock), JSON_UNESCAPED_UNICODE); $ok(!str_contains($encoded, 'actor_id') && !str_contains($encoded, '合成') && !str_contains($encoded, 'patient_id'), 'public status reveals no actor, patient or source text'); file_put_contents($temporary . DIRECTORY_SEPARATOR . 'scan.json', '{broken'); $ok(Automation::status($temporary, $clock)['state'] === 'error', 'damaged heartbeat is explicit error rather than fictional running'); echo 'ASSISTANT_SERVICE_AUTOMATION assertions=' . $checks . ' PASS sqlite=memory real_access_context_enqueue=1 multiprocess_flock=1 network=0' . PHP_EOL; } finally { // 只清理本测试生成的确切文件,不递归删除计算路径。 foreach (['scan.json', 'scan.lock', 'workers.json', 'workers.lock'] as $name) { $path = $temporary . DIRECTORY_SEPARATOR . $name; if (is_file($path)) { unlink($path); } } if (is_dir($temporary)) { rmdir($temporary); } }