Files
zyt/server/tests/AssistantServiceAlertTest.php
2026-11-12 09:07:07 +08:00

206 lines
20 KiB
PHP

<?php
declare(strict_types=1);
// 真实 ThinkPHP ORM/权限服务,合成 SQLite 内存库;不初始化应用、不读 .env、不联网。
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\logic\tcm\AssistantServiceLogic as Logic;
use app\adminapi\validate\tcm\AssistantServiceValidate as Validate;
use app\common\service\assistantservice\AssistantServiceAccess as Access;
use app\common\service\assistantservice\AssistantServiceAlert as Alert;
use think\Container;
use think\facade\Db;
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { throw new RuntimeException('需要隔离测试 SQLite 扩展'); }
new think\App();
set_exception_handler(static function (Throwable $e): void { fwrite(STDERR, $e->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);
}
}