diff --git a/admin/src/views/tcm/diagnosis/edit.vue b/admin/src/views/tcm/diagnosis/edit.vue index 0c13e82cf..24740ccc4 100644 --- a/admin/src/views/tcm/diagnosis/edit.vue +++ b/admin/src/views/tcm/diagnosis/edit.vue @@ -671,7 +671,7 @@ - +
@@ -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)");