feat(followup-audio): add independent assignable recording permission

This commit is contained in:
2026-10-12 15:14:19 +08:00
parent 86041042b9
commit 8f7544c1ad
10 changed files with 299 additions and 18 deletions
@@ -86,7 +86,8 @@ class AuthMiddleware
'create', 'lists', 'detail', 'savedraft', 'apply', 'retry', 'audio', 'delete', 'reextract'];
$action = substr($accessUri, strlen('tcm.followupaudio/'));
$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)) {
return $next($request); // The service also checks the current patient scope and daily permissions.
}
return JsonService::fail('权限不足,无法访问或操作');
@@ -50,6 +50,7 @@ final class FollowupAudioAccess
$row = Db::name('tcm_diagnosis')->where('id', $diagnosisId)->whereNull('delete_time')->lock(true)->find();
$info = self::actor($actor);
if (!$row || !self::allowed($actor, $info, 'tcm.diagnosis/edit')
|| !self::allowed($actor, $info, 'tcm.diagnosis/followupAudio')
|| ($daily && !self::allowed($actor, $info, 'tcm.diagnosis/dailyRecord'))) {
throw new DomainException('诊单不存在或无权操作');
}
@@ -0,0 +1,16 @@
-- 回访录音统一按钮权限。与各入口的“日常记录”同层级,重复执行不新增重复节点。
-- 只创建菜单节点,不复制日常记录的角色授权;管理员后续手动分配。
-- 上传/转写/审核/播放仍需诊单编辑及患者范围;写入日常记录另需dailyRecord。
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 d.pid, 'A', '回访录音', '', GREATEST(MAX(d.sort) - 1, 0),
'tcm.diagnosis/followupAudio', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM zyt_system_menu AS d
WHERE d.perms = 'tcm.diagnosis/dailyRecord' AND d.type = 'A'
AND NOT EXISTS (
SELECT 1 FROM zyt_system_menu AS a
WHERE a.pid = d.pid AND a.perms = 'tcm.diagnosis/followupAudio'
)
GROUP BY d.pid;
+2 -1
View File
@@ -54,8 +54,9 @@ namespace {
$db::name('system_menu')->insertAll([
['id' => 1, 'perms' => 'tcm.diagnosis/edit', 'is_disable' => 0],
['id' => 2, 'perms' => 'tcm.diagnosis/dailyRecord', 'is_disable' => 0],
['id' => 3, 'perms' => 'tcm.diagnosis/followupAudio', 'is_disable' => 0],
]);
$db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => 1]);
$db::name('system_role_menu')->insertAll([['role_id' => 2, 'menu_id' => 1], ['role_id' => 2, 'menu_id' => 3]]);
$db::name('tcm_diagnosis')->insertAll([
['id' => 91, 'patient_id' => 101, 'assistant_id' => 8, 'status' => 1],
['id' => 92, 'patient_id' => 102, 'assistant_id' => 9, 'status' => 1],
+2 -2
View File
@@ -99,9 +99,9 @@ try {
$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_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)");
$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('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),(5,1),(5,3)');
$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('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("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)");
@@ -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, '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($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, "\$task['patient_id'] !== (int) (\$diagnosis['patient_id']"), 'historical patient rebind guard');
expectEndpoint(str_contains($controller, 'array_diff(array_keys($params), $allowed)'), 'request field allowlist');
expectEndpoint(str_contains($middleware, "'apply', 'retry', 'audio', 'delete'"), 'delete uses explicit edit permission route group');
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
/** Real middleware + current database authorization, using only an explicitly disposable local DB. */
require __DIR__ . '/fixtures/followup_audio/database.php';
use app\adminapi\controller\tcm\FollowupAudioController;
use app\adminapi\http\middleware\AuthMiddleware;
use app\common\service\followupaudio\FollowupAudioAccess as Access;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use think\Container;
use think\facade\Db;
// Optional immutable source snapshots prove that the same negative tests catch the old behavior.
foreach (['FOLLOWUP_AUDIO_TEST_ACCESS_SOURCE', 'FOLLOWUP_AUDIO_TEST_MIDDLEWARE_SOURCE'] as $name) {
$source = getenv($name);
if (is_string($source) && $source !== '') {
if (!is_file($source)) { throw new RuntimeException('Missing explicit test source override'); }
require $source;
}
}
final class FollowupPermissionAuthCacheDouble
{
public static array $uris = [];
public function __construct($adminId = '') {}
public function getAdminUri(): array { return self::$uris; }
public function getAllUri(): array { throw new RuntimeException('Audio routes must not use unregistered-route bypass'); }
}
if (!class_alias(FollowupPermissionAuthCacheDouble::class, 'app\\common\\cache\\AdminAuthCache')) {
throw new RuntimeException('Permission cache double must be installed before middleware autoload');
}
function followupPermissionController(string $action, array $body, int $actor, 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]);
set_exception_handler(static function (Throwable $e): void {
fwrite(STDERR, $e->getMessage() . PHP_EOL); exit(1);
});
$checks = 0;
$expect = static function (bool $ok, string $label) use (&$checks): void {
if (!$ok) { throw new RuntimeException('FAIL: ' . $label); }
$checks++;
};
$deny = static function (callable $call, string $label) use ($expect): void {
try { $call(); } catch (DomainException $e) {
$expect($e->getMessage() === '诊单不存在或无权操作', $label . ' returns authorization denial'); return;
}
$expect(false, $label);
};
$pdo = $f['pdo'];
try {
// Independent of shared-fixture timing/default grants: configure all permission cases explicitly.
if (!Db::name('system_menu')->where('id', 4)->find()) {
Db::name('system_menu')->insert(['id' => 4, 'perms' => 'tcm.diagnosis/followupAudio', 'is_disable' => 0]);
}
$setMenus = static function (array $ids): void {
Db::name('system_role_menu')->where('role_id', 2)->delete();
foreach ($ids as $id) { Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => $id]); }
};
$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();
$actions = ['capabilities', 'uploadSession', 'uploadChunk', 'uploadComplete', 'create', 'lists',
'detail', 'saveDraft', 'apply', 'retry', 'audio', 'delete', 'reextract'];
foreach ($actions as $action) {
foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/followupAudio'],
['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio']] as $uris) {
FollowupPermissionAuthCacheDouble::$uris = $uris;
$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; }
};
$result = $middleware->handle($route, static fn () => 'ALLOWED');
$expected = count($uris) === 2;
$expect(($result === 'ALLOWED') === $expected, 'middleware ' . $action . ' permission set ' . json_encode($uris));
if (!$expected) { $expect($result->getData()['code'] === 0, 'middleware failure envelope'); }
}
}
$unknown = clone $route;
$verb = new ReflectionProperty($unknown, 'verb'); $verb->setAccessible(true); $verb->setValue($unknown, 'unknownFutureAction');
$expect($middleware->handle($unknown, static fn () => 'ALLOWED') !== 'ALLOWED', 'unknown audio route fails closed');
$route->adminInfo['root'] = 1;
FollowupPermissionAuthCacheDouble::$uris = [];
$expect($middleware->handle($route, static fn () => 'ALLOWED') === 'ALLOWED', 'middleware root keeps existing bypass');
foreach ([[], [1], [4], [1, 2]] as $menus) {
$setMenus($menus);
$deny(fn () => Access::diagnosis(1, 2, ['root' => 1]), 'current DB rejects missing permission ' . json_encode($menus));
}
$setMenus([1, 4]);
$expect((int) Access::diagnosis(1, 2, ['root' => 1])['id'] === 1, 'edit and recording grant allows own patient');
$expect(!Access::canDaily(2, ['root' => 1]), 'recording grant does not grant daily permission');
$deny(fn () => Access::diagnosis(1, 2, [], true), 'daily write requires separate daily permission');
$setMenus([1, 2, 4]);
$expect((int) Access::diagnosis(1, 2, [], true)['id'] === 1, 'all three permissions allow daily context');
$deny(fn () => Access::diagnosis(2, 2, ['root' => 1]), 'forged root cannot bypass patient scope');
$expect((int) Access::diagnosis(2, 1, [])['id'] === 2, 'current root remains authorized without role grants');
$caps = followupPermissionController('capabilities', ['diagnosis_id' => 1], 2);
$expect($caps['code'] === 1 && $caps['data']['can_upload'] === true, 'real controller permits current recording grant');
$upload = Upload::createSession(1, 'synthetic.wav', 256, 2, ['root' => 1]);
$expect(isset($upload['upload_id']), 'real upload session permitted before revocation');
// A repeatable-read snapshot must not retain a grant revoked through another DB connection.
Db::startTrans(); Db::name('system_role_menu')->select();
$pdo->exec('DELETE FROM zyt_system_role_menu WHERE role_id=2 AND menu_id=4');
$deny(fn () => Access::diagnosis(1, 2, ['root' => 1]), 'recording revocation beats repeatable-read snapshot');
Db::rollback();
$caps = followupPermissionController('capabilities', ['diagnosis_id' => 1], 2);
$expect($caps['code'] === 0 && $caps['msg'] === '诊单不存在或无权操作', 'real controller denies revoked grant despite injected root');
$deniedUpload = followupPermissionController('uploadSession', ['diagnosis_id' => 1, 'file_name' => 'synthetic.wav', 'total_bytes' => 256], 2, 'POST');
$expect($deniedUpload['code'] === 0 && $deniedUpload['msg'] === '诊单不存在或无权操作', 'real controller upload denied after revocation');
$deny(fn () => Upload::owned($upload['upload_id'], 2, ['root' => 1]), 'existing upload no longer usable after revocation');
$setMenus([1, 2, 4]);
Db::name('system_menu')->where('id', 4)->update(['is_disable' => 1]);
$deny(fn () => Access::diagnosis(1, 2, []), 'disabled menu denies access despite role grant');
Db::name('system_menu')->where('id', 4)->update(['is_disable' => 0]);
$expect((int) Access::diagnosis(1, 2, [])['id'] === 1, 'reenabled menu restores authorized access');
Db::name('admin')->where('id', 2)->update(['disable' => 1]);
try { Access::diagnosis(1, 2, ['root' => 1]); $expect(false, 'disabled actor'); }
catch (DomainException $e) { $expect($e->getMessage() === '账号已停用或无权访问', 'disabled actor stays denied'); }
Db::name('admin')->where('id', 2)->update(['disable' => 0]);
// Migration runs against realistic menu columns in this database only; no shared fixture changes.
$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 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");
// The old fixture recording node is deliberately outside the two targeted menu families.
$pdo->exec("UPDATE zyt_system_menu SET pid=182,name='日常记录',sort=59 WHERE id=2");
$pdo->exec("INSERT INTO zyt_system_menu (id,pid,type,name,sort,perms,is_disable) VALUES
(182,0,'C','Synthetic first diagnosis page',0,'tcm.diagnosis/lists',0),
(191,0,'C','Synthetic second diagnosis page',0,'firstvisit.myPatient/lists',0),
(192,191,'A','日常记录',59,'tcm.diagnosis/dailyRecord',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 = dirname(__DIR__) . '/sql/2026_10_12_followup_audio_permission.sql';
$migration = file_get_contents($migrationFile);
$expect(is_string($migration), 'permission migration exists');
$migration = preg_replace('/^\s*(?:--|#).*$/m', '', $migration);
$firstMenus = 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,perms,is_disable,is_show FROM zyt_system_menu
WHERE perms='tcm.diagnosis/followupAudio' AND pid IN (182,191) ORDER BY pid,id")->fetchAll(PDO::FETCH_ASSOC);
$expect(count($menus) === 2 && array_map('intval', array_column($menus, 'pid')) === [182, 191], 'migration creates exactly one recording node per daily parent');
foreach ($menus as $menu) {
$expect($menu['type'] === 'A' && $menu['name'] === '回访录音' && (int) $menu['is_disable'] === 0 && (int) $menu['is_show'] === 1, 'migration node is assignable');
}
if ($firstMenus !== null) { $expect($menus === $firstMenus, 'second migration preserves nodes'); }
$firstMenus = $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 does not automatically grant any role');
}
echo 'FOLLOWUP_AUDIO_PERMISSION assertions=' . $checks . ' PASS middleware_actions=13 current_db=1 controller=1 revoked_upload=1 migration_idempotent=1 role_grants_unchanged=1' . PHP_EOL;
} finally {
try { Db::rollback(); } catch (Throwable $e) {}
followupAudioTestDatabaseCleanup($f);
}
+2 -2
View File
@@ -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_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("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',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)");
$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),(5,1),(5,3)');
$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('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("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)");