146 lines
12 KiB
PHP
146 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\assistantservice {
|
|
// 用隔离权限/来源适配器驱动真实 worker/ORM,API 权限算法由专门测试覆盖。
|
|
final class AssistantServiceAccess {
|
|
public static bool $allowed = true;
|
|
public static int $patientId = 100;
|
|
public static array $calls = [];
|
|
public static function diagnosis(int $id, int $admin, string $permission = 'view', bool $lock = false): array {
|
|
self::$calls[] = [$id, $admin, $permission, $lock];
|
|
if (!self::$allowed || $id !== 1 || $admin !== 11) { throw new \DomainException('synthetic-secret'); }
|
|
return ['id' => $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;
|
|
}
|