This commit is contained in:
Your Name
2026-10-17 16:51:48 +08:00
parent 56f85f7d89
commit ba2e2e4087
41 changed files with 2851 additions and 96 deletions
+130 -3
View File
@@ -11,6 +11,9 @@ use app\common\service\prescriptionai\PrescriptionAiPolicy as Policy;
use app\common\service\prescriptionai\PrescriptionAiCipher as Cipher;
use app\common\service\prescriptionai\PrescriptionAiProgress as Progress;
use app\common\service\prescriptionai\PrescriptionAiRequest as SaveRequest;
use app\common\service\prescriptionai\PrescriptionAiVisibility as Visibility;
use app\common\service\prescriptionai\PrescriptionAiAccess as Access;
use app\common\service\ConfigService;
use app\adminapi\logic\tcm\PrescriptionAiLogic as Api;
use think\Container;
use think\facade\Db;
@@ -56,11 +59,15 @@ $root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'role_id' => [], 'dept_id' =>
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)');
$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 VALUES(1,'Test',1,0,NULL)");
$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');
@@ -86,6 +93,87 @@ try {
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++) {
@@ -225,6 +313,45 @@ try {
'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');
@@ -363,7 +490,7 @@ try {
$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, 'items' => []], 'disabled list has graceful compatibility');
$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}`");