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
+6 -4
View File
@@ -671,7 +671,7 @@
<el-empty v-else description="请先保存诊单后查看" />
</el-tab-pane>
<el-tab-pane v-if="formData.id" label="回访录音" name="followupAudio" lazy>
<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音" name="followupAudio" lazy>
<el-alert v-if="followupCapabilitiesLoading" title="正在检查回访录音状态…" type="info" :closable="false" show-icon />
<div v-else-if="followupCapabilitiesError" role="alert">
<el-alert :title="followupCapabilitiesError" type="error" :closable="false" show-icon />
@@ -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<void> {
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<void> {
}
}
}
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' })
+81 -8
View File
@@ -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, /<el-tab-pane v-if="formData.id" label="回访录音"/)
assert.match(source, /<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音"/)
assert.match(source, /:before-leave="beforeTabLeave"/)
assert.match(source, /next === 'followupAudio' && basicDirty.value/)
assert.match(source, /await loadDiagnosisDetailIntoForm\(diagnosisId, true\)/)
@@ -1411,7 +1411,7 @@ test('actual patient editor outside preview allowlist still opens and edits its
await editor.handleSubmit()
assert.equal(edits.length, 1, 'ordinary diagnosis mock API remains usable; no real patient write')
assert.equal(edits[0].remark, '合成普通表单编辑')
assert.match(descriptor.template.content, /<el-tab-pane v-if="formData.id" label="回访录音"/)
assert.match(descriptor.template.content, /<el-tab-pane v-if="formData.id && hasFollowupAudioPermission" label="回访录音"/)
} finally { scope.stop() }
})
@@ -1851,7 +1851,9 @@ test('autosave denial for remotely deleted task clears local playback without an
})
// Execute the real editor script and its actual tab template, with deferred network I/O.
function setupAudioEntry() {
function setupAudioEntry(permissions = ['*']) {
const userStore = vue.reactive({ perms: [...permissions], userInfo: { id: 1 } })
const permissionModule = loadTs(path.join(root, 'src/utils/perm.ts'), () => ({ 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(/<el-tab-pane\s+v-if="hasDailyRecordPermission"[\s\S]*?<\/el-tab-pane>/)[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() }
})
@@ -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)");