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(); $settings = ['enabled' => false, 'alert_enabled' => false, 'alert_interval_seconds' => 60, 'alert_batch_size' => 200]; $config->set($settings, 'assistant_service'); $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', 'tcm_im_chat_message' => 'id INTEGER PRIMARY KEY,patient_id INTEGER,diagnosis_id INTEGER DEFAULT 0,from_account TEXT COLLATE NOCASE,to_account TEXT,msg_type TEXT,text TEXT,msg_time INTEGER', 'tcm_call_record' => 'id INTEGER PRIMARY KEY,diagnosis_id INTEGER,caller_id INTEGER,caller_type TEXT,callee_id INTEGER,callee_type TEXT,start_time INTEGER,duration INTEGER,delete_time INTEGER NULL', 'tcm_call_transcript_segment' => 'id INTEGER PRIMARY KEY,call_record_id INTEGER,speaker_user_id TEXT,speaker_role TEXT,text TEXT,create_time INTEGER', 'assistant_service_alert' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,staff_admin_id INTEGER,patient_id INTEGER,diagnosis_id INTEGER DEFAULT 0,channel TEXT,source_id INTEGER,source_key TEXT NOT NULL DEFAULT "",external_staff TEXT NOT NULL DEFAULT "",external_patient TEXT NOT NULL DEFAULT "",rule_code TEXT,rule_version INTEGER,severity TEXT,snippet TEXT,status TEXT DEFAULT "open",reviewer_id INTEGER DEFAULT 0,review_note TEXT DEFAULT "",reviewed_at INTEGER DEFAULT 0,occurred_at INTEGER,created_at INTEGER,updated_at INTEGER,UNIQUE(channel,source_id,source_key,rule_code)', ]; 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' => '甲', 'root' => 0], ['id' => 12, 'name' => '域外', 'root' => 0], ['id' => 13, 'name' => '主管', 'root' => 0], ['id' => 14, 'name' => '医生', '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]]); Db::name('system_menu')->insertAll([['id' => 1, 'perms' => 'tcm.assistantService/view'], ['id' => 2, 'perms' => 'tcm.assistantService/reviewQuality']]); Db::name('system_role_menu')->insertAll([['role_id' => 2, 'menu_id' => 1], ['role_id' => 3, 'menu_id' => 1], ['role_id' => 3, 'menu_id' => 2]]); Db::name('tcm_diagnosis')->insertAll([ ['id' => 101, 'patient_id' => 201, 'assistant_id' => 11, 'patient_name' => '合成甲', 'phone' => '', 'status' => 1], ['id' => 102, 'patient_id' => 301, 'assistant_id' => 12, 'patient_name' => '域外患者', 'phone' => '', 'status' => 1], ]); $clock = (new DateTimeImmutable('2026-11-10 11:00:00', new DateTimeZone('Asia/Shanghai')))->getTimestamp(); $temporary = sys_get_temp_dir() . '/assistant-alert-test-' . bin2hex(random_bytes(6)); $scanner = new Alert($temporary, static function () use (&$clock): int { return $clock; }); $checks = 0; $ok = static function (bool $pass, string $message) use (&$checks): void { if (!$pass) { throw new RuntimeException($message); } $checks++; }; $deny = static function (callable $call, string $message) use ($ok): void { try { $call(); } catch (DomainException $e) { $ok(true, $message); return; } $ok(false, $message); }; $configure = static function (array $extra) use ($config, $settings): void { $config->set(array_merge($settings, $extra), 'assistant_service'); }; $list = static fn (array $input = [], int $admin = 13): array => Logic::alerts(Validate::parameters('alerts', $input), $admin); $review = static fn (array $input, int $admin = 13): array => Logic::reviewAlert(Validate::parameters('reviewAlert', $input), $admin); $im = static function (int $id, int $patient, string $from, string $text, string $type = 'text', ?string $to = null, int $at = 0) use (&$clock): void { Db::name('tcm_im_chat_message')->insert(['id' => $id, 'patient_id' => $patient, 'diagnosis_id' => $patient === 201 ? 101 : 102, 'from_account' => $from, 'to_account' => $to ?? ('patient_' . $patient), 'msg_type' => $type, 'text' => $text, 'msg_time' => $at ?: $clock]); }; try { $ok(Access::permissionForAction('alerts') === 'view' && Access::permissionForAction('reviewAlert') === 'reviewQuality' && Access::permissionForAction('wecomChats') === 'view', '预警、企微会话和复核分别使用查看、质检权限'); $ok(Validate::parameters('alerts', [])['status'] === 'open' && Validate::parameters('alerts', [])['severity'] === 'all' && Validate::parameters('alerts', [])['role'] === 'all', '预警列表默认待处理且不预选身份'); $deny(static fn () => Validate::parameters('alerts', ['status' => 'open', 'injected' => 1]), '预警列表拒绝未知字段'); $deny(static fn () => Validate::parameters('reviewAlert', ['id' => 1, 'status' => 'false_positive', 'note' => '']), '误报必须填写原因'); $deny(static fn () => Validate::parameters('reviewAlert', ['id' => 1, 'status' => 'open', 'note' => '']), '不能把预警改回未处理'); $im(1, 201, 'doctor_11', '诊断为糖尿病'); $ok($scanner->tick()['state'] === 'disabled' && Db::name('assistant_service_alert')->count() === 0, '关闭扫描时不写预警'); $configure(['alert_enabled' => true]); $armed = $scanner->tick(); $ok($armed['state'] === 'ready' && $armed['created'] === 1 && Db::name('assistant_service_alert')->count() === 1, '首次扫描检查已有的员工文字'); $before = Db::name('assistant_service_alert')->count(); $ok($list()['count'] === $before && $list()['schema_ready'] === true, '读取预警不产生新记录'); $quiet = $list([], 12); $ok($quiet['count'] === 0 && str_contains((string) $quiet['empty_reason'], '当前范围没有待处理预警') && $quiet['coverage'][1]['status'] === 'available', '其他患者没有命中时说明当前范围为空,通话转写已接入'); $pad = str_repeat('甲', 100); $im(2, 201, 'doctor_11', $pad . '诊断为糖尿病,加我微信'); $im(3, 201, 'doctor_11', '这个包治,保证好转'); $im(4, 201, 'doctor_11', '把银行卡和身份证照片发我'); $im(5, 201, 'doctor_11', '你有病吧'); $im(6, 201, 'doctor_11', '明天来换药,记得吃饭'); $im(7, 201, 'doctor_11', '建议服用黄连'); $im(8, 201, 'patient_201', '诊断为糖尿病', 'text', 'doctor_11'); $im(9, 201, 'DOCTOR_11', '诊断为糖尿病'); $im(10, 201, 'doctor_011', '诊断为糖尿病'); $im(11, 201, 'doctor_14', '诊断为糖尿病'); $im(12, 201, 'doctor_13', '诊断为糖尿病'); $im(13, 201, 'doctor_11', ' '); $im(14, 201, 'doctor_11', '诊断为糖尿病', 'image'); $im(15, 201, 'doctor_11', '诊断为糖尿病', 'text', 'patient_999'); $im(16, 301, 'doctor_12', '私下转账给我'); $ok($scanner->tick()['state'] === 'waiting' && Db::name('assistant_service_alert')->count() === 1, '间隔未到不提前扫描'); Db::name('tcm_call_record')->insert(['id' => 1, 'diagnosis_id' => 101, 'caller_id' => 11, 'caller_type' => 'doctor', 'callee_id' => 201, 'callee_type' => 'patient', 'start_time' => $clock, 'duration' => 30, 'delete_time' => null]); Db::name('tcm_call_transcript_segment')->insertAll([ ['id' => 1, 'call_record_id' => 1, 'speaker_user_id' => 'doctor_11', 'speaker_role' => 'doctor', 'text' => '先停药再观察', 'create_time' => $clock], ['id' => 2, 'call_record_id' => 1, 'speaker_user_id' => 'patient_201', 'speaker_role' => 'patient', 'text' => '我自己停药了', 'create_time' => $clock], ['id' => 3, 'call_record_id' => 1, 'speaker_user_id' => 'doctor_14', 'speaker_role' => 'doctor', 'text' => '加我微信', 'create_time' => $clock], ]); $clock += 61; $created = $scanner->tick(); $ok($created['created'] === 11 && Db::name('assistant_service_alert')->count() === 12, '医生、主管的聊天和员工侧通话转写都记,患者发言和不配对账号不计'); $clinical = Db::name('assistant_service_alert')->where('source_id', 2)->where('rule_code', 'clinical_boundary')->find(); $ok($clinical && mb_strlen((string) $clinical['snippet']) <= 80 && str_contains((string) $clinical['snippet'], '诊断为糖尿病') && !str_contains((string) $clinical['snippet'], str_repeat('甲', 30)), '摘录只保留命中附近,不保存整段聊天'); $ok(Db::name('assistant_service_alert')->where('source_id', 2)->count() === 2, '同一条消息的诊疗和导流分成两条'); $ok(Db::name('assistant_service_alert')->where('rule_code', 'cure_promise')->value('severity') === 'high' && Db::name('assistant_service_alert')->where('rule_code', 'privacy_ask')->value('severity') === 'medium' && Db::name('assistant_service_alert')->where('rule_code', 'hostility')->value('severity') === 'low', '规则级别按诊疗承诺、索取、冲突分开'); $ok((int) Db::name('assistant_service_alert')->where('source_id', 7)->count() === 1, '明确的服药建议要进入预警'); $page = $list(); $ok($page['summary']['open'] === 11 && $page['summary']['high_open'] === 9 && $page['summary']['im_open'] === 9 && $page['summary']['call_open'] === 2, '主管汇总范围内聊天和通话预警'); $ok($page['summary']['assistant_open'] > 0 && $page['summary']['doctor_open'] > 0 && $list(['role' => 'doctor'])['summary']['open'] === 11, '待处理按医助和医生分开计数,身份筛选不改总数'); $ok($list(['role' => 'doctor'])['lists'][0]['staff']['role_name'] === '医生' && $list(['role' => 'assistant'])['lists'][0]['staff']['role_name'] === '医助', '可以只看医生或只看医助'); $ok($page['coverage'][1]['channel'] === 'call' && $page['coverage'][1]['status'] === 'available' && $page['coverage'][2]['status'] === 'not_connected', '通话转写已接入,企微仍未接入'); $ok($page['empty_reason'] === '' && str_contains((string) $page['coverage'][0]['note'], '医生和医助'), '有命中时不再把列表解释成没有发言'); $doctorCall = null; foreach ($list([], 1)['lists'] as $row) { if ($row['channel'] === 'call' && (int) $row['staff']['id'] === 14) { $doctorCall = $row; } } $ok($doctorCall && $doctorCall['staff']['assistant'] === false && $doctorCall['staff']['role_name'] === '医生' && !Db::name('assistant_service_alert')->where('channel', 'call')->where('source_id', 2)->find(), '医生通话命中进入列表,患者转写不计'); $ok($list(['status' => 'all', 'page_size' => 1])['summary']['open'] === 11 && $list(['status' => 'all', 'page_size' => 1])['count'] === 11, '筛选和分页不缩小总待处理数'); $ok($list(['keyword' => '合成甲'])['count'] === 11 && $list(['keyword' => '域外'])['count'] === 0, '主管搜不到范围外的患者'); $ok($list([], 11)['count'] === 11 && $list([], 12)['count'] === 1 && $list([], 1)['count'] === 12, '可见患者上的医生和医助命中都列出,范围外患者不列出'); $hidden = (int) Db::name('assistant_service_alert')->where('staff_admin_id', 12)->value('id'); $deny(static fn () => $review(['id' => $hidden, 'status' => 'confirmed', 'note' => '']), '不能复核范围外预警'); $deny(static fn () => $review(['id' => (int) $clinical['id'], 'status' => 'confirmed', 'note' => ''], 11), '医助不能把自己的预警改成成立'); $deny(static fn () => $list([], 14), '没有模块权限的医生不能读预警'); $confirmed = $review(['id' => (int) $clinical['id'], 'status' => 'confirmed', 'note' => '']); $ok($confirmed['status'] === 'confirmed' && $list()['summary']['open'] === 10 && Db::name('assistant_service_alert')->where('id', $clinical['id'])->value('snippet') === $clinical['snippet'], '成立后离开待处理,摘录保持不变'); $hostility = (int) Db::name('assistant_service_alert')->where('rule_code', 'hostility')->value('id'); $reviewed = $review(['id' => $hostility, 'status' => 'false_positive', 'note' => '患者原话']); $ok($reviewed['status'] === 'false_positive' && Db::name('assistant_service_alert')->where('id', $hostility)->value('review_note') === '患者原话', '误报保留原因'); $clock += 61; file_put_contents($temporary . '/scan.json', json_encode(['cursor' => 1, 'last_scan_at' => 0, 'next_scan_at' => 0, 'state' => 'ready', 'rule_version' => 1])); $ok($scanner->tick()['created'] === 0 && Db::name('assistant_service_alert')->count() === 12, '游标回退后同一来源同一规则不重复入库'); $im(17, 201, 'doctor_11', '开具处方'); $ok($scanner->tick()['state'] === 'waiting' && Db::name('assistant_service_alert')->count() === 12, '刚扫完的间隔内新消息先等待'); $clock += 61; $configure(['alert_enabled' => true, 'alert_batch_size' => 1]); $ok($scanner->tick()['created'] === 1 && $scanner->tick()['state'] === 'waiting', '一批只处理设定条数,下一条等下一轮'); $clock += 61; $wecom = new Alert($temporary, static function () use (&$clock): int { return $clock; }, static function (string $cursor): array { if ($cursor !== '') { return ['configured' => true, 'items' => [], 'next_cursor' => $cursor, 'has_more' => false]; } return ['configured' => true, 'has_more' => false, 'next_cursor' => '2026-11-10T03:00:00Z|m1', 'items' => [[ 'id' => 'm1', 'sent_at' => '2026-11-10T03:00:00Z', 'patient_id' => 201, 'diagnosis_id' => 101, 'zyt_user_id' => '11', 'content' => '加我微信', ]]]; }); $fed = $wecom->tick(); $ok($fed['created'] === 1 && (int) Db::name('assistant_service_alert')->where('channel', 'wecom')->where('source_key', 'm1:201')->count() === 1, '企微归档里员工发出且已绑定患者的文字进入预警'); $local = $temporary . '/archive.db'; $archive = new PDO('sqlite:' . $local); $archive->exec('CREATE TABLE archive_source_account (id TEXT PRIMARY KEY, display_name TEXT)'); $archive->exec('CREATE TABLE archive_conversation (id TEXT PRIMARY KEY, conversation_type TEXT, name TEXT)'); $archive->exec('CREATE TABLE archive_message (id TEXT PRIMARY KEY, conversation_id TEXT, source_account_id TEXT, content TEXT, direction TEXT, status TEXT, message_type TEXT, sent_at TEXT, sent_at_epoch INTEGER)'); $archive->exec("INSERT INTO archive_source_account VALUES ('a1','甲'),('a2','外来客服')"); $archive->exec("INSERT INTO archive_conversation VALUES ('c1','direct_wecom','合成甲'),('c2','direct_wecom','未建档')"); $archive->exec("INSERT INTO archive_message VALUES ('local1','c1','a1','加我微信','outbound','normal','文本','2026-11-10T03:00:00+00:00',1794268800),('local2','c2','a2','私下转账给我','outbound','normal','文本','2026-11-10T03:01:00+00:00',1794268860)"); $saved = json_decode((string) file_get_contents($temporary . '/scan.json'), true); $saved['wecom_cursor'] = ''; $saved['next_scan_at'] = 0; file_put_contents($temporary . '/scan.json', json_encode($saved)); $configure(['alert_enabled' => true, 'wecom_archive_sqlite' => $local]); $localScan = (new Alert($temporary, static function () use (&$clock): int { return $clock; }))->tick(); $ok($localScan['created'] === 2 && (int) Db::name('assistant_service_alert')->where('source_key', 'local1:201')->value('staff_admin_id') === 11, '本地归档按姓名对上医助和患者'); $loose = null; foreach ($list([], 1)['lists'] as $row) { if (($row['patient_name'] ?? '') === '未建档') { $loose = $row; } } $ok($loose && $loose['staff']['name'] === '外来客服' && $loose['patient_id'] === 0, '对不上的本地会话仍给不受限账号查看'); $hiddenLocal = false; foreach ($list([], 13)['lists'] as $row) { if ((int) ($row['patient_id'] ?? 1) === 0) { $hiddenLocal = true; } } $ok(!$hiddenLocal, '部门主管看不到没有患者归属的企微会话'); $configure(['alert_enabled' => true, 'wecom_archive_sqlite' => $local, 'wecom_wording_enabled' => true, 'wecom_wording_interval_seconds' => 60, 'wecom_wording_batch_size' => 6]); $saved = json_decode((string) file_get_contents($temporary . '/scan.json'), true); $saved['wording_next_at'] = 0; $saved['wording_cursor'] = ''; $saved['next_scan_at'] = 0; file_put_contents($temporary . '/scan.json', json_encode($saved)); $wording = new Alert($temporary, static function () use (&$clock): int { return $clock; }, null, static function (): array { return ['ok' => true, 'content' => json_encode(['findings' => [['id' => 'local1', 'quote' => '加我微信', 'label' => '脱离平台']]], JSON_UNESCAPED_UNICODE)]; }); $judged = $wording->tick(); $ok($judged['created'] === 1 && (int) Db::name('assistant_service_alert')->where('rule_code', 'inappropriate_wording')->count() === 1, '模型指出且原文确实存在的用词才进入预警'); Db::execute('DROP TABLE test_assistant_service_alert'); $missing = $list(); $ok($missing['schema_ready'] === false && $missing['lists'] === [] && $missing['summary']['open'] === 0, '缺表时显示未就绪,不把空列表当成没有风险'); $ok($scanner->tick()['state'] === 'unavailable', '缺表时扫描停止并说明未就绪'); echo 'ASSISTANT_SERVICE_ALERT assertions=' . $checks . ' PASS sqlite=memory real_access=1 flock=1 model=0 network=0' . PHP_EOL; } finally { if (is_dir($temporary)) { foreach (glob($temporary . '/*') ?: [] as $file) { @unlink($file); } @rmdir($temporary); } }