feat(followup-audio): add independent assignable recording permission
This commit is contained in:
@@ -671,7 +671,7 @@
|
|||||||
<el-empty v-else description="请先保存诊单后查看" />
|
<el-empty v-else description="请先保存诊单后查看" />
|
||||||
</el-tab-pane>
|
</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 />
|
<el-alert v-if="followupCapabilitiesLoading" title="正在检查回访录音状态…" type="info" :closable="false" show-icon />
|
||||||
<div v-else-if="followupCapabilitiesError" role="alert">
|
<div v-else-if="followupCapabilitiesError" role="alert">
|
||||||
<el-alert :title="followupCapabilitiesError" type="error" :closable="false" show-icon />
|
<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 hasPhonePlainPermission = computed(() => hasPermission(['tcm.diagnosis/phonePlain']))
|
||||||
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
const hasDailyRecordPermission = computed(() => hasPermission(['tcm.diagnosis/dailyRecord']))
|
||||||
|
const hasFollowupAudioPermission = computed(() => hasPermission(['tcm.diagnosis/followupAudio']))
|
||||||
const patientBasicLocked = ref(false)
|
const patientBasicLocked = ref(false)
|
||||||
const canEditPatientBasicFromApi = ref(true)
|
const canEditPatientBasicFromApi = ref(true)
|
||||||
const latestPrescriptionOrder = ref<{
|
const latestPrescriptionOrder = ref<{
|
||||||
@@ -1049,7 +1050,7 @@ async function loadFollowupCapabilities(): Promise<void> {
|
|||||||
if (followupCapabilitiesLoading.value) return
|
if (followupCapabilitiesLoading.value) return
|
||||||
resetFollowupCapabilities()
|
resetFollowupCapabilities()
|
||||||
const id = Number(formData.value.id)
|
const id = Number(formData.value.id)
|
||||||
if (!visible.value || !id) return
|
if (!visible.value || !id || !hasFollowupAudioPermission.value) return
|
||||||
const generation = diagnosisGeneration
|
const generation = diagnosisGeneration
|
||||||
const request = new AbortController()
|
const request = new AbortController()
|
||||||
capabilitiesRequest = request
|
capabilitiesRequest = request
|
||||||
@@ -1073,8 +1074,9 @@ async function loadFollowupCapabilities(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
watch([visible, () => formData.value.id], () => {
|
watch([visible, () => formData.value.id, hasFollowupAudioPermission], () => {
|
||||||
// Invalidate synchronously so an old patient's capabilities never survive a switch.
|
// Invalidate synchronously on patient switches and permission changes.
|
||||||
|
if (!hasFollowupAudioPermission.value && activeTab.value === 'followupAudio') activeTab.value = 'basic'
|
||||||
resetFollowupCapabilities()
|
resetFollowupCapabilities()
|
||||||
void loadFollowupCapabilities()
|
void loadFollowupCapabilities()
|
||||||
}, { flush: 'sync' })
|
}, { flush: 'sync' })
|
||||||
|
|||||||
@@ -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', () => {
|
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')
|
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, /:before-leave="beforeTabLeave"/)
|
||||||
assert.match(source, /next === 'followupAudio' && basicDirty.value/)
|
assert.match(source, /next === 'followupAudio' && basicDirty.value/)
|
||||||
assert.match(source, /await loadDiagnosisDetailIntoForm\(diagnosisId, true\)/)
|
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()
|
await editor.handleSubmit()
|
||||||
assert.equal(edits.length, 1, 'ordinary diagnosis mock API remains usable; no real patient write')
|
assert.equal(edits.length, 1, 'ordinary diagnosis mock API remains usable; no real patient write')
|
||||||
assert.equal(edits[0].remark, '合成普通表单编辑')
|
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() }
|
} 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.
|
// 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 requests = [], warnings = [], hooks = {}, exposed = {}
|
||||||
const filename = path.join(componentDir, '../edit.vue')
|
const filename = path.join(componentDir, '../edit.vue')
|
||||||
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
|
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/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 === '@/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/feedback') return { default: { msgWarning: message => warnings.push(message) } }
|
||||||
if (name === '@/utils/perm') return { hasPermission: () => true }
|
if (name === '@/utils/perm') return permissionModule
|
||||||
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: { id: 1 } }) }
|
if (name === '@/stores/modules/user') return { default: () => userStore }
|
||||||
if (name === '@/stores/modules/app') return { default: () => ({ getImageUrl: value => value }) }
|
if (name === '@/stores/modules/app') return { default: () => ({ getImageUrl: value => value }) }
|
||||||
if (name === 'element-plus') return { ElMessage: { warning() {} } }
|
if (name === 'element-plus') return { ElMessage: { warning() {} } }
|
||||||
if (name === '@element-plus/icons-vue' || name === '@/api/patient') return {}
|
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 render = execute(compiled.code, () => ({ ...vue, resolveComponent: name => name })).render
|
||||||
const scope = vue.effectScope()
|
const scope = vue.effectScope()
|
||||||
const editor = scope.run(() => module.default.setup({}, { expose: value => Object.assign(exposed, value), emit() {} }))
|
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 collected = []
|
||||||
const visit = node => {
|
const visit = node => {
|
||||||
if (!node || typeof node !== 'object') return
|
if (!node || typeof node !== 'object') return
|
||||||
@@ -1887,10 +1893,10 @@ function setupAudioEntry() {
|
|||||||
visit(node.children)
|
visit(node.children)
|
||||||
if (node.children?.default) visit(node.children.default())
|
if (node.children?.default) visit(node.children.default())
|
||||||
}
|
}
|
||||||
visit(render({}, [], {}, vue.proxyRefs(editor)))
|
visit(renderTab({}, [], {}, vue.proxyRefs(editor)))
|
||||||
return collected
|
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 () => {
|
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(state.reviewBatchIssue([item(),item({id:'another',record_time:'12:00'})],fields),'')
|
||||||
assert.equal(a.values.symptoms,'口干');assert.equal(b.values.symptoms,'睡眠中断')
|
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'];
|
'create', 'lists', 'detail', 'savedraft', 'apply', 'retry', 'audio', 'delete', 'reextract'];
|
||||||
$action = substr($accessUri, strlen('tcm.followupaudio/'));
|
$action = substr($accessUri, strlen('tcm.followupaudio/'));
|
||||||
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
|
$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 $next($request); // The service also checks the current patient scope and daily permissions.
|
||||||
}
|
}
|
||||||
return JsonService::fail('权限不足,无法访问或操作');
|
return JsonService::fail('权限不足,无法访问或操作');
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ final class FollowupAudioAccess
|
|||||||
$row = Db::name('tcm_diagnosis')->where('id', $diagnosisId)->whereNull('delete_time')->lock(true)->find();
|
$row = Db::name('tcm_diagnosis')->where('id', $diagnosisId)->whereNull('delete_time')->lock(true)->find();
|
||||||
$info = self::actor($actor);
|
$info = self::actor($actor);
|
||||||
if (!$row || !self::allowed($actor, $info, 'tcm.diagnosis/edit')
|
if (!$row || !self::allowed($actor, $info, 'tcm.diagnosis/edit')
|
||||||
|
|| !self::allowed($actor, $info, 'tcm.diagnosis/followupAudio')
|
||||||
|| ($daily && !self::allowed($actor, $info, 'tcm.diagnosis/dailyRecord'))) {
|
|| ($daily && !self::allowed($actor, $info, 'tcm.diagnosis/dailyRecord'))) {
|
||||||
throw new DomainException('诊单不存在或无权操作');
|
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;
|
||||||
@@ -54,8 +54,9 @@ namespace {
|
|||||||
$db::name('system_menu')->insertAll([
|
$db::name('system_menu')->insertAll([
|
||||||
['id' => 1, 'perms' => 'tcm.diagnosis/edit', 'is_disable' => 0],
|
['id' => 1, 'perms' => 'tcm.diagnosis/edit', 'is_disable' => 0],
|
||||||
['id' => 2, 'perms' => 'tcm.diagnosis/dailyRecord', '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([
|
$db::name('tcm_diagnosis')->insertAll([
|
||||||
['id' => 91, 'patient_id' => 101, 'assistant_id' => 8, 'status' => 1],
|
['id' => 91, 'patient_id' => 101, 'assistant_id' => 8, 'status' => 1],
|
||||||
['id' => 92, 'patient_id' => 102, 'assistant_id' => 9, 'status' => 1],
|
['id' => 92, 'patient_id' => 102, 'assistant_id' => 9, 'status' => 1],
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ try {
|
|||||||
$pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_id INT)');
|
$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_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('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('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_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('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)");
|
$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, "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, '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($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($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($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');
|
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
@@ -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_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_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('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('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_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('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)");
|
$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)");
|
||||||
|
|||||||
Reference in New Issue
Block a user