From 14e1722291b618c660241e7d87bc15262d3152b2 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Mon, 12 Oct 2026 10:39:12 +0800 Subject: [PATCH] feat(followup-audio): soft delete unadopted recordings and expose extraction doubts --- admin/src/api/followupAudio.ts | 3 + .../components/FollowupAudioPanel.vue | 65 +++++- .../components/FollowupAudioReview.vue | 4 +- admin/tests/followup-audio.test.cjs | 55 +++++ .../tcm/FollowupAudioController.php | 6 + .../http/middleware/AuthMiddleware.php | 2 +- .../adminapi/logic/tcm/FollowupAudioLogic.php | 21 +- .../followupaudio/FollowupAudioStore.php | 61 +++++- .../2026_10_12_followup_audio_soft_delete.sql | 16 ++ server/tests/FollowupAudioCoreTest.php | 4 + server/tests/FollowupAudioDeleteTest.php | 200 ++++++++++++++++++ .../FollowupAudioEndpointContractTest.php | 2 + .../fixtures/followup_audio/database.php | 5 + 13 files changed, 426 insertions(+), 18 deletions(-) create mode 100644 server/sql/2026_10_12_followup_audio_soft_delete.sql create mode 100644 server/tests/FollowupAudioDeleteTest.php diff --git a/admin/src/api/followupAudio.ts b/admin/src/api/followupAudio.ts index 57671b940..bec5dbf37 100644 --- a/admin/src/api/followupAudio.ts +++ b/admin/src/api/followupAudio.ts @@ -75,6 +75,8 @@ export interface FollowupTaskSummary { version: number created_at: number | string expires_at: number | string + can_delete?: boolean + delete_block_reason?: string can_retry: boolean audio_available: boolean preview_only?: boolean @@ -130,6 +132,7 @@ export const followupAudioUploadComplete = (uploadId: string, signal?: AbortSign export const followupAudioCreate = (data: { diagnosis_id: number; upload_id: string; recorded_at: string; model_key: string; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post<{ task_id: number; status: string; reused?: boolean; reuse_message?: string }>('create', data, signal) export const followupAudioSaveDraft = (data: { id: number; version: number; items: FollowupReviewItem[]; channel_roles?: FollowupChannelRoles }, signal?: AbortSignal) => post('saveDraft', data, signal) export const followupAudioApply = (data: { id: number; version: number; items: FollowupReviewItem[] }, signal?: AbortSignal) => post<{ id: number; status: 'applied'; applied_items: unknown[] }>('apply', data, signal) +export const followupAudioDelete = (data: { id: number; version: number }, signal?: AbortSignal) => post<{ id: number; status: 'deleted'; version: number }>('delete', data, signal) export const followupAudioRetry = (id: number, signal?: AbortSignal) => post<{ id: number; status: string }>('retry', { id }, signal) /** The existing request interceptor puts the token in a header, never in the audio URL. */ export async function followupAudioBlob(id: number, signal?: AbortSignal): Promise { diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue index 7d0570075..15e7d14df 100644 --- a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue @@ -34,8 +34,8 @@ - - + + @@ -43,7 +43,7 @@

{{ detail.file_name }}

通话时间 {{ detail.recorded_at }}
- {{ followupTaskStatus(detail) }} +
{{ followupTaskStatus(detail) }}删除录音

{{ followupProgressText(detail) }}

@@ -56,6 +56,7 @@

{{ detail.summary || '暂无回访摘要' }}

{{ detail.status === 'applied' ? '已写入' : '已选' }} {{ selectedCount }} 项待核实 {{ pendingCount }} 项
+
{{ savedChannelRoles === 'left_service' ? '左客服端 / 右患者或家属端' : '右客服端 / 左患者或家属端' }} · 已确认修改
@@ -144,7 +145,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { - FollowupAudioError, followupAudioApply, followupAudioBlob, followupAudioCreate, + FollowupAudioError, followupAudioDelete, followupAudioApply, followupAudioBlob, followupAudioCreate, followupAudioDetail, followupAudioLists, followupAudioRetry, followupAudioSaveDraft, followupAudioUploadChunk, followupAudioUploadComplete, followupAudioUploadSession, type FollowupAudioChannel, type FollowupCapabilities, type FollowupChannelRoles, type FollowupReviewItem, type FollowupTaskDetail, type FollowupTaskSummary @@ -194,7 +195,7 @@ const uploading = ref(false) const uploadProgress = ref(0) const uploadStage = ref('') const createUncertain = ref(false) -const mutation = ref<'save' | 'apply' | 'retry' | ''>('') +const mutation = ref<'save' | 'apply' | 'retry' | 'delete' | ''>('') const errorMessage = ref('') const audioUrl = ref('') const audioElement = ref(null) @@ -270,9 +271,25 @@ function setChannelRoles(value: unknown) { if (!canEditReview.value || (busy.value && mutation.value !== 'save') || !hasStereo.value || !['unconfirmed', 'left_service', 'right_service'].includes(String(value))) return channelRoles.value = value as FollowupChannelRoles } -async function readDetail(id: number, preserve = false) { +function taskUnavailable(error: unknown): boolean { + return error instanceof FollowupAudioError && (error.code === 'FOLLOWUP_AUDIO_TASK_UNAVAILABLE' || error.message.includes('FOLLOWUP_AUDIO_TASK_UNAVAILABLE')) +} +function forgetUnavailableTask(id: number) { + if (selectedId.value !== id) return + stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = [] + tasks.value = tasks.value.filter(row => row.id !== id) + errorMessage.value = '录音已删除或不再可访问,已关闭本页缓存与播放;未写入病例。' + void refresh() +} +async function readDetail(id: number, preserve = false, verifyOnly = false) { const ticket = scope.capture() - const fresh = await followupAudioDetail(id, ticket.signal) + let fresh: FollowupTaskDetail + try { fresh = await followupAudioDetail(id, ticket.signal) } + catch (error) { + if (scope.current(ticket) && id === selectedId.value && taskUnavailable(error)) { forgetUnavailableTask(id); return } + throw error + } + if (verifyOnly && detail.value && fresh.version === detail.value.version && fresh.status === detail.value.status) return if (!scope.current(ticket) || id !== selectedId.value || (detail.value && fresh.version < detail.value.version)) return preserve = preserve || (!!detail.value && dirty.value) const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied' @@ -292,6 +309,9 @@ async function refresh() { const updated = tasks.value.find(task => task.id === selectedId.value) if (updated && (!detail.value || updated.version > detail.value.version || (updated.version >= detail.value.version && updated.status !== detail.value.status) || updated.status === 'running')) { await readDetail(updated.id, dirty.value) + } else if (!updated && selectedId.value && detail.value) { + // A limited list can omit older tasks; only an authoritative detail denial revokes them. + await readDetail(selectedId.value, false, true) } } catch (error) { if (scope.current(ticket)) errorMessage.value = followupError(error) } finally { @@ -382,6 +402,7 @@ async function submitUpload() { } async function handleReviewError(error: unknown) { const ticket = scope.capture() + if (taskUnavailable(error) && detail.value) { forgetUnavailableTask(detail.value.id); return } errorMessage.value = followupError(error) const stale = error instanceof FollowupAudioError && error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW' if (stale || /STALE_REVIEW|版本|快照|已变化/.test(errorMessage.value)) { @@ -488,6 +509,35 @@ async function applySelected() { } catch (error) { if (scope.current(ticket)) await handleReviewError(error) } finally { if (scope.current(ticket)) { mutation.value = ''; await refresh() } } } +function canDeleteTask(task: FollowupTaskSummary): boolean { + return !props.viewOnly && featureEnabled.value && task.can_delete === true && ['review', 'failed'].includes(task.status) +} +async function deleteRecording(task: FollowupTaskSummary) { + if (busy.value || !canDeleteTask(task)) return + const ticket = scope.capture(), diagnosisId = props.diagnosisId, id = task.id, version = task.version + const current = () => scope.current(ticket) && diagnosisId === props.diagnosisId + clearAutosave(); clearTimer(); mutation.value = 'delete'; errorMessage.value = '' + let confirmed = false + try { + await ElMessageBox.confirm('删除后,该录音任务及审核草稿将从列表移除,未保存修改会丢弃;不删除或回滚病例。原录音和转写仍按原保留期限清理。重新上传同一文件将创建新的分析任务。确认删除?', '删除录音任务', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '保留录音' }) + if (!current() || !canDeleteTask(task)) return + confirmed = true + await followupAudioDelete({ id, version }, ticket.signal) + if (!current()) return + // Fence pending playback, autosaves and reads; deletion never revives a stale draft. + if (selectedId.value === id) { + stop(); resetRoles(); selectedId.value = 0; detail.value = null; reviewItems.value = []; snapshot.value = '[]'; detailSections.value = [] + } + tasks.value = tasks.value.filter(row => row.id !== id) + errorMessage.value = '' + ElMessage.success('录音任务已删除,未修改病例。原资料按保留策略清理。') + await refresh() + } catch (error) { + if (confirmed && current()) errorMessage.value = followupError(error) + } finally { + if (current()) { mutation.value = ''; scheduleAutosave(); await refresh() } + } +} async function retryTask() { if (!detail.value?.can_retry || detail.value.status !== 'failed' || !canUpload.value || busy.value) return const ticket = scope.capture() @@ -577,6 +627,7 @@ h3 { margin: 0; font-size: 14px; font-weight: 600; } .detail-heading { align-items: flex-start; } .recording-title { min-width: 0; } .recording-title h3 { overflow-wrap: anywhere; line-height: 1.6; } +.detail-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .detail-heading :deep(.el-tag) { flex-shrink: 0; } .detail-loading { padding: 32px; text-align: center; color: var(--el-text-color-secondary); } .processing-state, .applied-note { margin: 0; color: var(--el-text-color-secondary); font-size: 13px; } diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue index c6a2b94d1..96964569d 100644 --- a/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioReview.vue @@ -32,8 +32,8 @@ -
- 原文中还需补充的信息 +
+ 本次未能确认的信息与原因
  • {{ uncertaintyText(value) }}
diff --git a/admin/tests/followup-audio.test.cjs b/admin/tests/followup-audio.test.cjs index 8f5202afa..9b23c5862 100644 --- a/admin/tests/followup-audio.test.cjs +++ b/admin/tests/followup-audio.test.cjs @@ -38,6 +38,7 @@ function setupPanel(overrides = {}, propsPatch = {}, options = {}) { followupAudioUploadChunk: async (id, index, blob) => { calls.push(['chunk', id, index, blob.size]); return { index, received: true } }, followupAudioUploadComplete: async id => { calls.push(['complete', id]); return { upload_id: id, duration_seconds: 10, sha256: 'test' } }, followupAudioCreate: async payload => { calls.push(['create', payload]); return { task_id: 2, status: 'queued' } }, + followupAudioDelete: async payload => { calls.push(['delete', payload]); return { id: payload.id, status: 'deleted', version: payload.version + 1 } }, followupAudioRetry: async id => { calls.push(['retry', id]); return { id, status: 'queued' } }, ...overrides } @@ -1793,3 +1794,57 @@ test('detail fetch begun before an edit preserves that edit when the new snapsho assert.equal(f.calls.filter(x => x[0] === 'save').length, 0) f.dispose() }) + + +test('delete requires actor-aware capability and blocks applied/running/unknown tasks', async () => { + const f=setupPanel();await settle() + for (const row of [task(), task(1,{can_delete:false}),task(1,{can_delete:true,status:'applied'}),task(1,{can_delete:true,status:'running'}),task(1,{can_delete:true,status:'needs_reconciliation'})]) await f.panel.deleteRecording(row) + assert.equal(f.calls.filter(c=>c[0]==='delete').length,0) + f.props.viewOnly=true;await f.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(f.calls.filter(c=>c[0]==='delete').length,0);f.dispose() +}) +test('confirmed deletion sends optimistic version once, clears selected editor and never applies', async () => { + let removed=false;const current=task(1,{can_delete:true}) + const f=setupPanel({followupAudioDetail:async()=>current,followupAudioLists:async()=>({items:removed?[]:[current]}),followupAudioDelete:async p=>{f.calls.push(['delete',p]);removed=true;return {id:p.id,status:'deleted',version:p.version+1}}}) + await settle();await f.panel.openTask(1);await f.panel.deleteRecording(current) + assert.deepEqual(f.calls.find(c=>c[0]==='delete')[1],{id:1,version:1});assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.detail.value,null);assert.deepEqual(f.panel.tasks.value,[]);assert.deepEqual(f.panel.reviewItems.value,[]) + assert.equal(f.calls.some(c=>c[0]==='apply'),false);assert.equal(f.panel.mutation.value,'');f.dispose() +}) +test('delete cancellation keeps recording and drafts, patient switch before confirm sends nothing', async () => { + const f=setupPanel({}, {}, {confirm:async()=>{throw new Error('cancel')}});await settle();await f.panel.openTask(1);await f.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(f.panel.selectedId.value,1);assert.equal(f.calls.some(c=>c[0]==='delete'),false);assert.equal(f.panel.mutation.value,'');f.dispose() + const answer=deferred();const g=setupPanel({}, {}, {confirm:()=>answer.promise});await settle();const pending=g.panel.deleteRecording(task(1,{can_delete:true}));g.props.diagnosisId=20;await settle();answer.resolve();await pending;assert.equal(g.calls.some(c=>c[0]==='delete'),false);g.dispose() +}) +test('late delete result cannot clear another patient and errors preserve current recording', async () => { + const response=deferred();const f=setupPanel({followupAudioDelete:async p=>{f.calls.push(['delete',p]);return response.promise}});await settle();await f.panel.openTask(1) + const deleting=f.panel.deleteRecording(task(1,{can_delete:true}));await settle();const duplicate=f.panel.deleteRecording(task(1,{can_delete:true}));await duplicate;assert.equal(f.calls.filter(c=>c[0]==='delete').length,1) + f.props.diagnosisId=20;await settle();await f.panel.openTask(2);response.resolve({id:1,status:'deleted',version:2});await deleting;assert.equal(f.panel.selectedId.value,2);assert.equal(f.panel.detail.value.id,2);f.dispose() + const g=setupPanel({followupAudioDelete:async()=>{throw new apiModule.FollowupAudioError('版本已变化')}});await settle();await g.panel.openTask(1);await g.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(g.panel.selectedId.value,1);assert.match(g.panel.errorMessage.value,/版本已变化/);g.dispose() +}) +test('delete API has no automatic retry and empty results expose uncertainty reasons without tab switching', async () => { + const requests=[];const api=loadTs(path.join(root,'src/api/followupAudio.ts'),()=>({default:{post:async(config,options)=>{requests.push({config,options});return {code:1,data:{id:1,status:'deleted',version:2}}}}})) + await api.followupAudioDelete({id:1,version:1});assert.equal(requests[0].config.url,'/tcm.followupAudio/delete');assert.equal(requests[0].options.isOpenRetry,false) + const source=fs.readFileSync(path.join(componentDir,'FollowupAudioReview.vue'),'utf8');assert.match(source,/\(group === 'pending' \|\| !items.length\) && uncertainties\?\.length/) + const panelSource=fs.readFileSync(path.join(componentDir,'FollowupAudioPanel.vue'),'utf8');assert.match(panelSource,/未生成可写入信息/);assert.match(panelSource,/不删除或回滚病例/) +}) + + +test('remote deletion revalidates missing detail and releases cached audio/editor', async () => { + let deleted=false;const f=setupPanel({followupAudioLists:async()=>({items:deleted?[]:[task()]}),followupAudioDetail:async id=>{if(deleted)throw new apiModule.FollowupAudioError('FOLLOWUP_AUDIO_TASK_UNAVAILABLE');return task(id)}}) + await settle();await f.panel.openTask(1);await f.panel.loadAudio();assert.equal(f.panel.audioUrl.value,'blob:private-0') + deleted=true;await f.panel.refresh();await settle() + assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.detail.value,null);assert.equal(f.panel.audioUrl.value,'');assert.deepEqual(f.revoked,['blob:private-0']);assert.deepEqual(f.panel.reviewItems.value,[]);assert.match(f.panel.errorMessage.value,/录音已删除/);f.dispose() +}) +test('list pagination omission alone never discards current unsaved edits', async () => { + let omit=false;const f=setupPanel({followupAudioLists:async()=>({items:omit?[]:[task()]})});await settle();await f.panel.openTask(1) + f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9;omit=true;await f.panel.refresh() + assert.equal(f.panel.selectedId.value,1);assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar,6.9);assert.notEqual(f.panel.saveState.value,'conflict');f.dispose() +}) +test('deleting another row resumes current task autosave without clearing its draft', async () => { + const f=setupPanel();await settle();await f.panel.openTask(1);f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9 + await f.panel.deleteRecording(task(2,{can_delete:true}));assert.equal(f.panel.selectedId.value,1);assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar,6.9) + f.tick();await settle();assert.ok(f.calls.some(c=>c[0]==='save'&&c[1].id===1&&c[1].items[0].values.fasting_blood_sugar===6.9));f.dispose() +}) +test('autosave denial for remotely deleted task clears local playback without another save', async () => { + let deleted=false;const f=setupPanel({followupAudioLists:async()=>({items:deleted?[]:[task()]}),followupAudioSaveDraft:async()=>{deleted=true;throw new apiModule.FollowupAudioError('FOLLOWUP_AUDIO_TASK_UNAVAILABLE')}}) + await settle();await f.panel.openTask(1);await f.panel.loadAudio();f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9;await f.panel.saveDraft(true);await settle() + assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.audioUrl.value,'');assert.match(f.panel.errorMessage.value,/录音已删除/);f.dispose() +}) diff --git a/server/app/adminapi/controller/tcm/FollowupAudioController.php b/server/app/adminapi/controller/tcm/FollowupAudioController.php index 02ff6ada6..83b10e7c2 100644 --- a/server/app/adminapi/controller/tcm/FollowupAudioController.php +++ b/server/app/adminapi/controller/tcm/FollowupAudioController.php @@ -93,6 +93,12 @@ final class FollowupAudioController extends BaseAdminController $this->items($p), $this->adminId, $this->adminInfo), true); } + public function delete() + { + return $this->handle(true, ['id', 'version'], fn (array $p): array => + Logic::delete($this->positive($p, 'id'), $this->positive($p, 'version'), $this->adminId, $this->adminInfo)); + } + public function retry() { return $this->handle(true, ['id'], fn (array $p): array => diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php index 8690e891c..454171965 100755 --- a/server/app/adminapi/http/middleware/AuthMiddleware.php +++ b/server/app/adminapi/http/middleware/AuthMiddleware.php @@ -83,7 +83,7 @@ class AuthMiddleware // This group must never use the legacy unregistered-route bypass. if (str_starts_with($accessUri, 'tcm.followupaudio/')) { $allowed = ['capabilities', 'uploadsession', 'uploadchunk', 'uploadcomplete', - 'create', 'lists', 'detail', 'savedraft', 'apply', 'retry', 'audio']; + 'create', 'lists', 'detail', 'savedraft', 'apply', 'retry', 'audio', 'delete']; $action = substr($accessUri, strlen('tcm.followupaudio/')); $uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []); if (in_array($action, $allowed, true) && in_array('tcm.diagnosis/edit', $uris, true)) { diff --git a/server/app/adminapi/logic/tcm/FollowupAudioLogic.php b/server/app/adminapi/logic/tcm/FollowupAudioLogic.php index 0210dea48..64e50494e 100644 --- a/server/app/adminapi/logic/tcm/FollowupAudioLogic.php +++ b/server/app/adminapi/logic/tcm/FollowupAudioLogic.php @@ -70,7 +70,8 @@ final class FollowupAudioLogic public static function lists(int $diagnosisId, int $actor, array $info): array { Access::diagnosis($diagnosisId, $actor, $info); - return ['items' => Store::enabled() ? Store::lists($diagnosisId) : []]; + return ['items' => Store::enabled() ? array_map(static fn (array $task): array => + self::withDeleteCapability($task, $actor), Store::lists($diagnosisId)) : []]; } public static function detail(int $id, int $actor, array $info): array @@ -96,6 +97,22 @@ final class FollowupAudioLogic return Apply::apply($id, $version, $items, $actor, $info); } + public static function delete(int $id, int $version, int $actor, array $info): array + { + self::requireEnabled(); + return Store::delete($id, $version, $actor, $info); + } + + private static function withDeleteCapability(array $summary, int $actor): array + { + $task = Store::task((int) $summary['id']); + $info = Access::actor($actor); + $reason = !Store::enabled() ? '回访录音功能尚未启用' + : ((int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor + ? '仅上传者或超级管理员可删除录音' : Store::deleteBlockReason($task)); + return $summary + ['can_delete' => $reason === '', 'delete_block_reason' => $reason]; + } + public static function retry(int $id, int $actor, array $info): array { self::requireEnabled(true); @@ -160,7 +177,7 @@ final class FollowupAudioLogic } unset($item); } - return $detail; + return self::withDeleteCapability($detail, $actor); } } diff --git a/server/app/common/service/followupaudio/FollowupAudioStore.php b/server/app/common/service/followupaudio/FollowupAudioStore.php index f4fbd457a..258dc896b 100644 --- a/server/app/common/service/followupaudio/FollowupAudioStore.php +++ b/server/app/common/service/followupaudio/FollowupAudioStore.php @@ -67,12 +67,13 @@ final class FollowupAudioStore FollowupAudioAccess::diagnosis($diagnosisId, $actor, $info); $diagnosis = FollowupAudioApply::diagnosis($diagnosisId, true); $existing = Db::name('followup_audio_task')->where('upload_id', $stored['id'])->lock(true)->find(); + if ($existing && (int) ($existing['active_slot'] ?? 0) !== 0) { throw new DomainException('FOLLOWUP_AUDIO_UPLOAD_ALREADY_USED'); } if ($existing && $existing['model_key'] !== $modelKey) { throw new DomainException('FOLLOWUP_AUDIO_UPLOAD_ALREADY_USED'); } // Diagnose+content+profile is immutable even if a retry changes upload ID, filename or recordedAt. $existing = $existing ?: Db::name('followup_audio_task')->where('diagnosis_id', $diagnosisId) - ->where('sha256', $stored['sha256'])->where('model_key', $modelKey)->lock(true)->find(); + ->where('sha256', $stored['sha256'])->where('model_key', $modelKey)->where('active_slot', 0)->lock(true)->find(); if ($existing) { return self::reuse($existing, $recordedAt, $channelRoles); } $path = FollowupAudioUpload::path($stored); if (!is_file($path) || (int) filesize($path) !== (int) $stored['total_bytes'] @@ -102,7 +103,7 @@ final class FollowupAudioStore // The unique content key is the final arbiter even for a concurrent caller outside this service. if ((int) ($exception->getData()['PDO Error Info']['Driver Error Code'] ?? 0) !== 1062) { throw $exception; } $winner = Db::name('followup_audio_task')->where('diagnosis_id', $diagnosisId) - ->where('sha256', $stored['sha256'])->where('model_key', $modelKey)->lock(true)->find(); + ->where('sha256', $stored['sha256'])->where('model_key', $modelKey)->where('active_slot', 0)->lock(true)->find(); if (!$winner) { throw $exception; } Db::name('followup_audio_upload')->where('id', $stored['id'])->update(['expires_at' => $stored['expires_at']]); return self::reuse($winner, $recordedAt, $channelRoles); @@ -136,13 +137,13 @@ final class FollowupAudioStore public static function task(int $taskId): array { $task = Db::name('followup_audio_task')->where('id', $taskId)->find(); - if (!$task) { throw new DomainException('FOLLOWUP_AUDIO_TASK_UNAVAILABLE'); } + if (!$task || (int) ($task['active_slot'] ?? 0) !== 0 || $task['status'] === 'deleted') { throw new DomainException('FOLLOWUP_AUDIO_TASK_UNAVAILABLE'); } return $task; } public static function lists(int $diagnosisId): array { - $rows = Db::name('followup_audio_task')->where('diagnosis_id', $diagnosisId)->order('id', 'desc')->limit(200)->select()->toArray(); + $rows = Db::name('followup_audio_task')->where('diagnosis_id', $diagnosisId)->where('active_slot', 0)->order('id', 'desc')->limit(200)->select()->toArray(); return array_map([self::class, 'summary'], $rows); } @@ -238,6 +239,54 @@ final class FollowupAudioStore return $detail; } + /** Actor-independent deny reasons also guard against a corrupt status hiding an adoption/intent. */ + public static function deleteBlockReason(array $task): string + { + if ((int) ($task['active_slot'] ?? 0) !== 0 || $task['status'] === 'deleted') { return '录音已删除'; } + if ($task['status'] === 'applied' || (int) $task['applied_at'] > 0 || $task['applied_cipher'] !== '' + || Db::name('followup_audio_audit')->where('task_id', $task['id'])->lock(true)->find()) { + return '已采用或已有采用审计的录音须保留'; + } + if (!in_array($task['status'], ['review', 'failed'], true) || (int) $task['lease_until'] > time()) { + return '仅已完成且未采用、或确定失败的录音可删除;排队、处理中及待核对任务须先等待或核对'; + } + if (!empty($task['pipeline_cipher'])) { + $state = self::pipelineState($task); + if ($state === [] || FollowupAudioPipelineCheckpoint::hasIntent($state)) { return '上游结果尚未确定,须先核对'; } + } elseif ($task['status'] === 'failed' && !self::safeToResume($task)) { + return '上游结果尚未确定,须先核对'; + } + return ''; + } + + /** Soft deletion releases only the live dedup slot. Immutable provenance and retention remain intact. */ + public static function delete(int $taskId, int $version, int $actor, array $info): array + { + $connection = Db::connect(); + if ($connection->getPdo()->inTransaction()) { throw new DomainException('FOLLOWUP_AUDIO_NESTED_DELETE_FORBIDDEN'); } + $connection->execute('SET TRANSACTION ISOLATION LEVEL READ COMMITTED'); + return Db::transaction(static function () use ($taskId, $version, $actor, $info): array { + $task = self::lockTask($taskId); + $info = FollowupAudioAccess::actor($actor); + FollowupAudioAccess::task($taskId, $actor, $info); + if ((int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor) { + throw new DomainException('仅上传者或超级管理员可删除录音'); + } + if ((int) $task['version'] !== $version) { throw new DomainException('FOLLOWUP_AUDIO_VERSION_CONFLICT'); } + $reason = self::deleteBlockReason($task); + if ($reason !== '') { throw new DomainException($reason); } + $now = time(); + Db::name('followup_audio_task')->where('id', $taskId)->update([ + 'status' => 'deleted', 'stage' => 'deleted', 'active_slot' => $taskId, + 'deleted_at' => $now, 'deleted_by' => $actor, + 'deletion_json' => FollowupAudioPolicy::canonical(['previous_status' => $task['status'], + 'previous_version' => (int) $task['version'], 'retained_until' => (int) $task['expires_at']]), + 'version' => $version + 1, 'updated_at' => $now, + ]); + return ['id' => $taskId, 'status' => 'deleted', 'version' => $version + 1]; + }); + } + public static function retry(int $taskId): array { self::assertEnabled(); @@ -454,8 +503,8 @@ final class FollowupAudioStore if (!(int) $task['purged_at']) { Db::name('followup_audio_task')->where('id', $id)->update([ 'extraction_cipher' => '', 'review_cipher' => '', 'pipeline_cipher' => null, 'file_name' => '已清理录音', 'purged_at' => time(), - 'status' => $task['status'] === 'applied' ? 'applied' : 'expired', - 'stage' => $task['status'] === 'applied' ? 'applied' : 'expired', + 'status' => in_array($task['status'], ['applied', 'deleted'], true) ? $task['status'] : 'expired', + 'stage' => in_array($task['status'], ['applied', 'deleted'], true) ? $task['status'] : 'expired', 'lease_token' => '', 'lease_until' => 0, 'updated_at' => time(), 'version' => (int) $task['version'] + 1, ]); $counts['tasks_purged']++; diff --git a/server/sql/2026_10_12_followup_audio_soft_delete.sql b/server/sql/2026_10_12_followup_audio_soft_delete.sql new file mode 100644 index 000000000..50e08f19a --- /dev/null +++ b/server/sql/2026_10_12_followup_audio_soft_delete.sql @@ -0,0 +1,16 @@ +-- Apply AFTER 2026_09_29_followup_audio.sql, BEFORE deploying delete-aware application code. +-- Re-runnable. Preserve original content SHA, upload binding, provider and all encrypted snapshots. +-- Rollback: leave these additive columns/index in place and disable deletion first. +-- Old application code must NOT be restored after tombstones exist: it exposes hidden tasks. +-- Restoring the old unique key is not generally possible after same-content reuploads. +SET @fa_db = DATABASE(); +SET @fa_sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@fa_db AND TABLE_NAME='zyt_followup_audio_task' AND COLUMN_NAME='active_slot')=0, + 'ALTER TABLE zyt_followup_audio_task ADD COLUMN active_slot BIGINT UNSIGNED NOT NULL DEFAULT 0, ADD COLUMN deleted_at BIGINT UNSIGNED NOT NULL DEFAULT 0, ADD COLUMN deleted_by BIGINT UNSIGNED NOT NULL DEFAULT 0, ADD COLUMN deletion_json TEXT NULL', 'SELECT 1'); +PREPARE fa_stmt FROM @fa_sql; +EXECUTE fa_stmt; +DEALLOCATE PREPARE fa_stmt; +SET @fa_sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=@fa_db AND TABLE_NAME='zyt_followup_audio_task' AND INDEX_NAME='uk_content_profile' AND COLUMN_NAME='active_slot')=0, + 'ALTER TABLE zyt_followup_audio_task DROP INDEX uk_content_profile, ADD UNIQUE KEY uk_content_profile (diagnosis_id,sha256,model_key,active_slot)', 'SELECT 1'); +PREPARE fa_stmt FROM @fa_sql; +EXECUTE fa_stmt; +DEALLOCATE PREPARE fa_stmt; diff --git a/server/tests/FollowupAudioCoreTest.php b/server/tests/FollowupAudioCoreTest.php index b0df8868d..a02f39bff 100644 --- a/server/tests/FollowupAudioCoreTest.php +++ b/server/tests/FollowupAudioCoreTest.php @@ -122,6 +122,10 @@ try { for ($i = 0; $i < 2; $i++) { foreach (explode(';', $migration) as $statement) { if (trim($statement) !== '') { $stmt = $pdo->query($statement); $stmt->closeCursor(); } } } + $migration = preg_replace('/^--.*$/m', '', file_get_contents(dirname(__DIR__) . '/sql/2026_10_12_followup_audio_soft_delete.sql')); + for ($i = 0; $i < 2; $i++) { + foreach (explode(';', $migration) as $statement) { if (trim($statement) !== '') { $stmt = $pdo->query($statement); $stmt->closeCursor(); } } + } $expect((int) Db::name('followup_audio_mutex')->count() === 2, 'Migration is rerunnable'); $expect(count(array_filter(Fields::catalog()['diagnosis'], static fn ($f) => $f['key'] === 'status')) === 0, 'Ownership/status fields are not writable'); $catalog = array_column(Fields::catalog()['diagnosis'], null, 'key'); diff --git a/server/tests/FollowupAudioDeleteTest.php b/server/tests/FollowupAudioDeleteTest.php new file mode 100644 index 000000000..3633ee909 --- /dev/null +++ b/server/tests/FollowupAudioDeleteTest.php @@ -0,0 +1,200 @@ +withServer(['REQUEST_METHOD' => $method]); + $request = $method === 'GET' ? $request->withGet($body) : $request->withPost($body); + $controller = (new ReflectionClass(FollowupAudioController::class))->newInstanceWithoutConstructor(); + // The endpoint itself reloads the current actor; injected info must not grant root rights. + foreach (['request' => $request, 'adminId' => $actor, 'adminInfo' => ['root' => 1]] as $key => $value) { + $property = new ReflectionProperty($controller, $key); + $property->setAccessible(true); $property->setValue($controller, $value); + } + return $controller->$action()->getData(); +} + +if (($argv[1] ?? '') === '--request') { + $path = $argv[2] ?? ''; + if (!preg_match('#^/private/tmp/fa_pipeline_[a-f0-9]{12}/controller-child-[a-f0-9]+\.json$#D', $path)) { + throw new RuntimeException('Disposable child input required'); + } + $input = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); + if ($input['connection']['hostname'] !== '127.0.0.1' || (int) $input['connection']['hostport'] !== 23319 + || !preg_match('/^fa_pipeline_[a-f0-9]{12}$/D', $input['connection']['database'])) { + throw new RuntimeException('Disposable child connection required'); + } + new think\App(); // Deliberately not initialized: no application .env/config discovery. + $manager = new think\DbManager(); + $manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => $input['connection']]]); + Container::getInstance()->instance('think\DbManager', $manager); + $config = new think\Config(); $config->set($input['settings'], 'followup_audio'); + Container::getInstance()->instance('config', $config); + file_put_contents($path . '.ready', 'ready'); + $deadline = microtime(true) + 15; + while (!is_file($input['barrier'])) { + if (microtime(true) > $deadline) { throw new RuntimeException('Child barrier timeout'); } + usleep(10000); + } + echo json_encode(followupDeleteRequest($input['action'], $input['body']), JSON_THROW_ON_ERROR) . PHP_EOL; + exit(0); +} + +$f = followupAudioTestDatabase(['preview_only' => false]); +$checks = 0; +$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; }; +$success = static function (array $r) use ($expect): array { $expect($r['code'] === 1, json_encode($r, JSON_UNESCAPED_UNICODE)); return $r['data']; }; +$reject = static function (array $r, string $message) use ($expect): void { $expect($r['code'] === 0 && $r['msg'] === $message, json_encode($r, JSON_UNESCAPED_UNICODE)); }; +$body = static fn (array $t): array => ['id' => $t['id'], 'version' => $t['version']]; +$clinical = static function (): string { + $rows = []; + foreach (['tcm_diagnosis','tcm_blood_record','patient_diet_record','patient_exercise_record','tracking_note','followup_audio_audit'] as $table) { + $rows[$table] = Db::name($table)->order('id')->select()->toArray(); + } + return hash('sha256', json_encode($rows)); +}; +$make = static function (bool $review = true) use ($f, $success, $expect): array { + static $serial = 0; $serial++; + $pcm = str_repeat(pack('v', 1000 + $serial), 16000); + $path = $f['private'] . '/delete-synthetic-' . $serial . '.wav'; + file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' + . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($pcm)) . $pcm); chmod($path, 0600); + $upload = followupAudioTestUpload($f, $path, 'synthetic.wav'); + $task = Store::create($upload, '2026-09-29 10:00:00', 'qwen', 1, $f['actor']); + if ($review) { + $claim = Store::claim(); $expect((int) $claim['id'] === $task['id'], 'claim exact synthetic task'); + $expect(Store::complete($task['id'], $claim['lease_token'], ['summary' => 'Synthetic', 'transcript' => '今天合成症状', + 'uncertainties' => [], 'items' => [['kind' => 'diagnosis', 'values' => ['symptoms' => '合成症状'], + 'record_date' => '2026-09-29', 'date_text' => '今天', 'evidence' => [['text' => '今天合成症状']]]]]), 'complete'); + } + return $success(followupDeleteRequest('detail', ['id' => $task['id']], 1, 'GET')); +}; +$parallel = static function (array $requests) use ($f, $expect): array { + $jobs = []; $barrier = $f['private'] . '/barrier-' . bin2hex(random_bytes(6)); + try { + foreach ($requests as [$action, $payload]) { + $path = $f['private'] . '/controller-child-' . bin2hex(random_bytes(6)) . '.json'; + file_put_contents($path, json_encode(['connection' => Db::connect()->getConfig(), 'settings' => config('followup_audio'), + 'action' => $action, 'body' => $payload, 'barrier' => $barrier], JSON_THROW_ON_ERROR)); chmod($path, 0600); + $pipes = []; $process = proc_open([PHP_BINARY, __FILE__, '--request', $path], [0 => ['pipe','r'],1 => ['pipe','w'],2 => ['pipe','w']], $pipes); + if (!is_resource($process)) { throw new RuntimeException('Child launch failed'); } + fclose($pipes[0]); $jobs[] = [$process,$pipes,$path]; + } + $deadline = microtime(true) + 15; + while (count(array_filter($jobs, static fn ($j): bool => is_file($j[2] . '.ready'))) !== count($jobs)) { + if (microtime(true) > $deadline) { throw new RuntimeException('Child startup timeout'); } usleep(10000); + } + file_put_contents($barrier, 'go'); + } finally { + if (!is_file($barrier)) { file_put_contents($barrier, 'go'); } + $results = []; + foreach ($jobs as [$process,$pipes]) { + $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($process); + $expect($exit === 0 && $stderr === '', 'child clean: ' . $stderr); + $results[] = json_decode(trim($stdout), true, 512, JSON_THROW_ON_ERROR); + } + } + return $results; +}; +try { + $detail = $make(); $original = Store::task($detail['id']); $snapshot = $clinical(); + $expect($detail['can_delete'] && $detail['delete_block_reason'] === '', 'root can delete review'); + $other = $success(followupDeleteRequest('detail', ['id' => $detail['id']], 2, 'GET')); + $expect(!$other['can_delete'] && $other['delete_block_reason'] !== '', 'nonowner capability denied'); + $reject(followupDeleteRequest('delete', $body($detail), 2), '仅上传者或超级管理员可删除录音'); + $reject(followupDeleteRequest('delete', $body($detail), 3), '诊单不存在或无权操作'); + Db::name('admin')->where('id', 1)->update(['disable' => 1]); + $reject(followupDeleteRequest('delete', $body($detail)), '账号已停用或无权访问'); + Db::name('admin')->where('id', 1)->update(['disable' => 0]); + $reject(followupDeleteRequest('delete', ['id' => $detail['id'], 'version' => $detail['version'] + 1]), 'FOLLOWUP_AUDIO_VERSION_CONFLICT'); + $reject(followupDeleteRequest('delete', $body($detail), 1, 'GET'), '请求方式错误'); + $reject(followupDeleteRequest('delete', $body($detail) + ['force' => true]), '请求包含不支持的字段'); + $deleted = $success(followupDeleteRequest('delete', $body($detail))); + $expect($deleted['status'] === 'deleted' && $deleted['version'] === $detail['version'] + 1, 'tombstone response'); + $row = Db::name('followup_audio_task')->where('id', $detail['id'])->find(); + foreach (['sha256','upload_id','upstream_ids_json','extraction_cipher','review_cipher','pipeline_cipher','expires_at'] as $key) { + $expect($row[$key] === $original[$key], 'immutable retained ' . $key); + } + $expect((int) $row['active_slot'] === $detail['id'] && (int) $row['deleted_by'] === 1 && (int) $row['deleted_at'] > 0 + && json_decode($row['deletion_json'], true)['previous_status'] === 'review', 'deletion audit metadata'); + $expect(is_file(app\common\service\followupaudio\FollowupAudioUpload::path(app\common\service\followupaudio\FollowupAudioUpload::session($row['upload_id']))), 'audio retained'); + $items = $success(followupDeleteRequest('lists', ['diagnosis_id' => 1], 1, 'GET'))['items']; + $expect(!in_array($detail['id'], array_column($items,'id'), true), 'deleted hidden'); + foreach (['detail','audio','retry','saveDraft','apply','delete'] as $action) { + $payload = in_array($action,['detail','audio','retry'],true) ? ['id' => $detail['id']] : $body($detail); + if (in_array($action,['saveDraft','apply'],true)) { $payload['items'] = $detail['items']; } + $reject(followupDeleteRequest($action,$payload,1,in_array($action,['detail','audio'],true)?'GET':'POST'), 'FOLLOWUP_AUDIO_TASK_UNAVAILABLE'); + } + $create = ['diagnosis_id' => 1,'upload_id' => $row['upload_id'],'recorded_at' => '2026-09-29 10:00:00','model_key' => 'qwen']; + $reject(followupDeleteRequest('create',$create), 'FOLLOWUP_AUDIO_UPLOAD_ALREADY_USED'); + $upload = followupAudioTestUpload($f, $f['private'] . '/delete-synthetic-1.wav','same-content.wav'); + $create['upload_id'] = $upload['id']; $new = $success(followupDeleteRequest('create',$create)); + $expect(!$new['reused'] && $new['id'] !== $detail['id'] && Store::task($new['id'])['sha256'] === $row['sha256'], 'fresh identical upload is new inference'); + $repeat = $success(followupDeleteRequest('create',$create)); $expect($repeat['reused'] && $repeat['id'] === $new['id'], 'new live dedup retained'); + $expect($clinical() === $snapshot, 'no clinical or adoption audit changed'); + // Keep the queue clear for following fixtures, without executing any upstream provider. + Db::name('followup_audio_task')->where('id',$new['id'])->update(['status'=>'failed']); + foreach (['queued','running','needs_reconciliation','applied'] as $status) { + $t = $make(); Db::name('followup_audio_task')->where('id',$t['id'])->update(['status'=>$status]); + $fresh = $success(followupDeleteRequest('detail',['id'=>$t['id']],1,'GET')); + $expect(!$fresh['can_delete'], 'blocked status '.$status); + $reject(followupDeleteRequest('delete',$body($t)), $fresh['delete_block_reason']); + Db::name('followup_audio_task')->where('id',$t['id'])->update(['status'=>'failed']); + } + $t=$make(); Db::name('followup_audio_task')->where('id',$t['id'])->update(['status'=>'failed','upstream_started_at'=>time()]); + $reject(followupDeleteRequest('delete',$body($t)),'上游结果尚未确定,须先核对'); + $t=$make(); Db::name('followup_audio_task')->where('id',$t['id'])->update(['pipeline_cipher'=>'corrupt']); + $reject(followupDeleteRequest('delete',$body($t)),'上游结果尚未确定,须先核对'); + $t=$make(); Db::name('followup_audio_task')->where('id',$t['id'])->update(['status'=>'failed']); + $success(followupDeleteRequest('delete',$body($t))); + $t=$make(); Db::name('followup_audio_audit')->insert(['task_id'=>$t['id'],'item_id'=>'synthetic-audit', + 'diagnosis_id'=>1,'actor_id'=>1,'kind'=>'diagnosis','table_name'=>'tcm_diagnosis','record_id'=>1,'action'=>'update', + 'source_cipher'=>'retained','before_cipher'=>'retained','after_cipher'=>'retained','created_at'=>time()]); + $reject(followupDeleteRequest('delete',$body($t)),'已采用或已有采用审计的录音须保留'); + $t=$make(); Db::name('tcm_diagnosis')->where('id',1)->update(['patient_id'=>999]); + $reject(followupDeleteRequest('delete',$body($t)),'诊单患者归属已变化,不能访问原回访录音'); + Db::name('tcm_diagnosis')->where('id',1)->update(['patient_id'=>101]); + $t=$make(); Db::name('followup_audio_task')->where('id',$t['id'])->update(['actor_id'=>2]); + $fresh=$success(followupDeleteRequest('detail',['id'=>$t['id']],2,'GET')); $expect($fresh['can_delete'],'uploader capability'); + Db::name('system_role_menu')->where('role_id',2)->where('menu_id',1)->delete(); + $reject(followupDeleteRequest('delete',$body($t),2),'诊单不存在或无权操作'); + Db::name('system_role_menu')->insert(['role_id'=>2,'menu_id'=>1]); + $success(followupDeleteRequest('delete',$body($t),2)); + $t=$make(); $results=$parallel([['delete',$body($t)],['delete',$body($t)]]); + $codes=array_column($results,'code');sort($codes);$expect($codes===[0,1],'parallel delete one winner'); + $t=$make(); foreach($t['items'] as &$item){$item['selected']=true;$item['needs_review']=false;}unset($item); + $before=Db::name('tcm_diagnosis')->where('id',1)->value('symptoms'); + $results=$parallel([['apply',$body($t)+['items'=>$t['items']]],['delete',$body($t)]]); + $codes=array_column($results,'code');sort($codes);$expect($codes===[0,1],'apply-delete exactly one winner'); + $race=Db::name('followup_audio_task')->where('id',$t['id'])->find(); + $count=(int)Db::name('followup_audio_audit')->where('task_id',$t['id'])->count(); + $expect(($race['status']==='deleted' && $count===0 && Db::name('tcm_diagnosis')->where('id',1)->value('symptoms')===$before) + || ($race['status']==='applied' && $count===1),'race result consistent with clinical writes'); + if($race['status']==='applied'){$reject(followupDeleteRequest('delete',['id'=>$t['id'],'version'=>(int)$race['version']]),'已采用或已有采用审计的录音须保留');} + // Existing retention cleanup still purges tombstones, without making them visible again. + Db::name('followup_audio_task')->where('id',$detail['id'])->update(['expires_at'=>time()-1]); + Db::name('followup_audio_upload')->where('id',$row['upload_id'])->update(['expires_at'=>time()-1]); + $counts=Store::cleanupExpired(); $purged=Db::name('followup_audio_task')->where('id',$detail['id'])->find(); + $expect($counts['tasks_purged']===1 && $purged['status']==='deleted' && (int)$purged['purged_at']>0 && $purged['review_cipher']==='', 'retention purge keeps tombstone hidden'); + $reject(followupDeleteRequest('detail',['id'=>$detail['id']],1,'GET'),'FOLLOWUP_AUDIO_TASK_UNAVAILABLE'); + echo "FOLLOWUP_AUDIO_DELETE assertions={$checks} PASS mysql23319=1 rbac=1 version=1 immutable_retention=1 same_content_new_upload=1 old_upload_denied=1 direct_access_denied=1 blocked_states=1 parallel_delete_one_winner=1 apply_delete_one_winner=1 retention_cleanup=1 upstream_calls=0\n"; +} finally { followupAudioTestDatabaseCleanup($f); } diff --git a/server/tests/FollowupAudioEndpointContractTest.php b/server/tests/FollowupAudioEndpointContractTest.php index b0c314c8b..2a112036d 100644 --- a/server/tests/FollowupAudioEndpointContractTest.php +++ b/server/tests/FollowupAudioEndpointContractTest.php @@ -25,6 +25,8 @@ expectEndpoint(str_contains($access, 'MyPatientLogic::applyScope($query, $actor, expectEndpoint(str_contains($access, 'tcm.diagnosis/dailyRecord'), 'daily permission 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($middleware, "'apply', 'retry', 'audio', 'delete'"), 'delete uses explicit edit permission route group'); +expectEndpoint(str_contains($controller, "Logic::delete(\$this->positive(\$p, 'id'), \$this->positive(\$p, 'version')"), 'delete requires task and optimistic version'); expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review items'); expectEndpoint(str_contains($controller, "'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW'"), 'typed stale review response'); expectEndpoint(substr_count($controller, 'Logic::requireEnabled(true)') >= 3, 'upload capability gate'); diff --git a/server/tests/fixtures/followup_audio/database.php b/server/tests/fixtures/followup_audio/database.php index 371f6923e..a4b155611 100644 --- a/server/tests/fixtures/followup_audio/database.php +++ b/server/tests/fixtures/followup_audio/database.php @@ -71,6 +71,11 @@ function followupAudioTestDatabase(array $settings = []): array foreach (explode(';', $migration) as $statement) { if (trim($statement) !== '') { $stmt = $pdo->query($statement); $stmt->closeCursor(); } } } + $migration = preg_replace('/^--.*$/m', '', file_get_contents(dirname(__DIR__, 3) . '/sql/2026_10_12_followup_audio_soft_delete.sql')); + for ($i = 0; $i < 2; $i++) { + foreach (explode(';', $migration) as $statement) { if (trim($statement) !== '') { $stmt = $pdo->query($statement); $stmt->closeCursor(); } } + } + return ['pdo' => $pdo, 'database' => $database, 'private' => $private, 'config' => $config, 'actor' => ['root' => 1, 'admin_id' => 1, 'id' => 1, 'name' => 'Synthetic'], 'diagnosis_id' => 1]; }