$id, 'patient_id' => self::$patientId, 'assistant_id' => 11]; } } final class AssistantServiceContext { public static array $context = []; public static function build(int $id, int $admin, int $days = 30): array { return self::$context; } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php'; use app\common\service\assistantservice\AssistantServiceAccess as Access; use app\common\service\assistantservice\AssistantServiceContext as Context; use app\common\service\assistantservice\AssistantServiceEngine as Engine; use app\common\service\assistantservice\AssistantServiceWorker as Worker; use think\Container; use think\facade\Db; if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { throw new RuntimeException('SQLite required; Windows: php -d extension=php_pdo_sqlite.dll tests/AssistantServiceWorkerTest.php'); } new think\App(); // 禁止 initialize,不读环境与业务连接。 $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); $config = new think\Config(); $config->set(['enabled' => true, 'timeout' => 120, 'lease_seconds' => 300], 'assistant_service'); Container::getInstance()->instance('config', $config); Db::execute('CREATE TABLE test_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)'); Db::execute('CREATE TABLE test_assistant_service_knowledge (id INTEGER PRIMARY KEY,question TEXT,answer TEXT,category TEXT,source_note TEXT,status TEXT,version INTEGER)'); $knowledge = [['id' => 7, 'version' => 2, 'question' => '如何预约?', 'answer' => '医助协助核对可预约时段。', 'category' => '预约', 'source_note' => '合成文档']]; Db::name('assistant_service_knowledge')->insert($knowledge[0] + ['status' => 'published']); $context = ['patient' => ['diagnosis_id' => 1, 'patient_id' => 100, 'assistant_id' => 11], 'source_hash' => hash('sha256', 'synthetic sources'), 'scope_hash' => hash('sha256', 'synthetic scope'), 'sources' => [['id' => 'im:1', 'channel' => 'im', 'text' => '请问能否预约周六?', 'occurred_at' => 1, 'speaker' => 'patient', 'actor_admin_id' => 0, 'quality_eligible' => true]], 'coverage' => [], 'days' => 30]; Context::$context = $context; $reply = ['reply' => '我会协助核对可预约时段。', 'knowledge_refs' => [['id' => 7, 'version' => 2]], 'evidence_ids' => ['im:1'], 'needs_confirmation' => true, 'reason' => '时间仍需人工核对']; $checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; }; $make = static function (array $changes = []) use ($context, $knowledge): int { return (int) Db::name('assistant_service_job')->insertGetId(array_replace([ 'diagnosis_id' => 1, 'patient_id' => 100, 'actor_id' => 11, 'type' => 'reply', 'scenario' => 'followup', 'days' => 30, 'status' => 'queued', 'version' => 1, 'dedupe_key' => bin2hex(random_bytes(32)), 'source_hash' => $context['source_hash'], 'scope_hash' => $context['scope_hash'], 'payload_json' => json_encode(['context' => $context, 'knowledge' => $knowledge, 'question' => '如何预约?']), 'created_at' => time(), 'updated_at' => time(), ], $changes)); }; $row = static fn (int $id): array => Db::name('assistant_service_job')->where('id', $id)->find(); $calls = 0; $active = 0; $mode = 'success'; $worker = new Worker(); $generator = static function ($type, $scenario, $ctx, $kb, $question) use (&$calls, &$active, &$mode, $reply, $row, $expect, $worker): array { $calls++; $claimed = $row($active); $expect($claimed['status'] === 'running' && strlen($claimed['claim_token']) === 48 && $claimed['lease_until'] > time(), 'durable lease exists before send'); if ($mode === 'revoke') { Access::$allowed = false; } if ($mode === 'patient') { Access::$patientId = 200; } if ($mode === 'source') { Context::$context['source_hash'] = hash('sha256', 'changed'); } if ($mode === 'scope') { Context::$context['scope_hash'] = hash('sha256', 'reassigned'); } if ($mode === 'knowledge') { Db::name('assistant_service_knowledge')->where('id', 7)->update(['version' => 3]); } if ($mode === 'withdraw') { Db::name('assistant_service_knowledge')->where('id', 7)->update(['status' => 'disabled']); } if ($mode === 'body') { Db::name('assistant_service_knowledge')->where('id', 7)->update(['answer' => '未经版本更新的正文']); } if ($mode === 'newknowledge') { Db::name('assistant_service_knowledge')->insert(['id' => 8, 'question' => '新问题', 'answer' => '新答案', 'category' => '预约', 'source_note' => '合成', 'status' => 'published', 'version' => 1]); } if ($mode === 'lost') { Db::name('assistant_service_job')->where('id', $active)->update(['claim_token' => str_repeat('b', 48)]); } if ($mode === 'expired') { Db::name('assistant_service_job')->where('id', $active)->update(['lease_until' => time() - 1]); } if ($mode === 'second_worker') { $expect(!$worker->runOnce(fn () => throw new RuntimeException('must not send twice')), 'second worker cannot reclaim running job'); } if ($mode === 'unknown') { return ['ok' => false, 'error_code' => 'UPSTREAM_UNCERTAIN', 'error_message' => 'private-source']; } if ($mode === 'exception') { throw new RuntimeException('private-source-secret'); } if ($mode === 'domain_exception') { throw new DomainException('private-source-secret'); } if ($mode === 'rejected') { return ['ok' => false, 'error_code' => 'UPSTREAM_REJECTED']; } if ($mode === 'null_result') { return ['ok' => true, 'result' => null]; } if ($mode === 'invalid') { $reply['evidence_ids'] = ['im:999']; } // Engine 真实解析和规则复核走合成 transport,绝不发网络请求。 return Engine::generate($type, $scenario, $ctx, $kb, $question, fn () => ['ok' => true, 'content' => json_encode($reply), 'message_id' => 'synthetic-message']); }; $active = $make(); $expect($worker->runOnce($generator), 'queued job consumed'); $done = $row($active); $expect($done['status'] === 'ready' && json_decode($done['result_json'], true)['needs_confirmation'] && $done['claim_token'] === '', 'only validated result persisted'); $expect(count(Access::$calls) === 2 && Access::$calls[0][2] === 'draftReply' && Access::$calls[1][3] === true, 'fresh permission before and within completion transaction'); $expect(!$worker->runOnce($generator) && $calls === 1, 'ready not reissued'); foreach (['revoke', 'patient', 'source', 'scope', 'knowledge', 'withdraw', 'body'] as $mode) { Access::$allowed = true; Access::$patientId = 100; Context::$context = $context; Db::name('assistant_service_knowledge')->where('id', 7)->update($knowledge[0] + ['status' => 'published']); $active = $make(); $worker->runOnce($generator); $done = $row($active); $expect($done['status'] === 'stale' && $done['result_json'] === null, 'commit rejects changed ' . $mode); } Access::$allowed = true; Access::$patientId = 100; Context::$context = $context; Db::name('assistant_service_knowledge')->where('id', 7)->update($knowledge[0] + ['status' => 'published']); foreach (['newknowledge' => 'ready', 'second_worker' => 'ready', 'unknown' => 'needs_reconciliation', 'exception' => 'needs_reconciliation', 'domain_exception' => 'needs_reconciliation', 'rejected' => 'failed', 'invalid' => 'failed', 'null_result' => 'failed'] as $mode => $status) { $active = $make(); $worker->runOnce($generator); $done = $row($active); $expect($done['status'] === $status, 'terminal classification ' . $mode); $expect(!str_contains(json_encode($done), 'private-source'), 'exception/upstream details not persisted'); $before = $calls; $expect(!$worker->runOnce($generator) && $calls === $before, 'terminal never blindly retried'); } foreach (['lost', 'expired'] as $mode) { $active = $make(); $worker->runOnce($generator); $done = $row($active); $expect($done['status'] === 'running' && $done['result_json'] === null, 'stale worker cannot persist after ' . $mode); Db::name('assistant_service_job')->where('id', $active)->update(['lease_until' => time() - 1]); $before = $calls; $expect(!$worker->runOnce($generator), 'expired running not requeued'); $expect($row($active)['status'] === 'needs_reconciliation' && $calls === $before, 'expired lease fenced without upstream'); } $mode = 'success'; Access::$allowed = false; $active = $make(); $before = $calls; $worker->runOnce($generator); $expect($row($active)['status'] === 'stale' && $calls === $before, 'revoked before send never invokes transport'); Access::$allowed = true; $active = $make(['diagnosis_id' => 2]); $worker->runOnce($generator); $expect($row($active)['status'] === 'stale' && $calls === $before, 'cross-patient job rejected before send'); Db::name('assistant_service_knowledge')->where('id', 7)->update(['version' => 4]); $active = $make(); $worker->runOnce($generator); $expect($row($active)['status'] === 'stale' && $calls === $before, 'knowledge changed before send'); $active = $make(); $config->set(['enabled' => false], 'assistant_service'); $expect(!$worker->runOnce($generator) && $row($active)['status'] === 'queued', 'disabled queue sends nothing'); $output = new think\console\Output('buffer'); $exit = (new app\command\AssistantServiceWork())->run(new think\console\Input(['--once', '--sleep=3']), $output); $expect($exit === 0 && str_contains($output->fetch(), 'processed=0'), 'real command once parses and exits while disabled'); Db::execute('DROP TABLE test_assistant_service_job'); $config->set(['enabled' => true], 'assistant_service'); $output = new think\console\Output('buffer'); $exit = (new app\command\AssistantServiceWork())->run(new think\console\Input(['--once']), $output); $printed = $output->fetch(); $expect($exit === 1 && str_contains($printed, 'storage_or_configuration_error') && !str_contains($printed, 'test_assistant'), 'real command catches missing schema without leaking SQL or names'); echo 'ASSISTANT_SERVICE_WORKER assertions=' . $checks . ' PASS sqlite=memory real_worker_orm=1 network=0' . PHP_EOL; }