@@ -862,6 +862,7 @@ const submitting = ref(false)
/** 拥有后手机号可正常编辑,失焦不再强制脱敏 */
const hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
+const hasFollowupAudioPermission = computed(() => hasPermission(['tcm.diagnosis/followupAudio']))
const patientBasicLocked = ref(false)
const canEditPatientBasicFromApi = ref(true)
const latestPrescriptionOrder = ref<{
@@ -1049,7 +1050,7 @@ async function loadFollowupCapabilities(): Promise {
if (followupCapabilitiesLoading.value) return
resetFollowupCapabilities()
const id = Number(formData.value.id)
- if (!visible.value || !id) return
+ if (!visible.value || !id || !hasFollowupAudioPermission.value) return
const generation = diagnosisGeneration
const request = new AbortController()
capabilitiesRequest = request
@@ -1073,8 +1074,9 @@ async function loadFollowupCapabilities(): Promise {
}
}
}
-watch([visible, () => formData.value.id], () => {
- // Invalidate synchronously so an old patient's capabilities never survive a switch.
+watch([visible, () => formData.value.id, hasFollowupAudioPermission], () => {
+ // Invalidate synchronously on patient switches and permission changes.
+ if (!hasFollowupAudioPermission.value && activeTab.value === 'followupAudio') activeTab.value = 'basic'
resetFollowupCapabilities()
void loadFollowupCapabilities()
}, { flush: 'sync' })
diff --git a/admin/tests/followup-audio.test.cjs b/admin/tests/followup-audio.test.cjs
index 14a593c08..40908127e 100644
--- a/admin/tests/followup-audio.test.cjs
+++ b/admin/tests/followup-audio.test.cjs
@@ -269,7 +269,7 @@ test('authenticated audio object URLs revoked on hide and stale blobs never crea
test('editor capability gate, dirty guard and successful apply hook wire diagnosis plus daily refresh', () => {
const source = fs.readFileSync(path.join(componentDir, '../edit.vue'), 'utf8')
- assert.match(source, / ({ default: () => userStore }))
const requests = [], warnings = [], hooks = {}, exposed = {}
const filename = path.join(componentDir, '../edit.vue')
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
@@ -1863,8 +1865,8 @@ function setupAudioEntry() {
if (name === '@/api/app') return { getDictData: async () => ({}) }
if (name === '@/api/followupAudio') return { followupAudioCapabilities: (id, signal) => { const pending = deferred(); requests.push({ id, signal, ...pending }); return pending.promise } }
if (name === '@/utils/feedback') return { default: { msgWarning: message => warnings.push(message) } }
- if (name === '@/utils/perm') return { hasPermission: () => true }
- if (name === '@/stores/modules/user') return { default: () => ({ userInfo: { id: 1 } }) }
+ if (name === '@/utils/perm') return permissionModule
+ if (name === '@/stores/modules/user') return { default: () => userStore }
if (name === '@/stores/modules/app') return { default: () => ({ getImageUrl: value => value }) }
if (name === 'element-plus') return { ElMessage: { warning() {} } }
if (name === '@element-plus/icons-vue' || name === '@/api/patient') return {}
@@ -1878,7 +1880,11 @@ function setupAudioEntry() {
const render = execute(compiled.code, () => ({ ...vue, resolveComponent: name => name })).render
const scope = vue.effectScope()
const editor = scope.run(() => module.default.setup({}, { expose: value => Object.assign(exposed, value), emit() {} }))
- const nodes = () => {
+ const dailyTab = descriptor.template.content.match(//)[0]
+ const dailyCompiled = compileTemplate({ source: dailyTab, filename, id: 'daily-tab', compilerOptions: { bindingMetadata: script.bindings } })
+ assert.deepEqual(dailyCompiled.errors, [])
+ const renderDaily = execute(dailyCompiled.code, () => ({ ...vue, resolveComponent: name => name })).render
+ const nodes = (renderTab = render) => {
const collected = []
const visit = node => {
if (!node || typeof node !== 'object') return
@@ -1887,10 +1893,10 @@ function setupAudioEntry() {
visit(node.children)
if (node.children?.default) visit(node.children.default())
}
- visit(render({}, [], {}, vue.proxyRefs(editor)))
+ visit(renderTab({}, [], {}, vue.proxyRefs(editor)))
return collected
}
- return { editor, exposed, requests, warnings, nodes, hasTab: () => nodes().some(node => node.type === 'el-tab-pane'), hasPanel: () => nodes().some(node => node.type === panelComponent), dispose: () => { hooks.unmount(); scope.stop() } }
+ return { editor, exposed, requests, warnings, nodes, userStore, hasDailyTab: () => nodes(renderDaily).some(node => node.type === 'el-tab-pane'), hasTab: () => nodes().some(node => node.type === 'el-tab-pane'), hasPanel: () => nodes().some(node => node.type === panelComponent), dispose: () => { hooks.unmount(); scope.stop() } }
}
test('saved diagnosis keeps audio entry during pending/error; manual retry recovers without losing dirty input', async () => {
@@ -2051,3 +2057,70 @@ test('duplicate diagnosis suggestions are blocked clearly without losing distinc
assert.equal(state.reviewBatchIssue([item(),item({id:'another',record_time:'12:00'})],fields),'')
assert.equal(a.values.symptoms,'口干');assert.equal(b.values.symptoms,'睡眠中断')
})
+
+
+test('grouped audio permission independently gates saved diagnosis entry and capabilities requests', async () => {
+ for (const permissions of [[], ['tcm.diagnosis/dailyRecord'], ['tcm.diagnosis/detail', 'tcm.diagnosis/edit'], ['tcm.diagnosis/followupaudio']]) {
+ const f = setupAudioEntry(permissions)
+ try {
+ await f.editor.open('edit', 10)
+ assert.equal(f.hasTab(), false)
+ assert.equal(f.hasPanel(), false)
+ assert.equal(f.hasDailyTab(), permissions.includes('tcm.diagnosis/dailyRecord'))
+ await f.editor.loadFollowupCapabilities()
+ assert.equal(f.requests.length, 0, 'denied entry makes no capabilities request, including direct reload')
+ assert.equal(f.editor.followupCapabilitiesLoading.value, false)
+ assert.equal(f.editor.followupCapabilitiesError.value, '')
+ } finally { f.dispose() }
+ }
+ for (const permissions of [['tcm.diagnosis/followupAudio'], ['tcm.diagnosis/followupAudio', 'tcm.diagnosis/dailyRecord'], ['*']]) {
+ const f = setupAudioEntry(permissions)
+ try {
+ await f.editor.open('add')
+ assert.equal(f.hasTab(), false, 'unsaved diagnoses have no audio entry even with permission')
+ assert.equal(f.requests.length, 0)
+ await f.editor.open('edit', 10)
+ assert.equal(f.hasTab(), true)
+ assert.equal(f.hasDailyTab(), permissions.includes('*') || permissions.includes('tcm.diagnosis/dailyRecord'))
+ assert.equal(f.requests.length, 1)
+ f.requests[0].reject(new Error('transient network failure')); await settle()
+ assert.equal(f.hasTab(), true, 'granted entry never depends on capabilities success')
+ assert.equal(f.hasPanel(), false)
+ assert.match(f.editor.followupCapabilitiesError.value, /加载失败/)
+ assert.equal(f.requests.length, 1)
+ const retry = f.editor.loadFollowupCapabilities()
+ f.requests[1].resolve({ ...caps(), can_upload: false, can_apply: false, can_daily: false })
+ await retry
+ assert.equal(f.hasPanel(), true)
+ const panel = f.nodes().find(node => node.props?.capabilities)
+ assert.equal(panel.props.capabilities.can_apply, false, 'entry grant never overrides server write capability')
+ assert.equal(panel.props.capabilities.can_upload, false)
+ } finally { f.dispose() }
+ }
+})
+
+test('permission revocation aborts pending capabilities and grant refreshes current saved diagnosis only', async () => {
+ const f = setupAudioEntry(['tcm.diagnosis/followupAudio'])
+ try {
+ await f.editor.open('edit', 10)
+ f.editor.activeTab.value = 'followupAudio'
+ const old = f.requests[0]
+ f.userStore.perms = ['tcm.diagnosis/dailyRecord']
+ assert.equal(old.signal.aborted, true)
+ assert.equal(f.hasTab(), false)
+ assert.equal(f.hasDailyTab(), true)
+ assert.equal(f.editor.activeTab.value, 'basic')
+ old.resolve(caps()); await settle()
+ assert.equal(f.editor.followupCapabilities.value, null)
+ assert.equal(f.requests.length, 1)
+ f.userStore.perms.push('tcm.diagnosis/followupAudio')
+ assert.equal(f.hasTab(), true)
+ assert.equal(f.requests.length, 2)
+ f.requests[1].resolve(caps()); await settle()
+ assert.equal(f.hasPanel(), true)
+ f.userStore.perms = []
+ assert.equal(f.hasPanel(), false)
+ assert.equal(f.editor.followupCapabilities.value, null)
+ assert.equal(f.requests.length, 2)
+ } finally { f.dispose() }
+})
diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php
index 731c20f7e..75f414df2 100755
--- a/server/app/adminapi/http/middleware/AuthMiddleware.php
+++ b/server/app/adminapi/http/middleware/AuthMiddleware.php
@@ -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('权限不足,无法访问或操作');
diff --git a/server/app/common/service/followupaudio/FollowupAudioAccess.php b/server/app/common/service/followupaudio/FollowupAudioAccess.php
index dc796ceec..0f0989d9b 100644
--- a/server/app/common/service/followupaudio/FollowupAudioAccess.php
+++ b/server/app/common/service/followupaudio/FollowupAudioAccess.php
@@ -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('诊单不存在或无权操作');
}
diff --git a/server/sql/2026_10_12_followup_audio_permission.sql b/server/sql/2026_10_12_followup_audio_permission.sql
new file mode 100644
index 000000000..6e0ed56d8
--- /dev/null
+++ b/server/sql/2026_10_12_followup_audio_permission.sql
@@ -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;
diff --git a/server/tests/FollowupAudioAccessTest.php b/server/tests/FollowupAudioAccessTest.php
index c639e3a83..2899c9743 100644
--- a/server/tests/FollowupAudioAccessTest.php
+++ b/server/tests/FollowupAudioAccessTest.php
@@ -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],
diff --git a/server/tests/FollowupAudioCoreTest.php b/server/tests/FollowupAudioCoreTest.php
index a02f39bff..2ddbd59bd 100644
--- a/server/tests/FollowupAudioCoreTest.php
+++ b/server/tests/FollowupAudioCoreTest.php
@@ -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)");
diff --git a/server/tests/FollowupAudioEndpointContractTest.php b/server/tests/FollowupAudioEndpointContractTest.php
index 2a112036d..98656ab57 100644
--- a/server/tests/FollowupAudioEndpointContractTest.php
+++ b/server/tests/FollowupAudioEndpointContractTest.php
@@ -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');
diff --git a/server/tests/FollowupAudioPermissionTest.php b/server/tests/FollowupAudioPermissionTest.php
new file mode 100644
index 000000000..82ea1cb2f
--- /dev/null
+++ b/server/tests/FollowupAudioPermissionTest.php
@@ -0,0 +1,185 @@
+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);
+}
diff --git a/server/tests/fixtures/followup_audio/database.php b/server/tests/fixtures/followup_audio/database.php
index 35994a61c..175eb785e 100644
--- a/server/tests/fixtures/followup_audio/database.php
+++ b/server/tests/fixtures/followup_audio/database.php
@@ -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)");