feat(followup-audio): separate uploader-only recording delete permission
This commit is contained in:
@@ -87,7 +87,8 @@ class AuthMiddleware
|
|||||||
$action = substr($accessUri, strlen('tcm.followupaudio/'));
|
$action = substr($accessUri, strlen('tcm.followupaudio/'));
|
||||||
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
|
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
|
||||||
if (in_array($action, $allowed, true) && in_array('tcm.diagnosis/edit', $uris, true)
|
if (in_array($action, $allowed, true) && in_array('tcm.diagnosis/edit', $uris, true)
|
||||||
&& in_array('tcm.diagnosis/followupaudio', $uris, true)) {
|
&& in_array('tcm.diagnosis/followupaudio', $uris, true)
|
||||||
|
&& ($action !== 'delete' || in_array('tcm.followupaudio/delete', $uris, true))) {
|
||||||
return $next($request); // The service also checks the current patient scope and daily permissions.
|
return $next($request); // The service also checks the current patient scope and daily permissions.
|
||||||
}
|
}
|
||||||
return JsonService::fail('权限不足,无法访问或操作');
|
return JsonService::fail('权限不足,无法访问或操作');
|
||||||
|
|||||||
@@ -108,9 +108,8 @@ final class FollowupAudioLogic
|
|||||||
{
|
{
|
||||||
$task = Store::task((int) $summary['id']);
|
$task = Store::task((int) $summary['id']);
|
||||||
$info = Access::actor($actor);
|
$info = Access::actor($actor);
|
||||||
$reason = !Store::enabled() ? '回访录音功能尚未启用'
|
$reason = !Store::enabled() ? '回访录音功能尚未启用' : Access::deleteReason($task, $actor);
|
||||||
: ((int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor
|
if ($reason === '') { $reason = Store::deleteBlockReason($task); }
|
||||||
? '仅上传者或超级管理员可删除录音' : Store::deleteBlockReason($task));
|
|
||||||
$reextractReason = (int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor
|
$reextractReason = (int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor
|
||||||
? '仅上传者或超级管理员可重新提炼' : Reextract::blockReason($task);
|
? '仅上传者或超级管理员可重新提炼' : Reextract::blockReason($task);
|
||||||
return $summary + ['can_delete' => $reason === '', 'delete_block_reason' => $reason,
|
return $summary + ['can_delete' => $reason === '', 'delete_block_reason' => $reason,
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ final class FollowupAudioAccess
|
|||||||
return $task;
|
return $task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One fresh authorization rule for both delete capability and the locked delete transaction. */
|
||||||
|
public static function deleteReason(array $task, int $actor): string
|
||||||
|
{
|
||||||
|
$info = self::actor($actor);
|
||||||
|
if (!self::allowed($actor, $info, 'tcm.followupAudio/delete')) {
|
||||||
|
return '缺少删除回访录音权限';
|
||||||
|
}
|
||||||
|
if ((int) $info['root'] !== 1 && (int) ($task['actor_id'] ?? 0) !== $actor) {
|
||||||
|
return '仅上传者或超级管理员可删除录音';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
public static function canDaily(int $actor, array $info): bool
|
public static function canDaily(int $actor, array $info): bool
|
||||||
{
|
{
|
||||||
$info = self::actor($actor);
|
$info = self::actor($actor);
|
||||||
|
|||||||
@@ -273,9 +273,8 @@ final class FollowupAudioStore
|
|||||||
$task = self::lockTask($taskId);
|
$task = self::lockTask($taskId);
|
||||||
$info = FollowupAudioAccess::actor($actor);
|
$info = FollowupAudioAccess::actor($actor);
|
||||||
FollowupAudioAccess::task($taskId, $actor, $info);
|
FollowupAudioAccess::task($taskId, $actor, $info);
|
||||||
if ((int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor) {
|
$permissionReason = FollowupAudioAccess::deleteReason($task, $actor);
|
||||||
throw new DomainException('仅上传者或超级管理员可删除录音');
|
if ($permissionReason !== '') { throw new DomainException($permissionReason); }
|
||||||
}
|
|
||||||
if ((int) $task['version'] !== $version) { throw new DomainException('FOLLOWUP_AUDIO_VERSION_CONFLICT'); }
|
if ((int) $task['version'] !== $version) { throw new DomainException('FOLLOWUP_AUDIO_VERSION_CONFLICT'); }
|
||||||
$reason = self::deleteBlockReason($task);
|
$reason = self::deleteBlockReason($task);
|
||||||
if ($reason !== '') { throw new DomainException($reason); }
|
if ($reason !== '') { throw new DomainException($reason); }
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 删除回访录音:按钮权限,与各入口的“回访录音”同层级。
|
||||||
|
-- 不复制任何角色授权;非超级管理员仍限本人上传且未采用的可删除任务。
|
||||||
|
INSERT INTO zyt_system_menu (
|
||||||
|
pid, type, name, icon, sort, perms, paths, component,
|
||||||
|
selected, params, is_cache, is_show, is_disable, create_time, update_time
|
||||||
|
)
|
||||||
|
SELECT r.pid, 'A', '删除回访录音', '', GREATEST(CAST(MAX(r.sort) AS SIGNED) - 1, 0),
|
||||||
|
'tcm.followupAudio/delete', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
|
||||||
|
FROM zyt_system_menu AS r
|
||||||
|
WHERE r.perms = 'tcm.diagnosis/followupAudio' AND r.type = 'A'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM zyt_system_menu AS d
|
||||||
|
WHERE d.pid = r.pid AND d.perms = 'tcm.followupAudio/delete'
|
||||||
|
)
|
||||||
|
GROUP BY r.pid;
|
||||||
@@ -99,9 +99,9 @@ try {
|
|||||||
$pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_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('CREATE TABLE zyt_admin_jobs(admin_id INT,jobs_id INT)');
|
||||||
$pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY,perms VARCHAR(100),is_disable INT)');
|
$pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY,perms VARCHAR(100),is_disable INT)');
|
||||||
$pdo->exec("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',0),(4,'tcm.diagnosis/followupAudio',0)");
|
$pdo->exec("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',0),(4,'tcm.diagnosis/followupAudio',0),(5,'tcm.followupAudio/delete',0)");
|
||||||
$pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT)');
|
$pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT)');
|
||||||
$pdo->exec('INSERT INTO zyt_system_role_menu VALUES(2,1),(2,2),(2,3),(2,4),(5,1),(5,3),(5,4)');
|
$pdo->exec('INSERT INTO zyt_system_role_menu VALUES(2,1),(2,2),(2,3),(2,4),(2,5),(5,1),(5,3),(5,4),(5,5)');
|
||||||
$pdo->exec('CREATE TABLE zyt_doctor_appointment(id INT PRIMARY KEY,patient_id INT,doctor_id INT,status INT)');
|
$pdo->exec('CREATE TABLE zyt_doctor_appointment(id INT PRIMARY KEY,patient_id INT,doctor_id INT,status INT)');
|
||||||
$pdo->exec('CREATE TABLE zyt_dict_data(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(50),value VARCHAR(50),type_value VARCHAR(50),status INT,sort INT)');
|
$pdo->exec('CREATE TABLE zyt_dict_data(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(50),value VARCHAR(50),type_value VARCHAR(50),status INT,sort INT)');
|
||||||
$pdo->exec("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)");
|
$pdo->exec("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)");
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** Real controller, middleware and current RBAC reads; disposable MySQL 23319 only. */
|
||||||
|
require __DIR__ . '/fixtures/followup_audio/database.php';
|
||||||
|
|
||||||
|
use app\adminapi\controller\tcm\FollowupAudioController;
|
||||||
|
use app\adminapi\http\middleware\AuthMiddleware;
|
||||||
|
use app\common\service\followupaudio\FollowupAudioStore as Store;
|
||||||
|
use think\Container;
|
||||||
|
use think\facade\Db;
|
||||||
|
|
||||||
|
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23319
|
||||||
|
|| getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
|
||||||
|
throw new RuntimeException('Delete permission test requires disposable MySQL 23319');
|
||||||
|
}
|
||||||
|
// Immutable pre-change snapshots allow the identical regression test to prove the original defect.
|
||||||
|
foreach (['ACCESS', 'STORE', 'LOGIC', 'MIDDLEWARE'] as $component) {
|
||||||
|
$source = getenv('FOLLOWUP_AUDIO_TEST_' . $component . '_SOURCE');
|
||||||
|
if (is_string($source) && $source !== '') {
|
||||||
|
if (!is_file($source)) { throw new RuntimeException('Missing explicit source override'); }
|
||||||
|
require $source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final class FollowupDeletePermissionCacheDouble
|
||||||
|
{
|
||||||
|
public static array $uris = [];
|
||||||
|
public function __construct($adminId = '') {}
|
||||||
|
public function getAdminUri(): array { return self::$uris; }
|
||||||
|
public function getAllUri(): array { throw new RuntimeException('Recording routes must fail closed'); }
|
||||||
|
}
|
||||||
|
if (!class_alias(FollowupDeletePermissionCacheDouble::class, 'app\\common\\cache\\AdminAuthCache')) {
|
||||||
|
throw new RuntimeException('Cache double must be installed before middleware autoload');
|
||||||
|
}
|
||||||
|
function followupDeletePermissionRequest(string $action, array $body, int $actor = 2, string $method = 'GET'): array
|
||||||
|
{
|
||||||
|
$request = (new app\Request())->withServer(['REQUEST_METHOD' => $method]);
|
||||||
|
$request = $method === 'GET' ? $request->withGet($body) : $request->withPost($body);
|
||||||
|
$controller = (new ReflectionClass(FollowupAudioController::class))->newInstanceWithoutConstructor();
|
||||||
|
foreach (['request' => $request, 'adminId' => $actor, 'adminInfo' => ['root' => 1]] as $name => $value) {
|
||||||
|
$property = new ReflectionProperty($controller, $name);
|
||||||
|
$property->setAccessible(true); $property->setValue($controller, $value);
|
||||||
|
}
|
||||||
|
return $controller->$action()->getData();
|
||||||
|
}
|
||||||
|
$f = followupAudioTestDatabase(['preview_only' => false]);
|
||||||
|
$checks = 0; $failures = [];
|
||||||
|
$expect = static function (bool $ok, string $label) use (&$checks, &$failures): void {
|
||||||
|
$checks++;
|
||||||
|
if (!$ok) { $failures[] = $label; fwrite(STDERR, 'FAIL: ' . $label . PHP_EOL); }
|
||||||
|
};
|
||||||
|
$success = static function (array $response): array {
|
||||||
|
if ($response['code'] !== 1) { throw new RuntimeException(json_encode($response, JSON_UNESCAPED_UNICODE)); }
|
||||||
|
return $response['data'];
|
||||||
|
};
|
||||||
|
$body = static fn (array $task): array => ['id' => (int) $task['id'], 'version' => (int) $task['version']];
|
||||||
|
$make = static function (int $owner = 2) use ($f): array {
|
||||||
|
static $serial = 0; $serial++;
|
||||||
|
$pcm = str_repeat(pack('v', 2000 + $serial), 16000);
|
||||||
|
$path = $f['private'] . '/permission-' . $serial . '.wav';
|
||||||
|
file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt '
|
||||||
|
. pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($pcm)) . $pcm);
|
||||||
|
chmod($path, 0600);
|
||||||
|
$upload = followupAudioTestUpload($f, $path, 'synthetic.wav');
|
||||||
|
$created = Store::create($upload, '2026-09-29 10:00:00', 'qwen', 1, $f['actor']);
|
||||||
|
$claim = Store::claim();
|
||||||
|
if ((int) $claim['id'] !== (int) $created['id'] || !Store::complete((int) $created['id'], $claim['lease_token'], [
|
||||||
|
'summary' => 'Synthetic', 'transcript' => '今天合成症状', 'uncertainties' => [],
|
||||||
|
'items' => [['kind' => 'diagnosis', 'values' => ['symptoms' => '合成症状'], 'record_date' => '2026-09-29',
|
||||||
|
'date_text' => '今天', 'evidence' => [['text' => '今天合成症状']]]],
|
||||||
|
])) { throw new RuntimeException('Synthetic completion failed'); }
|
||||||
|
// Explicit synthetic ownership, with patient/diagnosis unchanged; never call an upstream model.
|
||||||
|
Db::name('followup_audio_task')->where('id', $created['id'])->update(['actor_id' => $owner]);
|
||||||
|
Db::name('followup_audio_upload')->where('id', $upload['id'])->update(['actor_id' => $owner]);
|
||||||
|
return Store::task((int) $created['id']);
|
||||||
|
};
|
||||||
|
$grant = static function (bool $enabled): void {
|
||||||
|
Db::name('system_role_menu')->where('role_id', 2)->where('menu_id', 5)->delete();
|
||||||
|
if ($enabled) { Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => 5]); }
|
||||||
|
};
|
||||||
|
$assertDenied = static function (array $task, string $label) use ($expect, $success, $body): void {
|
||||||
|
$detail = $success(followupDeletePermissionRequest('detail', ['id' => $task['id']]));
|
||||||
|
$expect($detail['can_delete'] === false && $detail['delete_block_reason'] !== '', $label . ': detail button disabled');
|
||||||
|
$list = $success(followupDeletePermissionRequest('lists', ['diagnosis_id' => 1]));
|
||||||
|
$rows = array_values(array_filter($list['items'], static fn (array $row): bool => (int) $row['id'] === (int) $task['id']));
|
||||||
|
$expect(count($rows) === 1 && $rows[0]['can_delete'] === false, $label . ': list button disabled');
|
||||||
|
$response = followupDeletePermissionRequest('delete', $body($task), 2, 'POST');
|
||||||
|
$expect($response['code'] === 0 && $response['msg'] === $detail['delete_block_reason'], $label . ': actual controller rejects with same reason');
|
||||||
|
$row = Db::name('followup_audio_task')->where('id', $task['id'])->find();
|
||||||
|
$expect($row['status'] === $task['status'] && (int) $row['active_slot'] === 0
|
||||||
|
&& (int) $row['version'] === (int) $task['version'], $label . ': rejected task unchanged');
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (!Db::name('system_menu')->where('id', 5)->find()) {
|
||||||
|
Db::name('system_menu')->insert(['id' => 5, 'perms' => 'tcm.followupAudio/delete', 'is_disable' => 0]);
|
||||||
|
}
|
||||||
|
$grant(false);
|
||||||
|
$assertDenied($make(), 'own upload without delete permission');
|
||||||
|
$grant(true);
|
||||||
|
$own = $make();
|
||||||
|
$detail = $success(followupDeletePermissionRequest('detail', ['id' => $own['id']]));
|
||||||
|
$expect($detail['can_delete'] === true && $detail['delete_block_reason'] === '', 'grant enables own completed task');
|
||||||
|
$deleted = $success(followupDeletePermissionRequest('delete', $body($own), 2, 'POST'));
|
||||||
|
$expect($deleted['status'] === 'deleted', 'grant allows own actual delete');
|
||||||
|
$assertDenied($make(1), 'delete grant does not allow another uploader on own patient');
|
||||||
|
|
||||||
|
// Load the grant in a repeatable-read snapshot, then revoke from a separate connection.
|
||||||
|
$revoked = $make();
|
||||||
|
$expect($success(followupDeletePermissionRequest('detail', ['id' => $revoked['id']]))['can_delete'] === true, 'grant visible before revocation');
|
||||||
|
Db::startTrans(); Db::name('system_role_menu')->select();
|
||||||
|
$f['pdo']->exec('DELETE FROM zyt_system_role_menu WHERE role_id=2 AND menu_id=5');
|
||||||
|
$fresh = $success(followupDeletePermissionRequest('detail', ['id' => $revoked['id']]));
|
||||||
|
$expect($fresh['can_delete'] === false, 'revocation beats repeatable-read snapshot and stale client');
|
||||||
|
Db::rollback();
|
||||||
|
FollowupDeletePermissionCacheDouble::$uris = ['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio', 'tcm.followupAudio/delete'];
|
||||||
|
$assertDenied($revoked, 'revoked grant despite positive cached URI');
|
||||||
|
$grant(true);
|
||||||
|
$disabled = $make();
|
||||||
|
Db::startTrans(); Db::name('system_menu')->select();
|
||||||
|
$f['pdo']->exec('UPDATE zyt_system_menu SET is_disable=1 WHERE id=5');
|
||||||
|
$fresh = $success(followupDeletePermissionRequest('detail', ['id' => $disabled['id']]));
|
||||||
|
$expect($fresh['can_delete'] === false, 'disabled menu beats repeatable-read snapshot');
|
||||||
|
Db::rollback();
|
||||||
|
$assertDenied($disabled, 'disabled delete menu despite role grant');
|
||||||
|
Db::name('system_menu')->where('id', 5)->update(['is_disable' => 0]);
|
||||||
|
if ((int) Db::name('followup_audio_task')->where('id', $disabled['id'])->value('active_slot') === 0) {
|
||||||
|
$expect($success(followupDeletePermissionRequest('detail', ['id' => $disabled['id']]))['can_delete'] === true, 'reenabling menu restores current permission');
|
||||||
|
$success(followupDeletePermissionRequest('delete', $body($disabled), 2, 'POST'));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['applied', 'queued', 'running', 'needs_reconciliation'] as $status) {
|
||||||
|
$task = $make();
|
||||||
|
Db::name('followup_audio_task')->where('id', $task['id'])->update(['status' => $status]);
|
||||||
|
$task['status'] = $status;
|
||||||
|
$assertDenied($task, 'grant does not bypass status ' . $status);
|
||||||
|
Db::name('followup_audio_task')->where('id', $task['id'])->update(['status' => 'failed']);
|
||||||
|
}
|
||||||
|
$root = $make(2); $grant(false);
|
||||||
|
$rootDetail = $success(followupDeletePermissionRequest('detail', ['id' => $root['id']], 1));
|
||||||
|
$expect($rootDetail['can_delete'] === true, 'current root retains existing exception without role grant');
|
||||||
|
$expect($success(followupDeletePermissionRequest('delete', $body($root), 1, 'POST'))['status'] === 'deleted', 'root actual delete retained');
|
||||||
|
$blockedActor = $make(); $grant(true);
|
||||||
|
Db::name('admin')->where('id', 2)->update(['disable' => 1]);
|
||||||
|
$result = followupDeletePermissionRequest('delete', $body($blockedActor), 2, 'POST');
|
||||||
|
$expect($result['code'] === 0 && $result['msg'] === '账号已停用或无权访问', 'disabled actor rejected despite injected root and cached grants');
|
||||||
|
Db::name('admin')->where('id', 2)->update(['disable' => 0]);
|
||||||
|
|
||||||
|
$request = (new app\Request())->withServer(['REMOTE_ADDR' => '127.0.0.1', 'REQUEST_METHOD' => 'GET']);
|
||||||
|
Container::getInstance()->instance('request', $request);
|
||||||
|
Container::getInstance()->instance('think\\Request', $request);
|
||||||
|
$middleware = new AuthMiddleware();
|
||||||
|
$otherActions = ['capabilities', 'uploadSession', 'uploadChunk', 'uploadComplete', 'create', 'lists',
|
||||||
|
'detail', 'saveDraft', 'apply', 'retry', 'audio', 'reextract'];
|
||||||
|
foreach (array_merge($otherActions, ['delete']) as $action) {
|
||||||
|
for ($mask = 0; $mask < 8; $mask++) {
|
||||||
|
FollowupDeletePermissionCacheDouble::$uris = [];
|
||||||
|
foreach (['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio', 'tcm.followupAudio/delete'] as $bit => $uri) {
|
||||||
|
if (($mask & (1 << $bit)) !== 0) { FollowupDeletePermissionCacheDouble::$uris[] = $uri; }
|
||||||
|
}
|
||||||
|
$route = new class($action, $request->ip()) {
|
||||||
|
public array $adminInfo;
|
||||||
|
public object $controllerObject;
|
||||||
|
public function __construct(private string $verb, string $ip) {
|
||||||
|
$this->adminInfo = ['admin_id' => 2, 'root' => 0, 'login_ip' => $ip, 'work_wechat_userid' => 'synthetic'];
|
||||||
|
$this->controllerObject = new class { public function isNotNeedLogin(): bool { return false; } };
|
||||||
|
}
|
||||||
|
public function controller(): string { return 'tcm.FollowupAudio'; }
|
||||||
|
public function action(): string { return $this->verb; }
|
||||||
|
public function param($name, $default = null) { return $default; }
|
||||||
|
};
|
||||||
|
$actual = $middleware->handle($route, static fn () => 'ALLOWED');
|
||||||
|
$allowed = ($mask & 3) === 3 && ($action !== 'delete' || ($mask & 4) !== 0);
|
||||||
|
$expect(($actual === 'ALLOWED') === $allowed, 'middleware ' . $action . ' permission bitmask ' . $mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real menu migration: mirror every existing recording parent, remain idempotent, never grant roles.
|
||||||
|
$pdo = $f['pdo'];
|
||||||
|
$pdo->exec("ALTER TABLE zyt_system_menu MODIFY id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
ADD pid INT NOT NULL DEFAULT 0, ADD type CHAR(1) NOT NULL DEFAULT 'A', ADD name VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
ADD icon VARCHAR(100) NOT NULL DEFAULT '', ADD sort INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
ADD paths VARCHAR(200) NOT NULL DEFAULT '', ADD component VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
ADD selected VARCHAR(200) NOT NULL DEFAULT '', ADD params VARCHAR(200) NOT NULL DEFAULT '',
|
||||||
|
ADD is_cache INT NOT NULL DEFAULT 0, ADD is_show INT NOT NULL DEFAULT 1,
|
||||||
|
ADD create_time INT NOT NULL DEFAULT 0, ADD update_time INT NOT NULL DEFAULT 0");
|
||||||
|
$pdo->exec("UPDATE zyt_system_menu SET pid=182,name='回访录音',sort=58 WHERE id=4");
|
||||||
|
$pdo->exec("UPDATE zyt_system_menu SET pid=182,name='删除回访录音',sort=57 WHERE id=5");
|
||||||
|
$pdo->exec("INSERT INTO zyt_system_menu(id,pid,type,name,sort,perms,is_disable) VALUES
|
||||||
|
(191,0,'C','Synthetic second page',0,'firstvisit.myPatient/lists',0),
|
||||||
|
(192,191,'A','回访录音',0,'tcm.diagnosis/followupAudio',0)");
|
||||||
|
$grants = $pdo->query('SELECT role_id,menu_id FROM zyt_system_role_menu ORDER BY role_id,menu_id')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$migrationFile = getenv('FOLLOWUP_AUDIO_TEST_DELETE_MENU_MIGRATION') ?: dirname(__DIR__) . '/sql/2026_10_12_followup_audio_delete_permission.sql';
|
||||||
|
if (!is_file($migrationFile)) { throw new RuntimeException('Delete permission migration missing'); }
|
||||||
|
$migration = preg_replace('/^\s*(?:--|#).*$/m', '', file_get_contents($migrationFile));
|
||||||
|
$first = null;
|
||||||
|
for ($run = 0; $run < 2; $run++) {
|
||||||
|
foreach (explode(';', $migration) as $sql) {
|
||||||
|
if (trim($sql) !== '') { $statement = $pdo->query($sql); $statement->closeCursor(); }
|
||||||
|
}
|
||||||
|
$menus = $pdo->query("SELECT pid,type,name,sort,perms,is_disable,is_show FROM zyt_system_menu
|
||||||
|
WHERE LOWER(perms)='tcm.followupaudio/delete' ORDER BY pid,id")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$expect(count($menus) === 2 && array_map('intval', array_column($menus, 'pid')) === [182, 191], 'migration mirrors recording menu parent set without duplicates');
|
||||||
|
foreach ($menus as $menu) {
|
||||||
|
$expect($menu['type'] === 'A' && $menu['name'] === '删除回访录音' && (int) $menu['is_disable'] === 0
|
||||||
|
&& (int) $menu['is_show'] === 1, 'delete node assignable and correctly labeled');
|
||||||
|
}
|
||||||
|
if ($first !== null) { $expect($menus === $first, 'second migration is idempotent'); }
|
||||||
|
$first = $menus;
|
||||||
|
$expect($pdo->query('SELECT role_id,menu_id FROM zyt_system_role_menu ORDER BY role_id,menu_id')->fetchAll(PDO::FETCH_ASSOC) === $grants,
|
||||||
|
'migration changes no role grants');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { Db::rollback(); } catch (Throwable $e) {}
|
||||||
|
followupAudioTestDatabaseCleanup($f);
|
||||||
|
}
|
||||||
|
echo 'FOLLOWUP_AUDIO_DELETE_PERMISSION assertions=' . $checks . ' failures=' . count($failures)
|
||||||
|
. ' ' . ($failures === [] ? 'PASS' : 'FAIL')
|
||||||
|
. ' mysql23319=1 controller=1 middleware_actions=13 nondelete_actions=12 fresh_rbac=1 owner_scope=1 state_guards=1 migration_idempotent=1 role_grants_unchanged=1 upstream_calls=0' . PHP_EOL;
|
||||||
|
exit($failures === [] ? 0 : 1);
|
||||||
@@ -23,6 +23,8 @@ expectEndpoint(str_contains($middleware, "in_array('tcm.diagnosis/edit', \$uris,
|
|||||||
expectEndpoint(str_contains($access, "Db::name('admin')->where('id', \$actor)->whereNull('delete_time')->where('disable', 0)->lock(true)"), 'active actor refresh');
|
expectEndpoint(str_contains($access, "Db::name('admin')->where('id', \$actor)->whereNull('delete_time')->where('disable', 0)->lock(true)"), 'active actor refresh');
|
||||||
expectEndpoint(str_contains($access, 'MyPatientLogic::applyScope($query, $actor, $info)') && str_contains($access, '$query->lock(true)->find()'), 'row management guard');
|
expectEndpoint(str_contains($access, 'MyPatientLogic::applyScope($query, $actor, $info)') && str_contains($access, '$query->lock(true)->find()'), 'row management guard');
|
||||||
expectEndpoint(str_contains($access, 'tcm.diagnosis/dailyRecord'), 'daily permission guard');
|
expectEndpoint(str_contains($access, 'tcm.diagnosis/dailyRecord'), 'daily permission guard');
|
||||||
|
expectEndpoint(str_contains($middleware, "in_array('tcm.followupaudio/delete', \$uris, true)"), 'delete has an independent route permission');
|
||||||
|
expectEndpoint(str_contains($access, "self::allowed(\$actor, \$info, 'tcm.followupAudio/delete')"), 'delete permission also checked against current DB');
|
||||||
expectEndpoint(str_contains($middleware, "in_array('tcm.diagnosis/followupaudio', \$uris, true)"), 'independent audio route permission');
|
expectEndpoint(str_contains($middleware, "in_array('tcm.diagnosis/followupaudio', \$uris, true)"), 'independent audio route permission');
|
||||||
expectEndpoint(str_contains($access, "self::allowed(\$actor, \$info, 'tcm.diagnosis/followupAudio')"), 'fresh independent audio permission at every service entry');
|
expectEndpoint(str_contains($access, "self::allowed(\$actor, \$info, 'tcm.diagnosis/followupAudio')"), 'fresh independent audio permission at every service entry');
|
||||||
expectEndpoint(str_contains($access, "\$task['patient_id'] !== (int) (\$diagnosis['patient_id']"), 'historical patient rebind guard');
|
expectEndpoint(str_contains($access, "\$task['patient_id'] !== (int) (\$diagnosis['patient_id']"), 'historical patient rebind guard');
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ try {
|
|||||||
'detail', 'saveDraft', 'apply', 'retry', 'audio', 'delete', 'reextract'];
|
'detail', 'saveDraft', 'apply', 'retry', 'audio', 'delete', 'reextract'];
|
||||||
foreach ($actions as $action) {
|
foreach ($actions as $action) {
|
||||||
foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/followupAudio'],
|
foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/followupAudio'],
|
||||||
['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio']] as $uris) {
|
['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio'],
|
||||||
|
['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio', 'tcm.followupAudio/delete']] as $uris) {
|
||||||
FollowupPermissionAuthCacheDouble::$uris = $uris;
|
FollowupPermissionAuthCacheDouble::$uris = $uris;
|
||||||
$route = new class($action, $request->ip()) {
|
$route = new class($action, $request->ip()) {
|
||||||
public array $adminInfo;
|
public array $adminInfo;
|
||||||
@@ -93,7 +94,7 @@ try {
|
|||||||
public function param($name, $default = null) { return $default; }
|
public function param($name, $default = null) { return $default; }
|
||||||
};
|
};
|
||||||
$result = $middleware->handle($route, static fn () => 'ALLOWED');
|
$result = $middleware->handle($route, static fn () => 'ALLOWED');
|
||||||
$expected = count($uris) === 2;
|
$expected = count($uris) >= 2 && ($action !== 'delete' || in_array('tcm.followupAudio/delete', $uris, true));
|
||||||
$expect(($result === 'ALLOWED') === $expected, 'middleware ' . $action . ' permission set ' . json_encode($uris));
|
$expect(($result === 'ALLOWED') === $expected, 'middleware ' . $action . ' permission set ' . json_encode($uris));
|
||||||
if (!$expected) { $expect($result->getData()['code'] === 0, 'middleware failure envelope'); }
|
if (!$expected) { $expect($result->getData()['code'] === 0, 'middleware failure envelope'); }
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -47,9 +47,9 @@ function followupAudioTestDatabase(array $settings = []): array
|
|||||||
$pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_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('CREATE TABLE zyt_admin_jobs(admin_id INT,jobs_id INT)');
|
||||||
$pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY,perms VARCHAR(100),is_disable INT)');
|
$pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY,perms VARCHAR(100),is_disable INT)');
|
||||||
$pdo->exec("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',0),(4,'tcm.diagnosis/followupAudio',0)");
|
$pdo->exec("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',0),(4,'tcm.diagnosis/followupAudio',0),(5,'tcm.followupAudio/delete',0)");
|
||||||
$pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT)');
|
$pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT)');
|
||||||
$pdo->exec('INSERT INTO zyt_system_role_menu VALUES(2,1),(2,2),(2,3),(2,4),(5,1),(5,3),(5,4)');
|
$pdo->exec('INSERT INTO zyt_system_role_menu VALUES(2,1),(2,2),(2,3),(2,4),(2,5),(5,1),(5,3),(5,4),(5,5)');
|
||||||
$pdo->exec('CREATE TABLE zyt_doctor_appointment(id INT PRIMARY KEY,patient_id INT,doctor_id INT,status INT)');
|
$pdo->exec('CREATE TABLE zyt_doctor_appointment(id INT PRIMARY KEY,patient_id INT,doctor_id INT,status INT)');
|
||||||
$pdo->exec('CREATE TABLE zyt_dict_data(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(50),value VARCHAR(50),type_value VARCHAR(50),status INT,sort INT)');
|
$pdo->exec('CREATE TABLE zyt_dict_data(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(50),value VARCHAR(50),type_value VARCHAR(50),status INT,sort INT)');
|
||||||
$pdo->exec("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)");
|
$pdo->exec("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)");
|
||||||
|
|||||||
Reference in New Issue
Block a user