PDO::ERRMODE_EXCEPTION]); if (!$child) { $pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4"); } $pdo->exec("USE `{$database}`"); $app = new think\App(); // no initialize() $manager = new think\DbManager(); $manager->setConfig(['default' => 'mysql', 'auto_timestamp' => true, 'datetime_format' => false, 'connections' => ['mysql' => ['type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port, 'database' => $database, 'username' => 'root', 'password' => '', 'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true]]]); Container::getInstance()->instance('think\DbManager', $manager); $config = new think\Config(); $config->set(['enabled' => true, 'encryption_key' => str_repeat('isolated-test-', 4), 'debounce_seconds' => 0, 'lease_seconds' => 600, 'max_attempts' => 3, 'max_manual_retries' => 2, 'max_parallel_per_model' => 1, 'daily_model_tasks' => 200, 'transcript_wait_seconds' => 300], 'prescription_analysis'); Container::getInstance()->instance('config', $config); if ($child) { $claim = Store::claimTask($argv[2] ?? 'qwen'); echo json_encode(['id' => $claim['id'] ?? null]) . "\n"; exit(0); } $checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; }; $root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'role_id' => [], 'dept_id' => [], 'name' => 'Test']; try { $pdo->exec('CREATE TABLE zyt_system_menu (id INT PRIMARY KEY AUTO_INCREMENT,pid INT,type VARCHAR(5),name VARCHAR(100),icon VARCHAR(50),sort INT,perms VARCHAR(100),paths VARCHAR(100),component VARCHAR(100),selected VARCHAR(100),params VARCHAR(100),is_cache INT,is_show INT,is_disable INT,create_time INT,update_time INT)'); $pdo->exec('CREATE TABLE zyt_system_role_menu (role_id INT,menu_id INT,UNIQUE KEY(role_id,menu_id))'); $pdo->exec('CREATE TABLE zyt_admin (id INT PRIMARY KEY,name VARCHAR(50),root INT,disable INT,delete_time INT NULL,account VARCHAR(50) DEFAULT "fixture")'); $pdo->exec('CREATE TABLE zyt_system_role (id INT PRIMARY KEY,name VARCHAR(50),data_scope INT DEFAULT 1,delete_time INT NULL)'); $pdo->exec('CREATE TABLE zyt_config (id INT PRIMARY KEY AUTO_INCREMENT,type VARCHAR(30),name VARCHAR(60),value TEXT,create_time INT,update_time INT)'); $pdo->exec('CREATE TABLE zyt_admin_role (admin_id INT,role_id INT)'); $pdo->exec('CREATE TABLE zyt_admin_dept (admin_id INT,dept_id INT)'); $pdo->exec('CREATE TABLE zyt_admin_jobs (admin_id INT,jobs_id INT)'); $pdo->exec("INSERT INTO zyt_admin(id,name,root,disable,delete_time) VALUES(1,'Test',1,0,NULL),(2,'Role user',0,0,NULL),(3,'Direct user',0,0,NULL),(4,'Disabled user',0,1,NULL),(5,'Deleted user',0,0,1),(6,'Manager',0,0,NULL)"); $pdo->exec("INSERT INTO zyt_system_role VALUES(7,'Selected role',1,NULL),(8,'Other role',1,NULL),(9,'Deleted role',1,1),(10,'Policy manager',1,NULL)"); $pdo->exec('INSERT INTO zyt_admin_role VALUES(2,7),(2,8),(6,10)'); $pdo->exec('CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY,patient_id INT,assistant_id INT DEFAULT 1,delete_time INT NULL)'); $pdo->exec('INSERT INTO zyt_tcm_diagnosis(id,patient_id) VALUES(1,100),(2,200)'); $pdo->exec('CREATE TABLE zyt_tcm_prescription (id INT PRIMARY KEY AUTO_INCREMENT,diagnosis_id INT DEFAULT 1,patient_id INT DEFAULT 100,creator_id INT DEFAULT 1,is_system_auto INT DEFAULT 0,void_status INT DEFAULT 0,delete_time INT NULL,herbs TEXT,prescription_type VARCHAR(30) DEFAULT "饮片",update_time INT DEFAULT 0) ENGINE=InnoDB'); foreach (['sn','prescription_name','dosage_unit','patient_name','phone','visit_no','prescription_date','pulse','pulse_condition', 'tongue','tongue_image','clinical_diagnosis','case_record','dose_unit','aux_usage','usage_instruction','usage_time','usage_way', 'dietary_taboo','usage_notes','doctor_name','doctor_signature','visible_role_ids','audit_by_name','audit_remark','void_by_name'] as $field) { $pdo->exec("ALTER TABLE zyt_tcm_prescription ADD `$field` TEXT NULL"); } foreach (['appointment_id','assistant_id','gender','age','dosage_bag_count','need_decoction','bags_per_dose','dose_count', 'usage_days','times_per_day','template_id','is_shared','audit_status','audit_time','audit_by','void_time','void_by','create_time'] as $field) { $pdo->exec("ALTER TABLE zyt_tcm_prescription ADD `$field` INT DEFAULT 0"); } $pdo->exec('ALTER TABLE zyt_tcm_prescription ADD dosage_amount DECIMAL(10,2) NULL, ADD amount DECIMAL(10,2) DEFAULT 0'); $pdo->exec('CREATE TABLE zyt_tcm_prescription_order (id INT PRIMARY KEY,prescription_id INT,source_prescription_id INT,prescription_audit_status INT,fulfillment_status INT,delete_time INT NULL)'); $pdo->exec('CREATE TABLE zyt_doctor_medicine (id INT PRIMARY KEY,name VARCHAR(100),unit VARCHAR(20),status INT,delete_time INT NULL)'); $pdo->exec("INSERT INTO zyt_doctor_medicine VALUES(1,'测试药材','g',1,NULL)"); $migration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_09_09_prescription_ai_analysis.sql'); foreach (explode(';', preg_replace('/^--.*$/m', '', $migration)) as $statement) { if (trim($statement) !== '') { $pdo->exec($statement); } } // Migrations are rerunnable including role grants. foreach (explode(';', preg_replace('/^--.*$/m', '', $migration)) as $statement) { if (trim($statement) !== '') { $pdo->exec($statement); } } $expect((int) Db::name('system_menu')->count() === 7, 'idempotent permission migration'); $visibilityMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_10_17_prescription_ai_agreement_visibility.sql'); for ($repeat = 0; $repeat < 2; $repeat++) { foreach (explode(';', preg_replace('/^--.*$/m', '', $visibilityMigration)) as $statement) { if (trim($statement) !== '') { $pdo->exec($statement); } } } $expect((int) Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->count() === 1 && (int) Db::name('config')->count() === 1, 'visibility migration is idempotent and seeds one empty policy'); $expect((int) Db::name('system_role_menu')->count() === 0, 'migration grants no nonroot role management permission'); $expect(Visibility::capabilities(1) === ['agreement_visible' => false, 'can_manage_visibility' => true], 'root manages policy but does not bypass empty view allowlist'); $expect(Visibility::getConfig(1) === ['role_ids' => [], 'admin_ids' => []], 'default policy is closed'); $denied = static function (callable $call, string $message) use ($expect): void { try { $call(); $expect(false, $message); } catch (DomainException $e) { $expect(true, $message); } }; $http = static function (string $action, int $adminId = 1, string $method = 'GET', array $params = []): array { // Run the real final controller against a local request without bootstrapping an HTTP app. $reflection = new ReflectionClass(\app\adminapi\controller\tcm\PrescriptionAiController::class); $controller = $reflection->newInstanceWithoutConstructor(); $request = (new think\Request())->setMethod($method); $request = $method === 'POST' ? $request->withPost($params) : $request->withGet($params); foreach (['request' => $request, 'adminId' => $adminId, 'adminInfo' => ['root' => 1, 'role_id' => [7], 'permissions' => ['*']]] as $property => $value) { $reflection->getProperty($property)->setValue($controller, $value); } return $controller->$action()->getData(); }; $expect($http('capabilities', 2)['data'] === ['agreement_visible' => false, 'can_manage_visibility' => false], 'capabilities is a login-only self-check, never trusts stale root/star token information'); $expect($http('capabilities', 4)['code'] === 0, 'disabled account cannot call capabilities with stale root token'); foreach (['getVisibilityConfig', 'visibilityOptions'] as $action) { $expect($http($action, 2)['code'] === 0 && $http($action, 1, 'POST')['code'] === 0, 'config read/options enforce current manager and GET method'); } $expect($http('setVisibilityConfig', 2, 'POST', ['role_ids' => [], 'admin_ids' => [2]])['code'] === 0 && $http('setVisibilityConfig', 1, 'GET')['code'] === 0 && $http('setVisibilityConfig', 1, 'POST', ['role_ids' => [], 'admin_ids' => [], 'root' => true])['code'] === 0, 'config write rejects manager escalation, wrong method and unknown parameters'); foreach ([static fn () => Visibility::getConfig(2), static fn () => Visibility::options(2), static fn () => Visibility::setConfig(['role_ids' => [], 'admin_ids' => [2]], 2)] as $operation) { $denied($operation, 'ordinary account cannot manage allowlist despite unregistered config routes'); } Db::name('system_role_menu')->insert(['role_id' => 10, 'menu_id' => Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->value('id')]); $expect(Visibility::capabilities(6) === ['agreement_visible' => false, 'can_manage_visibility' => true], 'dedicated permission enables management without granting agreement view'); $selected = Visibility::setConfig(['role_ids' => ['7', 7], 'admin_ids' => [3]], 6); $expect($selected === ['role_ids' => [7], 'admin_ids' => [3]], 'authorized manager saves canonical policy'); $expect(Visibility::agreementVisible(2) && Visibility::agreementVisible(3) && !Visibility::agreementVisible(1), 'role union and direct user are alternatives, root has no implicit view'); $denied(static fn () => Api::statuses([], 2, Access::actor(2)), 'allowlist does not replace existing action RBAC'); Db::name('system_role_menu')->insert(['role_id' => 8, 'menu_id' => Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/statuses')->value('id')]); $expect(Api::statuses([], 2, Access::actor(2))['agreement_visible'] === true, 'selected role plus independent action permission can view metric capability'); Db::name('admin_role')->where('admin_id', 2)->where('role_id', 7)->delete(); $expect(!Visibility::agreementVisible(2), 'role membership revocation applies immediately'); Db::name('admin_role')->insert(['admin_id' => 2, 'role_id' => 7]); Db::name('system_role')->where('id', 7)->update(['delete_time' => 1]); $expect(!Visibility::agreementVisible(2), 'deleted selected role never authorizes stale membership'); Db::name('system_role')->where('id', 7)->update(['delete_time' => null]); $denied(static fn () => Visibility::setConfig(['role_ids' => [9], 'admin_ids' => []], 1), 'deleted role cannot be newly selected'); $denied(static fn () => Visibility::setConfig(['role_ids' => [], 'admin_ids' => [4]], 1), 'disabled user cannot be newly selected'); $options = Visibility::options(6); $expect(array_column($options['roles'], 'id') === [7, 8, 10] && array_column($options['admins'], 'id') === [1, 2, 3, 6] && array_keys($options['admins'][0]) === ['id', 'name', 'account'], 'options contain active records and minimal account fields only'); Db::name('system_role')->insertAll(array_map(static fn (int $id): array => ['id' => $id, 'name' => 'bounded selector fixture'], range(100, 599))); $denied(static fn () => Visibility::options(1), 'oversized selector fails explicitly instead of silently truncating'); Db::name('system_role')->where('id', '>=', 100)->delete(); Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->update(['is_disable' => 1]); $denied(static fn () => Visibility::getConfig(6), 'disabled management permission is revoked immediately'); Db::name('system_menu')->where('perms', 'tcm.prescriptionAi/manageVisibility')->update(['is_disable' => 0]); Db::name('config')->where('type', Visibility::CONFIG_TYPE)->update(['value' => '{"role_ids":"*","admin_ids":[1]}']); $expect(!Visibility::agreementVisible(1) && !Visibility::agreementVisible(3), 'malformed policy fails closed as a whole'); ConfigService::set(Visibility::CONFIG_TYPE, Visibility::CONFIG_NAME, ['role_ids' => [], 'admin_ids' => [1, 4, 5]]); $expect(Visibility::agreementVisible(1) && !Visibility::agreementVisible(4) && !Visibility::agreementVisible(5), 'stale direct grants cannot enable disabled or deleted accounts'); Visibility::setConfig(['role_ids' => [], 'admin_ids' => [1]], 1); if (!$legacyProgressSchema) { $progressMigration = file_get_contents(dirname(__DIR__) . '/database/migrations/2026_09_10_prescription_ai_progress.sql'); for ($i = 0; $i < 2; $i++) { foreach (explode(';', preg_replace('/^--.*$/m', '', $progressMigration)) as $statement) { if (trim($statement) !== '') { $pdo->exec($statement); } } } $expect((int) $pdo->query("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'zyt_prescription_ai_task' AND COLUMN_NAME = 'progress_json'")->fetchColumn() === 1, 'additive progress migration is rerunnable'); } $expect(Store::supportsProgress() === !$legacyProgressSchema, 'old and migrated schema detected without reading model caches'); $fixture = static function (array $extra = []) use ($root): array { $id = (int) Db::name('tcm_prescription')->insertGetId($extra + [ 'herbs' => json_encode([['medicine_id' => 1, 'name' => '测试药材', 'dosage' => 10]], JSON_UNESCAPED_UNICODE), 'update_time' => time(), ]); return Db::name('tcm_prescription')->where('id', $id)->find(); }; $save = static function (array $rx, array $options = []) use ($root): ?int { return Db::transaction(static function () use ($rx, $options, $root): ?int { $fresh = Db::name('tcm_prescription')->where('id', $rx['id'])->lock(true)->find(); return Store::recordSaved($fresh, 1, $root, ['ai_assisted' => false] + $options); }); }; $context = ['source' => ['clinical' => 'synthetic record'], 'source_hash' => hash('sha256', 'fixed'), 'source_diagnosis_ids' => [1], 'source_summary' => ['diagnosis_count' => 1], 'missing' => [], 'baseline_eligible' => false, 'baseline_exclusion_reasons' => ['SOURCE_HISTORY_VERSIONS_UNAVAILABLE'], 'comparison_type' => 'latest_context', 'cutoff_at' => time(), 'wait_for_transcript' => false]; $rx = $fixture(['patient_name' => 'Snapshot patient', 'gender' => 0, 'age' => 52, 'clinical_diagnosis' => 'Saved clinical diagnosis', 'usage_instruction' => 'Saved usage', 'times_per_day' => 2, 'usage_days' => 7, 'aux_usage' => '{"prescription_name":"Saved auxiliary formula","dosage_amount":1.5,"dosage_bag_count":2,"need_decoction":0,"bags_per_dose":1,"times_per_day":1,"usage_days":3,"phone":"private-aux-contact","nested":{"phone":"private-aux-nested"}}', 'phone' => 'private-contact', 'doctor_signature' => 'private-signature', 'case_record' => '{"phone":"private-nested-contact","clinical_diagnosis":"other diagnosis"}']); $context['source_access_manifest'] = ['schema_version' => 'prescription-source-access-v1', 'patient_id' => 100, 'target' => ['prescription_id' => (int) $rx['id'], 'diagnosis_id' => 1], 'records' => [['source_kind' => 'diagnoses', 'id' => 1, 'source_id' => 'diagnoses:1', 'diagnosis_id' => 1, 'patient_id' => 100, 'staff' => []]]]; $batchId = $save($rx); $expect($batchId > 0 && $save($rx) === $batchId, 'same clinical content enqueues once'); $pendingDetail = Api::detail($batchId, 1, $root); $snapshot = $pendingDetail['doctor_snapshot']; $expect($pendingDetail['status'] === 'preparing' && $pendingDetail['models'] === [] && $snapshot['prescription']['herbs'][0]['name'] === '测试药材', 'original prescription available before model preparation and results'); $expect($snapshot['patient']['name'] === 'Snapshot patient' && $snapshot['patient']['gender_label'] === '女' && (int) $snapshot['patient']['age'] === 52 && $snapshot['diagnosis']['clinical_diagnosis'] === 'Saved clinical diagnosis' && $snapshot['prescription']['usage_instruction'] === 'Saved usage', 'saved patient diagnosis and usage projected'); $expect((int) $snapshot['prescription']['times_per_day'] === 2 && (int) $snapshot['prescription']['usage_days'] === 7 && $snapshot['prescription']['aux_usage'] === ['prescription_name' => 'Saved auxiliary formula', 'dosage_amount' => 1.5, 'dosage_bag_count' => 2, 'need_decoction' => 0, 'bags_per_dose' => 1, 'times_per_day' => 1, 'usage_days' => 3], 'saved main and auxiliary usage remain distinct and auxiliary private fields are excluded'); $expect(!str_contains(json_encode($snapshot), 'private-') && !isset($snapshot['prescription']['herbs'][0]['medicine_id']), 'detail original excludes contacts signatures nested records and internal medicine IDs'); try { Api::detail($batchId, 0, []); $expect(false, 'unauthenticated caller cannot read original'); } catch (DomainException $e) { $checks++; } Db::name('tcm_diagnosis')->where('id', 1)->update(['delete_time' => time()]); try { Api::detail($batchId, 1, $root); $expect(false, 'revoked source access cannot read original'); } catch (DomainException $e) { $checks++; } Db::name('tcm_diagnosis')->where('id', 1)->update(['delete_time' => null]); $expect(!isset($snapshot['prescription']['herbs'][0]['unit']) && !isset($snapshot['prescription']['herbs'][0]['dose_basis']) && !isset($snapshot['diagnosis']['western_diagnosis']), 'missing source units and diagnosis split are not invented'); $blank = $fixture(['is_system_auto' => 1, 'herbs' => '[]']); $expect($save($blank) === null, 'blank prescriptions do not enqueue'); $expect((int) Db::name('prescription_ai_task')->count() === 0, 'no model call or task before snapshot'); $claim = Store::claimBatch(); $expect((int) $claim['id'] === $batchId && Store::claimBatch() === null, 'preparation lease prevents duplicate claim'); $expect(Store::finishPreparation($claim, $context), 'snapshot prepares'); $expect(!Store::finishPreparation($claim, $context), 'preparation cannot run twice'); $expect((int) Db::name('prescription_ai_task')->count() === 2, 'exactly two model tasks'); $stored = Db::name('prescription_ai_batch')->find($batchId); $expect(!str_contains($stored['context_cipher'], 'synthetic record'), 'context encrypted'); $expect((new Cipher())->decrypt($stored['context_cipher'], 'context')['source_hash'] === $context['source_hash'], 'context decrypts exactly'); $expect((int) $stored['baseline_eligible'] === 0, 'uncertain historical provenance excluded'); $q = Store::claimTask('qwen'); $o = Store::claimTask('openai'); $expect($q !== null && $o !== null && Store::claimTask('qwen') === null, 'models run independently, claim exclusive'); $expect(Store::checkpoint($q, ['stage' => 'first', 'steps' => ['private fixture']]), 'checkpoint persists'); $expect(!str_contains(Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_cipher'), 'private fixture'), 'checkpoint encrypted'); $beforeProgressCipher = Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_cipher'); $progressPayload = ['public' => Progress::advance([], 'text', 'waiting', 1, 3), 'steps' => ['must-not-replace-cache'], 'prompt' => 'never-public']; $expect(Store::checkpoint($q, $progressPayload, false) && Store::checkpoint($q, $progressPayload, false), 'same-second metadata no-op still recognizes a valid lease'); $expect(Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_cipher') === $beforeProgressCipher, 'metadata-only checkpoint leaves encrypted model cache unchanged'); if (!$legacyProgressSchema) { $publicJson = Db::name('prescription_ai_task')->where('id', $q['id'])->value('progress_json'); $expect(!str_contains($publicJson, 'never-public') && !str_contains($publicJson, 'must-not-replace-cache') && json_decode($publicJson, true)['completed_units'] === 1, 'database stores only sanitized scalar progress'); } $modelQueries = []; $captureModels = true; Db::listen(static function (string $sql) use (&$modelQueries, &$captureModels): void { if ($captureModels && str_contains($sql, 'prescription_ai_task') && preg_match('/^SELECT/i', $sql)) { $modelQueries[] = $sql; } }); $liveStatus = Api::statuses([$rx['id']], 1, $root)['items'][0]; $liveDetail = Api::detail($batchId, 1, $root); $liveReports = Api::reports(['prescription_id' => $rx['id']], 1, $root); $expect(!array_key_exists('doctor_snapshot', $liveStatus) && !array_key_exists('doctor_snapshot', $liveReports['lists'][0]), 'summaries do not expose original clinical content'); $captureModels = false; $expect($modelQueries !== [], 'list/detail/history task select queries observed'); foreach ($modelQueries as $sql) { $expect(!str_contains($sql, 'progress_cipher') && !preg_match('/SELECT\s+\*/i', $sql), 'polling selects bounded task fields, not encrypted model cache'); } $expect($liveStatus['models']['qwen']['progress']['stage'] === ($legacyProgressSchema ? 'unknown' : 'text') && $liveDetail['models']['qwen']['progress']['stage'] === ($legacyProgressSchema ? 'unknown' : 'text'), 'status and detail present measured progress, with honest old-schema fallback'); $expect(isset($liveStatus['progress']) && isset($liveReports['lists'][0]['models']['qwen']['progress']), 'batch and history include public progress'); $output = ['report' => ['summary' => 'synthetic report'], 'candidate' => ['status' => 'available_for_review'], 'coverage' => ['status' => 'complete'], 'model_name' => 'test', 'prompt_version' => 'test-v1']; $comparison = ['status' => 'comparable', 'score' => 80, 'herb_score' => 100, 'algorithm_version' => 'test-v1']; $expect(Store::complete($q, $output, $comparison), 'first model completes'); $expect(!Store::complete($q, $output, $comparison), 'duplicate result callback fenced'); Store::fail($o, 'UPSTREAM_TIMEOUT', false); $expect(Db::name('prescription_ai_batch')->where('id', $batchId)->value('status') === 'partial', 'one failure preserves other result'); $statuses = Api::statuses([$rx['id']], 1, $root); $expect($statuses['items'][0]['models']['qwen']['score'] === 80.0, 'list returns persisted numeric score'); $expect($statuses['items'][0]['models']['openai']['score'] === null, 'failed score is null rather than zero'); $detail = Api::detail($batchId, 1, $root); $expect($detail['models']['qwen']['report']['summary'] === 'synthetic report', 'authorized detail decrypts'); $expect($detail['models']['qwen']['progress']['stage'] === 'completed' && $statuses['items'][0]['models']['openai']['progress']['stage'] === 'failed', 'persisted task terminal state overrides stale stage'); $resultRow = Db::name('prescription_ai_result')->where('batch_id', $batchId)->where('model_key', 'qwen')->find(); $resultBody = (new Cipher())->decrypt($resultRow['body_cipher'], 'result:' . $batchId . ':qwen'); $resultBody['progress'] = ['stage' => 'poisoned', 'notice' => 'model supplied text']; Db::name('prescription_ai_result')->where('id', $resultRow['id'])->update([ 'body_cipher' => (new Cipher())->encrypt($resultBody, 'result:' . $batchId . ':qwen'), ]); $expect(Api::detail($batchId, 1, $root)['models']['qwen']['progress']['stage'] === 'completed', 'report body cannot override trusted task progress'); Api::review($batchId, 'qwen', 'not_adopted', 'test comment', 1, $root); $expect(Api::detail($batchId, 1, $root)['models']['qwen']['review']['status'] === 'not_adopted', 'review independent of prescription'); $resultBody['comparison']['reason'] = 'hidden-metric-sentinel'; $resultBody['comparison']['normalization'] = ['matched_contribution_sum' => 'hidden-metric-sentinel']; foreach (['score', 'herb_score', 'raw', 'result', 'response', 'body'] as $rawKey) { $resultBody[$rawKey] = ['unknown' => 'hidden-metric-sentinel']; } $resultBody['report']['unknown_debug'] = ['normalization' => 'hidden-metric-sentinel']; $resultBody['candidate']['unknown_debug'] = 'hidden-metric-sentinel'; Db::name('prescription_ai_result')->where('id', $resultRow['id'])->update([ 'body_cipher' => (new Cipher())->encrypt($resultBody, 'result:' . $batchId . ':qwen'), ]); Visibility::setConfig(['role_ids' => [], 'admin_ids' => []], 1); $hiddenStatus = Api::statuses([$rx['id']], 1, $root); $hiddenReports = Api::reports(['prescription_id' => $rx['id']], 1, $root); $hiddenDetail = Api::detail($batchId, 1, $root); $expect($hiddenStatus['agreement_visible'] === false && $hiddenStatus['items'][0]['agreement_visible'] === false && $hiddenReports['agreement_visible'] === false && $hiddenReports['lists'][0]['agreement_visible'] === false && $hiddenDetail['agreement_visible'] === false, 'all list/history/detail levels carry current agreement capability'); foreach ([$hiddenStatus['items'][0], $hiddenReports['lists'][0], $hiddenDetail] as $hiddenBatch) { $expect(!isset($hiddenBatch['comparison_type']) && !isset($hiddenBatch['baseline_eligible']) && !isset($hiddenBatch['baseline_exclusion_reasons']) && !isset($hiddenBatch['models']['qwen']['score']) && !isset($hiddenBatch['models']['qwen']['herb_score']) && !isset($hiddenBatch['models']['qwen']['comparison']) && !isset($hiddenBatch['models']['qwen']['comparison_status']) && !isset($hiddenBatch['models']['qwen']['comparison_reason_code']) && !isset($hiddenBatch['models']['qwen']['reason']) && !str_contains(json_encode($hiddenBatch), 'hidden-metric-sentinel'), 'shared API projection removes metrics and raw-body paths'); } $expect($hiddenDetail['models']['qwen']['report']['summary'] === 'synthetic report' && $hiddenDetail['models']['qwen']['candidate']['status'] === 'available_for_review' && $hiddenDetail['doctor_snapshot'] === $snapshot && $hiddenDetail['models']['qwen']['review']['status'] === 'not_adopted' && $hiddenDetail['models']['openai']['reason'] !== '', 'hidden metric preserves report, candidate, original, review and failure explanation'); $denied(static fn () => Api::statistics([], 1, $root), 'root without selected grant cannot read agreement statistics'); Api::review($batchId, 'qwen', 'not_adopted', 'test comment', 1, $root); $expect(Api::retry($batchId, 'openai', 1, $root)['status'] === 'retry_wait', 'agreement restriction does not remove independent report review/retry permission'); Visibility::setConfig(['role_ids' => [], 'admin_ids' => [1]], 1); $restored = Api::detail($batchId, 1, $root); $expect($restored['agreement_visible'] && $restored['models']['qwen']['score'] === 80.0 && $restored['models']['qwen']['comparison']['score'] === 80 && !isset($restored['models']['qwen']['raw']) && !isset($restored['models']['qwen']['result']), 'selected root gets genuine persisted metric, never arbitrary decrypted-body aliases'); Api::retry($batchId, 'openai', 1, $root); $o2 = Store::claimTask('openai'); $expect((int) $o2['total_attempts'] === 2 && (int) $o2['attempts'] === 1, 'manual retry preserves lifetime attempts'); $expect(Store::complete($o2, $output, $comparison), 'failed model retries without repeating successful model'); $expect(Db::name('prescription_ai_batch')->where('id', $batchId)->value('status') === 'success', 'both success aggregate'); $stats = Api::statistics([], 1, $root); $expect($stats['doctors'][0]['models']['qwen']['mean'] === null, 'nonbaseline scores never become doctor accuracy'); Db::name('prescription_ai_batch')->where('id', $batchId)->update(['baseline_eligible' => 1, 'baseline_exclusions_json' => '[]']); $eligibleStats = Api::statistics([], 1, $root); $expect($eligibleStats['doctors'][0]['models']['qwen']['mean'] === 80.0, 'synthetic qualified baseline has empty exclusion reason'); // 分布直方图必须随统计一起返回,否则界面只能凭均值猜形状。 $bins = $eligibleStats['doctors'][0]['models']['qwen']['distribution'] ?? null; $expect(is_array($bins) && array_sum($bins) === 1 && ($bins['[80,100]'] ?? 0) === 1, 'statistics expose the agreement distribution bins'); Db::name('prescription_ai_batch')->where('id', $batchId)->update(['baseline_eligible' => 0, 'baseline_exclusions_json' => '["SOURCE_HISTORY_VERSIONS_UNAVAILABLE"]']); $expect((int) Db::name('prescription_ai_attempt')->count() === 3, 'attempt history retained'); $payload = ['request_key' => '12345678-abcd-1234-abcd-123456789012', 'herbs' => [['name' => 'fixture']]]; Db::transaction(static function () use ($payload, $rx, $expect): void { $expect(SaveRequest::replay($payload, 1, true) === null, 'first request reserved'); SaveRequest::complete($payload, 1, (int) $rx['id']); }); $expect(SaveRequest::replay($payload, 1) === (int) $rx['id'], 'lost save response replays same prescription'); try { SaveRequest::replay($payload + ['extra' => 'changed'], 1); $expect(false, 'changed content cannot reuse key'); } catch (DomainException $e) { $checks++; } $countBefore = (int) Db::name('prescription_ai_batch')->count(); try { Db::transaction(static function () use ($fixture, $save): void { $save($fixture()); throw new RuntimeException('rollback fixture'); }); } catch (RuntimeException $e) {} $expect((int) Db::name('prescription_ai_batch')->count() === $countBefore, 'prescription and outbox roll back together'); Db::name('tcm_prescription')->where('id', $rx['id'])->update(['herbs' => '[{"name":"changed","dosage":20}]', 'clinical_diagnosis' => 'Changed clinical diagnosis', 'patient_name' => 'Changed patient', 'aux_usage' => '{"times_per_day":3,"usage_days":9}']); $newBatch = $save($rx); $expect($newBatch !== $batchId, 'clinical change creates immutable new batch'); $expect(Db::name('prescription_ai_batch')->where('id', $batchId)->value('validity') === 'prescription_changed', 'previous version invalidated'); $expect((int) Db::name('prescription_ai_result')->count() === 2, 'historic model results immutable'); $expect(Api::detail($batchId, 1, $root)['doctor_snapshot'] === $snapshot, 'historic report retains saved original after live prescription patient and diagnosis change'); $expect(Api::detail($newBatch, 1, $root)['doctor_snapshot']['diagnosis']['clinical_diagnosis'] === 'Changed clinical diagnosis', 'new pending revision carries its own updated original'); $newClaim = Store::claimBatch(); Store::finishPreparation($newClaim, $context); $lease = Store::claimTask('qwen'); Db::name('prescription_ai_task')->where('id', $lease['id'])->update(['lock_until' => time() - 1]); $replacement = Store::claimTask('qwen'); $expect($replacement !== null && $replacement['lock_token'] !== $lease['lock_token'], 'expired lease recovered'); $expect(!Store::checkpoint($lease, []) && !Store::complete($lease, $output, $comparison), 'old worker cannot write after lease steal'); $expect(!Store::checkpoint($lease, $progressPayload, false), 'metadata-only progress is fenced after lease steal'); $expect(Db::name('prescription_ai_attempt')->where('task_id', $lease['id'])->where('attempt_no', 1)->value('status') === 'expired', 'expired attempt audited'); putenv('ZYT_AI_TEST_DATABASE=' . $database); $pipes = []; $process = proc_open([PHP_BINARY, __FILE__, '--claim', 'qwen'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); $childOutput = stream_get_contents($pipes[1]); $childError = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($process); $expect($exit === 0 && $childError === '' && json_decode($childOutput, true)['id'] === null, 'second PHP process cannot duplicate live lease'); Db::transaction(static function () use ($rx): void { Db::name('tcm_prescription')->where('id', $rx['id'])->lock(true)->find(); Db::name('tcm_prescription')->where('id', $rx['id'])->update(['void_status' => 1]); Store::invalidate((int) $rx['id'], 'voided'); }); $expect(!Store::complete($replacement, $output, $comparison), 'void during generation cannot publish'); $expect(Db::name('prescription_ai_task')->where('id', $replacement['id'])->value('status') === 'cancelled', 'void cancels tasks'); $editParams = ['id' => $blank['id'], 'herbs' => [['medicine_id' => 1, 'name' => '测试药材', 'dosage' => 10]], 'prescription_date' => '2026-09-09', 'clinical_diagnosis' => 'fixture', 'usage_instruction' => 'fixture']; $expect(\app\adminapi\logic\tcm\PrescriptionLogic::edit($editParams, 1), 'real blank-to-manual hook: ' . \app\adminapi\logic\tcm\PrescriptionLogic::getError()); $blankBatch = Db::name('prescription_ai_batch')->where('prescription_id', $blank['id'])->find(); $expect($blankBatch['trigger_type'] === 'blank_to_manual' && (int) $blankBatch['patient_id'] === 100, 'blank hook carries authoritative binding'); $expect(\app\adminapi\logic\tcm\PrescriptionLogic::edit($editParams, 1), 'same edit can replay'); $expect((int) Db::name('prescription_ai_batch')->where('prescription_id', $blank['id'])->count() === 1, 'same edit does not generate twice'); $expect(\app\adminapi\logic\tcm\PrescriptionLogic::void((int) $blank['id'], 1, 'Test'), 'real void hook succeeds'); $expect(Db::name('prescription_ai_batch')->where('id', $blankBatch['id'])->value('validity') === 'voided', 'real void hook invalidates'); $addParams = ['request_key' => 'add-request-1234567890123456', 'diagnosis_id' => 0, 'patient_id' => 100, 'patient_name' => 'fixture', 'gender' => 1, 'age' => 50, 'clinical_diagnosis' => 'fixture', 'herbs' => [['medicine_id' => 1, 'name' => '测试药材', 'dosage' => 10]], 'doctor_signature' => 'fixture']; $added = \app\adminapi\logic\tcm\PrescriptionLogic::add($addParams, 1, $root); $expect($added !== null, 'real direct-manual save hook: ' . \app\adminapi\logic\tcm\PrescriptionLogic::getError()); $expect(\app\adminapi\logic\tcm\PrescriptionLogic::add($addParams, 1, $root) === $added, 'real direct save request replay returns same id'); $expect(Db::name('prescription_ai_batch')->where('prescription_id', $added)->value('error_code') === 'PATIENT_BINDING_REQUIRED', 'unbound direct prescription has explicit blocked analysis'); $pdo->exec("CREATE TRIGGER test_outbox_failure BEFORE INSERT ON zyt_prescription_ai_batch FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test fixture failure'"); $prescriptionsBefore = (int) Db::name('tcm_prescription')->count(); $addParams['request_key'] = 'rollback-request-123456789012'; $expect(\app\adminapi\logic\tcm\PrescriptionLogic::add($addParams, 1, $root) === null, 'outbox storage failure aborts real save'); $expect((int) Db::name('tcm_prescription')->count() === $prescriptionsBefore, 'real save and request reservation roll back with outbox'); $expect(!str_contains(\app\adminapi\logic\tcm\PrescriptionLogic::getError(), 'SQLSTATE'), 'outbox error text cannot expose SQL'); $pdo->exec('DROP TRIGGER test_outbox_failure'); if (defined('PRESCRIPTION_AI_PIPELINE_FIXTURE')) { $pipelineRx = $fixture(); $pipelineBatch = $save($pipelineRx); $worker = new \app\common\service\prescriptionai\PrescriptionAiWorker(); $expect($worker->prepareOne(), 'real coordinator freezes fixture evidence'); $comparingObservations = []; $captureComparing = !$legacyProgressSchema; Db::listen(static function (string $sql) use (&$comparingObservations, &$captureComparing, $pipelineBatch): void { if (!$captureComparing || !str_starts_with($sql, 'UPDATE') || !str_contains($sql, 'progress_json') || !str_contains($sql, 'comparing')) { return; } foreach (Db::name('prescription_ai_task')->where('batch_id', $pipelineBatch)->where('status', 'running')->select()->toArray() as $row) { if ((json_decode($row['progress_json'] ?? '{}', true)['stage'] ?? '') !== 'comparing') { continue; } $cache = (new Cipher())->decrypt($row['progress_cipher'], 'progress:' . $row['id']); $comparingObservations[] = ['status' => $row['status'], 'cache_stage' => $cache['stage'] ?? '', 'has_result' => (bool) Db::name('prescription_ai_result')->where('batch_id', $pipelineBatch)->where('model_key', $row['model_key'])->count()]; } }); $expect($worker->runOne('qwen'), 'first real model worker'); Db::name('doctor_medicine')->insert(['id' => 2, 'name' => 'new catalog fixture', 'unit' => 'g', 'status' => 1]); $expect($worker->runOne('openai'), 'second real model worker'); $captureComparing = false; if (!$legacyProgressSchema) { $expect(count($comparingObservations) === 2, 'each worker publishes comparing before it persists a result'); foreach ($comparingObservations as $observation) { $expect($observation === ['status' => 'running', 'cache_stage' => 'fixture', 'has_result' => false], 'comparison retains the encrypted checkpoint and never announces task completion before the result transaction'); } } $pipelineStatus = Api::statuses([$pipelineRx['id']], 1, $root)['items'][0]; $expect($pipelineStatus['models']['qwen']['score'] === 100.0, 'raw DB JSON prescription reaches real comparator'); $expect($pipelineStatus['models']['openai']['score'] === 100.0, 'second model score persisted'); $inputs = \app\common\service\prescriptionai\PrescriptionAiGenerator::$inputs; $expect($inputs['qwen']['source_hash'] === $inputs['openai']['source_hash'] && $inputs['qwen']['source'] === $inputs['openai']['source'], 'same frozen evidence supplied to both'); $expect($inputs['qwen']['dictionary_version'] === $inputs['openai']['dictionary_version'] && count($inputs['openai']['_comparison_catalog']) === 1, 'catalog changes between branches do not alter frozen dictionary'); $expect(\app\common\service\prescriptionai\PrescriptionAiContext::$builds === 1, 'evidence read once per batch'); $revokedRx = $fixture(); $revokedBatch = $save($revokedRx); $worker->prepareOne(); \app\common\service\prescriptionai\PrescriptionAiGenerator::$revokeDuringCall = true; $worker->runOne('qwen'); $expect((int) Db::name('prescription_ai_result')->where('batch_id', $revokedBatch)->count() === 0, 'source permission revoked during call prevents publication'); \app\common\service\prescriptionai\PrescriptionAiContext::$allowed = true; } $before = (int) Db::name('prescription_ai_batch')->count(); $config->set(['enabled' => false], 'prescription_analysis'); $expect($save($fixture()) === null && (int) Db::name('prescription_ai_batch')->count() === $before, 'feature disabled makes no outbox writes'); $expect(Api::statuses([1], 1, $root) === ['enabled' => false, 'agreement_visible' => true, 'items' => []], 'disabled list has graceful compatibility'); echo "Prescription AI queue: {$checks} checks passed\n"; } finally { $pdo->exec("DROP DATABASE `{$database}`"); }