@@ -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)");
From 11306e6314625f5d6f8affb1df4ecd7a69836da6 Mon Sep 17 00:00:00 2001
From: long <452591453@qq.com>
Date: Mon, 12 Oct 2026 15:18:37 +0800
Subject: [PATCH 2/2] fix(followup-audio): handle unsigned zero menu sort
during permission migration
---
server/sql/2026_10_12_followup_audio_permission.sql | 2 +-
server/tests/FollowupAudioPermissionTest.php | 9 +++++----
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/server/sql/2026_10_12_followup_audio_permission.sql b/server/sql/2026_10_12_followup_audio_permission.sql
index 6e0ed56d8..5865d4bfa 100644
--- a/server/sql/2026_10_12_followup_audio_permission.sql
+++ b/server/sql/2026_10_12_followup_audio_permission.sql
@@ -5,7 +5,7 @@ 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),
+SELECT d.pid, 'A', '回访录音', '', GREATEST(CAST(MAX(d.sort) AS SIGNED) - 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'
diff --git a/server/tests/FollowupAudioPermissionTest.php b/server/tests/FollowupAudioPermissionTest.php
index 82ea1cb2f..9885734e3 100644
--- a/server/tests/FollowupAudioPermissionTest.php
+++ b/server/tests/FollowupAudioPermissionTest.php
@@ -146,7 +146,7 @@ try {
// 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 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,
@@ -156,9 +156,9 @@ try {
$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)");
+ (192,191,'A','日常记录',0,'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';
+ $migrationFile = getenv('FOLLOWUP_AUDIO_TEST_MENU_MIGRATION') ?: 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);
@@ -167,9 +167,10 @@ try {
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
+ $menus = $pdo->query("SELECT pid,type,name,sort,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');
+ $expect(array_map('intval', array_column($menus, 'sort')) === [58, 0], 'unsigned zero sort never underflows');
foreach ($menus as $menu) {
$expect($menu['type'] === 'A' && $menu['name'] === '回访录音' && (int) $menu['is_disable'] === 0 && (int) $menu['is_show'] === 1, 'migration node is assignable');
}