diff --git a/admin/src/api/mobileRecording.ts b/admin/src/api/mobileRecording.ts index 7d51cd659..15c19d5e8 100644 --- a/admin/src/api/mobileRecording.ts +++ b/admin/src/api/mobileRecording.ts @@ -1,5 +1,7 @@ import request from '@/utils/request' +export type MobileRecordingUploadState = 'not_uploaded' | 'uploading' | 'partial' | 'complete' + export interface MobileRecordingMetadata { id: number diagnosis_id: number @@ -7,9 +9,12 @@ export interface MobileRecordingMetadata { recorded_at: number duration_ms: number bound_by: string - playback_location: 'original_device' + playback_location: string created_at: number updated_at: number + audio_available?: boolean + transcript_available?: boolean + upload_state?: MobileRecordingUploadState } export interface MobileRecordingPage { @@ -19,10 +24,100 @@ export interface MobileRecordingPage { page_size: number } +export interface MobileRecordingTranscript { + text: string + sha256: string + size_bytes: number +} + +interface Envelope { code: number; msg?: string; data: T } +export class MobileRecordingMediaError extends Error { + constructor(message: string, public readonly code = 0) { + super(message) + this.name = 'MobileRecordingMediaError' + } +} + +const requestOptions = { withToken: true, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 } +const mediaOptions = { ...requestOptions, isTransformResponse: false } +const audioExtensions: Record = { + 'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/wave': 'wav', + 'audio/flac': 'flac', 'audio/x-flac': 'flac', 'audio/ogg': 'ogg', + 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a', 'audio/amr': 'amr' +} + +function checkSignal(signal?: AbortSignal) { + if (signal?.aborted) throw new DOMException('请求已取消', 'AbortError') +} + +function unwrap(response: Envelope): T { + if (!response || response.code !== 1) { + const code = Number(response?.code) || 0 + const message = code === -1 ? '登录已失效,请重新登录后查看。' + : code === 10 ? '请先完成企业微信绑定后查看。' + : (typeof response?.msg === 'string' ? response.msg.slice(0, 160) : '') || '无法读取内容,请确认患者权限后重试。' + throw new MobileRecordingMediaError(message, code) + } + return response.data +} + +function mediaParams(bindingId: number, diagnosisId: number) { + if (!Number.isSafeInteger(bindingId) || bindingId <= 0 || !Number.isSafeInteger(diagnosisId) || diagnosisId <= 0) { + throw new MobileRecordingMediaError('录音关联标识无效,请刷新列表。') + } + return { binding_id: bindingId, diagnosis_id: diagnosisId } +} + export function mobileRecordingPatientLists(params: { diagnosis_id: number page_no: number page_size: number -}): Promise { - return request.get({ url: '/tcm.mobileRecording/patientLists', params }) +}, signal?: AbortSignal): Promise { + return request.get({ url: '/tcm.mobileRecording/patientLists', params, signal }, requestOptions) +} + +/** Existing request hooks attach the current token header; neither the media URL nor Blob URL contains it. */ +export async function mobileRecordingAudio(bindingId: number, diagnosisId: number, signal?: AbortSignal, + onProgress?: (loaded: number, total?: number) => void): Promise { + checkSignal(signal) + const blob = await request.get({ + url: '/tcm.mobileRecordingMedia/audio', params: mediaParams(bindingId, diagnosisId), + responseType: 'blob', signal, timeout: 300000, + onDownloadProgress: event => onProgress?.(event.loaded, event.total) + }, mediaOptions) + checkSignal(signal) + if (!(blob instanceof Blob)) throw new MobileRecordingMediaError('音频响应格式不正确,请重试。') + const mime = blob.type.toLowerCase().split(';')[0].trim() + if (mime.includes('json')) { + let response: Envelope + try { response = JSON.parse(await blob.text()) } + catch { throw new MobileRecordingMediaError('音频响应格式不正确,请重试。') } + checkSignal(signal) + unwrap(response) + throw new MobileRecordingMediaError('服务器未返回可播放的音频,请刷新列表。') + } + if (!audioExtensions[mime] || blob.size <= 0 || blob.size > 512 * 1024 * 1024) { + throw new MobileRecordingMediaError('音频格式或大小不符合要求,请重新上传后查看。') + } + return blob +} + +export async function mobileRecordingTranscript(bindingId: number, diagnosisId: number, signal?: AbortSignal): Promise { + checkSignal(signal) + const response = await request.get>({ + url: '/tcm.mobileRecordingMedia/transcript', params: mediaParams(bindingId, diagnosisId), signal, timeout: 120000 + }, mediaOptions) + checkSignal(signal) + const result = unwrap(response) + if (!result || typeof result.text !== 'string' || !Number.isSafeInteger(result.size_bytes) + || result.size_bytes < 0 || result.size_bytes > 8 * 1024 * 1024) { + throw new MobileRecordingMediaError('文字响应格式不正确,请重试。') + } + return result +} + +export function mobileRecordingDownloadName(bindingId: number, kind: 'audio' | 'transcript', mime = ''): string { + const id = Number.isSafeInteger(bindingId) && bindingId > 0 ? bindingId : 0 + const extension = kind === 'transcript' ? 'txt' : audioExtensions[mime.toLowerCase().split(';')[0].trim()] || 'audio' + return `${kind === 'audio' ? 'recording' : 'transcript'}-${id}.${extension}` } diff --git a/admin/src/views/tcm/diagnosis/components/MobileRecordingPanel.vue b/admin/src/views/tcm/diagnosis/components/MobileRecordingPanel.vue index be228c84a..6614f70a5 100644 --- a/admin/src/views/tcm/diagnosis/components/MobileRecordingPanel.vue +++ b/admin/src/views/tcm/diagnosis/components/MobileRecordingPanel.vue @@ -1,23 +1,36 @@ diff --git a/admin/tests/mobile-recording-media.test.cjs b/admin/tests/mobile-recording-media.test.cjs new file mode 100644 index 000000000..40f422a82 --- /dev/null +++ b/admin/tests/mobile-recording-media.test.cjs @@ -0,0 +1,207 @@ +'use strict' +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const test = require('node:test') +const { createRequire } = require('node:module') +const root = path.resolve(process.env.MOBILE_RECORDING_ADMIN_ROOT || path.join(__dirname, '..')) +const sourceRoot = path.resolve(process.env.MOBILE_RECORDING_SOURCE_ROOT || root) +const requireAdmin = createRequire(path.join(root, 'package.json')) +const vue = requireAdmin('vue') +const ts = requireAdmin('typescript') +const sfc = requireAdmin('@vue/compiler-sfc') +const apiSource = fs.readFileSync(path.join(sourceRoot, 'src/api/mobileRecording.ts'), 'utf8') +const filename = path.join(sourceRoot, 'src/views/tcm/diagnosis/components/MobileRecordingPanel.vue') +const parsed = sfc.parse(fs.readFileSync(filename, 'utf8'), { filename }) +assert.deepEqual(parsed.errors, []) +const script = sfc.compileScript(parsed.descriptor, { id: 'mobile-media-test' }) +const template = sfc.compileTemplate({ source: parsed.descriptor.template.content, filename, id: 'mobile-media-test', compilerOptions: { bindingMetadata: script.bindings } }) +assert.deepEqual(template.errors, []) +for (const style of parsed.descriptor.styles) assert.deepEqual(sfc.compileStyle({ source: style.content, filename, id: 'mobile-media-test', scoped: true }).errors, []) + +function execute(source, dependencies, globals = {}) { + const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText + const module = { exports: {} } + new Function('require', 'module', 'exports', ...Object.keys(globals), output)(dependencies, module, module.exports, ...Object.values(globals)) + return module.exports +} +function apiWith(get) { return execute(apiSource, name => { assert.equal(name, '@/utils/request'); return { default: { get } } }) } +const apiTypes = apiWith(() => { throw new Error('Unexpected live API call') }) +const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } } +const flush = async () => { for (let i = 0; i < 12; i++) { await Promise.resolve(); await vue.nextTick() } } +const row = (id = 1, diagnosisId = 101, extra = {}) => ({ id, diagnosis_id: diagnosisId, source_platform: 'android_bcr', recorded_at: 1700000000000, duration_ms: 12000, bound_by: '合成账号', playback_location: 'original_device', created_at: 1, updated_at: 1, ...extra }) +const page = (lists, total = lists.length) => ({ lists, count: total, page_no: 1, page_size: 20 }) +function fixture(overrides = {}, options = {}) { + const calls = [], created = [], revoked = [], roots = [], hooks = {} + const deps = { + ...apiTypes, + mobileRecordingPatientLists: async (params, signal) => { calls.push(['list', params, signal]); return page([row(1, params.diagnosis_id, { audio_available: true, transcript_available: true, upload_state: 'complete' })]) }, + mobileRecordingAudio: async (id, diagnosisId, signal, progress) => { calls.push(['audio', id, diagnosisId, signal]); progress(8, 8); return new Blob(['synthetic'], { type: 'audio/wav' }) }, + mobileRecordingTranscript: async (id, diagnosisId, signal) => { calls.push(['text', id, diagnosisId, signal]); return { text: '合成转写', sha256: 'a'.repeat(64), size_bytes: 12 } }, + ...overrides + } + const component = execute(script.content, name => { + if (name === 'vue') return { ...vue, onDeactivated: callback => { hooks.deactivate = callback }, onActivated: callback => { hooks.activate = callback } } + if (name === '@/api/mobileRecording') return deps + throw new Error('Unexpected dependency: ' + name) + }, { URL: { createObjectURL: blob => { const url = `blob:synthetic-${created.length}`; created.push({ url, blob }); return url }, revokeObjectURL: url => revoked.push(url) } }).default + component.render = options.render ? execute(template.code, name => { assert.equal(name, 'vue'); return vue }).render : () => null + const renderer = vue.createRenderer({ + createElement: tag => ({ tag, children: [], props: {}, pause() { this.paused = true }, removeAttribute(name) { delete this.props[name] }, load() { this.reloaded = true } }), + createText: text => ({ text }), createComment: text => ({ comment: text }), + setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text; node.children = [] }, + patchProp: (node, name, prev, next) => { node.props[name] = next }, + parentNode: node => node.parent || null, nextSibling: () => null, + insert(node, parent) { node.parent = parent; (parent.children ||= []).push(node) }, + remove(node) { if (node.parent) node.parent.children = node.parent.children.filter(child => child !== node) } + }) + const props = vue.reactive({ diagnosisId: 101 }) + const app = renderer.createApp({ setup: () => () => vue.h(component, props) }) + const slotComponent = vue.defineComponent({ setup: (_, { slots }) => () => vue.h('div', slots.default?.()) }) + for (const name of ['ElButton', 'ElAlert', 'ElProgress', 'ElEmpty', 'ElTag', 'ElPagination', 'ElTableColumn']) app.component(name, slotComponent) + app.component('ElTable', { render: () => null }) + app.component('ElDrawer', { props: ['modelValue'], setup: (p, { slots }) => () => p.modelValue ? vue.h('section', slots.default?.()) : null }) + app.directive('loading', {}) + const rootNode = { tag: 'root', children: [] }; roots.push(rootNode); app.mount(rootNode) + return { state: app._instance.subTree.component.setupState, props, calls, created, revoked, roots, hooks, dispose: () => app.unmount() } +} +function findNodes(node, tag, found = []) { if (node.tag === tag) found.push(node); for (const child of node.children || []) findNodes(child, tag, found); return found } + +test('audio uses existing authenticated request, fixed endpoint, diagnosis anchor, cancellable blob and long timeout', async () => { + const calls = []; const controller = new AbortController(); const progress = [] + const api = apiWith(async (config, options) => { calls.push([config, options]); config.onDownloadProgress({ loaded: 6, total: 12 }); return new Blob(['audio'], { type: 'audio/wav' }) }) + const blob = await api.mobileRecordingAudio(7, 101, controller.signal, (...values) => progress.push(values)) + assert.equal(blob.type, 'audio/wav'); assert.deepEqual(progress, [[6, 12]]) + const [config, options] = calls[0] + assert.equal(config.url, '/tcm.mobileRecordingMedia/audio'); assert.deepEqual(config.params, { binding_id: 7, diagnosis_id: 101 }) + assert.equal(config.responseType, 'blob'); assert.equal(config.signal, controller.signal); assert.equal(config.timeout, 300000) + assert.equal(options.withToken, true); assert.equal(options.isOpenRetry, false); assert.equal(options.isTransformResponse, false) + assert.equal(config.url.includes('token'), false); assert.equal('token' in config.params, false) +}) + +test('HTTP 200 JSON denial or non-audio payload never becomes a playable blob', async () => { + for (const code of [0, -1, 10]) { + const api = apiWith(async () => new Blob([JSON.stringify({ code, msg: '合成权限拒绝', data: [] })], { type: 'application/json' })) + await assert.rejects(api.mobileRecordingAudio(1, 101), error => error instanceof api.MobileRecordingMediaError && error.code === code) + } + for (const blob of [new Blob(['login'], { type: 'text/html' }), new Blob([], { type: 'audio/wav' }), new Blob(['{}'], { type: 'application/json' })]) { + const api = apiWith(async () => blob); await assert.rejects(api.mobileRecordingAudio(1, 101), api.MobileRecordingMediaError) + } +}) + +test('aborted audio is rejected even when the request resolves late; no request starts with an aborted signal', async () => { + let calls = 0; const wait = deferred(); const controller = new AbortController() + const api = apiWith(async () => { calls++; return wait.promise }) + const pending = api.mobileRecordingAudio(1, 101, controller.signal); controller.abort(); wait.resolve(new Blob(['audio'], { type: 'audio/wav' })) + await assert.rejects(pending, error => error.name === 'AbortError') + await assert.rejects(api.mobileRecordingAudio(1, 101, controller.signal), error => error.name === 'AbortError') + assert.equal(calls, 1) +}) + +test('transcript reads envelope, keeps plain text including empty, rejects permission and malformed success', async () => { + const text = '\n纯文本' + for (const value of [text, '']) { + const api = apiWith(async (config, options) => { assert.deepEqual(config.params, { binding_id: 1, diagnosis_id: 101 }); assert.equal(options.withToken, true); return { code: 1, data: { text: value, sha256: 'a'.repeat(64), size_bytes: value.length } } }) + assert.equal((await api.mobileRecordingTranscript(1, 101)).text, value) + } + for (const response of [{ code: 0, msg: '无权读取', data: [] }, { code: 1, data: {} }, { code: 1, data: { text: 'x', size_bytes: 9 * 1024 * 1024 } }]) { + const api = apiWith(async () => response); await assert.rejects(api.mobileRecordingTranscript(1, 101), api.MobileRecordingMediaError) + } +}) + +test('download names only use validated identifiers and allowed extensions', () => { + assert.equal(apiTypes.mobileRecordingDownloadName(7, 'audio', 'audio/mpeg'), 'recording-7.mp3') + assert.equal(apiTypes.mobileRecordingDownloadName(7, 'transcript'), 'transcript-7.txt') + assert.equal(apiTypes.mobileRecordingDownloadName('../private', 'audio', '../../secret'), 'recording-0.audio') +}) + +test('legacy metadata remains not uploaded; four upload states and media availability stay explicit', async () => { + const f = fixture({ mobileRecordingPatientLists: async () => page([row()]) }); await flush() + assert.equal(f.state.uploadStatus(row()).label, '未上传') + for (const [state, label] of [['not_uploaded', '未上传'], ['uploading', '上传中'], ['partial', '部分已上传'], ['complete', '上传完成']]) assert.equal(f.state.uploadStatus(row(1, 101, { upload_state: state })).label, label) + f.state.openMedia(f.state.rows[0], 'audio'); await flush(); assert.equal(f.state.mediaVisible, false); assert.equal(f.created.length, 0) + f.dispose() +}) + +test('successful audio loads progress, uses only a blob URL, and closes with revoke and player stop', async () => { + const f = fixture({}, { render: true }); await flush(); f.state.openMedia(f.state.rows[0], 'audio'); await flush() + assert.equal(f.state.mediaReady, true); assert.equal(f.state.mediaLoading, false); assert.equal(f.state.downloadPercent, 100) + assert.equal(f.state.mediaUrl, 'blob:synthetic-0'); assert.equal(f.state.mediaFilename, 'recording-1.wav') + const player = f.state.audioElement; assert.ok(player) + f.state.closeMedia(); await flush() + assert.equal(player.paused, true); assert.equal(player.reloaded, true); assert.deepEqual(f.revoked, ['blob:synthetic-0']) + assert.equal(f.state.mediaUrl, ''); assert.equal(f.state.mediaRow, null); f.dispose() +}) + +test('permission errors clear media content and explicit retry can recover', async () => { + let attempt = 0 + const f = fixture({ mobileRecordingAudio: async () => { if (++attempt === 1) throw new apiTypes.MobileRecordingMediaError('患者不存在或无权操作', 0); return new Blob(['synthetic'], { type: 'audio/wav' }) } }) + await flush(); f.state.openMedia(f.state.rows[0], 'audio'); await flush() + assert.match(f.state.mediaError, /无权/); assert.equal(f.created.length, 0); assert.equal(f.state.mediaReady, false) + await f.state.loadMedia(); assert.equal(attempt, 2); assert.equal(f.state.mediaError, ''); assert.equal(f.state.mediaReady, true) + f.dispose(); assert.equal(f.revoked.length, 1) +}) + +test('diagnosis switch aborts media and late audio or progress never becomes new-patient content', async () => { + const wait = deferred(); let signal, progress + const f = fixture({ mobileRecordingAudio: async (_id, diagnosis, s, p) => { assert.equal(diagnosis, 101); signal = s; progress = p; return wait.promise } }) + await flush(); f.state.openMedia(f.state.rows[0], 'audio'); await flush(); assert.equal(f.state.mediaLoading, true) + f.props.diagnosisId = 202; await flush(); assert.equal(signal.aborted, true); assert.equal(f.state.mediaVisible, false) + progress(99, 100); wait.resolve(new Blob(['old'], { type: 'audio/wav' })); await flush() + assert.equal(f.created.length, 0); assert.equal(f.state.downloadLoaded, 0); assert.equal(f.state.rows[0].diagnosis_id, 202); f.dispose() +}) + +test('old transcript cannot overwrite a new selection and text renders as text nodes, never markup', async () => { + const wait = deferred(); let firstSignal + const raw = '\n' + const f = fixture({ + mobileRecordingPatientLists: async () => page([row(1, 101, { transcript_available: true }), row(2, 101, { transcript_available: true })]), + mobileRecordingTranscript: async (id, _diagnosis, signal) => { if (id === 1) { firstSignal = signal; return wait.promise }; return { text: raw, size_bytes: raw.length, sha256: 'a'.repeat(64) } } + }, { render: true }) + await flush(); f.state.openMedia(f.state.rows[0], 'transcript'); await flush(); f.state.openMedia(f.state.rows[1], 'transcript'); await flush() + assert.equal(firstSignal.aborted, true); assert.equal(f.state.transcriptText, raw) + const pre = findNodes(f.roots[0], 'pre'); assert.equal(pre.length, 1); assert.equal(pre[0].text, raw) + assert.equal(findNodes(f.roots[0], 'img').length, 0); assert.equal(findNodes(f.roots[0], 'script').length, 0) + wait.resolve({ text: '旧患者文字', size_bytes: 12, sha256: 'a'.repeat(64) }); await flush() + assert.equal(f.state.transcriptText, raw); assert.equal(f.created.length, 1); assert.equal(f.state.mediaFilename, 'transcript-2.txt') + f.dispose(); assert.deepEqual(f.revoked, ['blob:synthetic-0']); assert.equal(f.state.transcriptText, '') +}) + +test('closing during download and unmount abort requests without retaining stale blobs or text', async () => { + for (const finish of ['close', 'unmount']) { + const wait = deferred(); let signal + const f = fixture({ mobileRecordingTranscript: async (_id, _diagnosis, s) => { signal = s; return wait.promise } }) + await flush(); f.state.openMedia(f.state.rows[0], 'transcript'); await flush() + if (finish === 'close') f.state.closeMedia(); else f.dispose() + assert.equal(signal.aborted, true); wait.resolve({ text: 'late', size_bytes: 4, sha256: 'a'.repeat(64) }); await flush() + assert.equal(f.created.length, 0); assert.equal(f.state.transcriptText, ''); if (finish === 'close') f.dispose() + } +}) + +test('deactivation revokes loaded media and activation reloads only the current diagnosis', async () => { + const f = fixture(); await flush(); f.state.openMedia(f.state.rows[0], 'audio'); await flush() + f.hooks.deactivate(); assert.deepEqual(f.revoked, ['blob:synthetic-0']); assert.equal(f.state.rows.length, 0) + f.props.diagnosisId = 303; await flush(); assert.equal(f.state.rows.length, 0) + f.hooks.activate(); await flush(); assert.equal(f.state.rows[0].diagnosis_id, 303); f.dispose() +}) + +test('list generation cancels old requests and preserves the repaired out-of-range pagination', async () => { + let count = 21; const pages = [], signals = [] + const f = fixture({ mobileRecordingPatientLists: async (params, signal) => { + pages.push(params.page_no); signals.push(signal) + const start = (params.page_no - 1) * 20 + return page(Array.from({ length: Math.max(0, Math.min(20, count - start)) }, (_, i) => row(start + i + 1, params.diagnosis_id)), count) + } }) + await flush(); f.state.page = 2; await f.state.load(); assert.equal(f.state.rows.length, 1) + count = 20; await f.state.load(); assert.equal(f.state.page, 1); assert.equal(f.state.rows.length, 20); assert.equal(f.state.total, 20) + assert.deepEqual(pages, [1, 2, 2, 1]); assert.ok(signals.slice(0, -1).every(signal => signal.aborted)) + f.dispose(); assert.equal(signals.at(-1).aborted, true) +}) + +test('late list response after patient switch cannot restore old rows or trigger a correction fetch', async () => { + const requests = [] + const f = fixture({ mobileRecordingPatientLists: (params, signal) => { const wait = deferred(); requests.push({ params, signal, ...wait }); return wait.promise } }) + assert.equal(requests.length, 1); f.props.diagnosisId = 202; await flush(); assert.equal(requests[0].signal.aborted, true) + requests[1].resolve(page([row(2, 202)])); await flush(); requests[0].resolve(page([row(1, 101)])); await flush() + assert.equal(f.state.rows[0].diagnosis_id, 202); assert.equal(requests.length, 2); f.dispose() +}) diff --git a/server/app/adminapi/controller/tcm/MobileRecordingMediaController.php b/server/app/adminapi/controller/tcm/MobileRecordingMediaController.php new file mode 100644 index 000000000..6ba53dd0d --- /dev/null +++ b/server/app/adminapi/controller/tcm/MobileRecordingMediaController.php @@ -0,0 +1,52 @@ +handle(true, fn (array $p) => Logic::prepare($p, $this->adminId)); } + public function complete() { return $this->handle(true, fn (array $p) => Logic::complete($p, $this->adminId)); } + public function status() { return $this->handle(false, fn (array $p) => Logic::status($p, $this->adminId)); } + public function transcript() { return $this->handle(false, fn (array $p) => Logic::transcript($p, $this->adminId)); } + public function audio() + { + return $this->handle(false, fn (array $p) => Logic::audio($p, $this->adminId, (string) $this->request->header('range', ''))); + } + public function chunk() + { + return $this->handle(true, function (array $p) { + $type = strtolower(trim(explode(';', (string) $this->request->header('content-type', ''))[0])); + if ($type !== 'application/octet-stream') { throw new DomainException('分片必须使用 application/octet-stream'); } + return Logic::chunk($p, $this->request->getInput(), $this->adminId); + }, true); + } + + private function handle(bool $post, callable $operation, bool $binary = false) + { + $headers = ['Cache-Control' => 'private, no-store, max-age=0', 'X-Content-Type-Options' => 'nosniff']; + $rejected = ['error_code' => 'request_rejected', 'retryable' => false]; + if ($post ? !$this->request->isPost() : !$this->request->isGet()) { return $this->fail('请求方式错误', $rejected)->header($headers); } + try { + MobileRecordingLogic::actor($this->adminId); + if ($this->request->file()) { throw new DomainException('请使用分片接口上传,不接受文件名或表单附件'); } + $params = $binary || !$post ? $this->request->get('', null, null) : $this->request->post('', null, null); + $result = $operation($params); + return ($result instanceof Response ? $result : $this->data($result))->header($headers); + } catch (MobileRecordingResyncException $e) { + return $this->fail($e->getMessage(), ['error_code' => MobileRecordingResyncException::ERROR_CODE, 'retryable' => true])->header($headers); + } catch (DomainException $e) { return $this->fail($e->getMessage(), $rejected)->header($headers); } + catch (\Throwable $e) { + return $this->fail('手机录音上传服务暂不可用,请稍后重试或联系管理员', + ['error_code' => 'temporarily_unavailable', 'retryable' => true])->header($headers); + } + } +} diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php index b7f7a2aee..8306238b6 100755 --- a/server/app/adminapi/http/middleware/AuthMiddleware.php +++ b/server/app/adminapi/http/middleware/AuthMiddleware.php @@ -145,7 +145,18 @@ class AuthMiddleware return JsonService::fail('权限不足,无法访问或操作'); } - // Metadata-only mobile links always share the patient page grant, even before menu registration. + // Private media endpoints must never use the unregistered-route compatibility bypass. + if (str_starts_with($accessUri, 'tcm.mobilerecordingmedia/')) { + $action = substr($accessUri, strlen('tcm.mobilerecordingmedia/')); + $uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []); + if (in_array($action, ['prepare', 'chunk', 'complete', 'status', 'audio', 'transcript'], true) + && in_array('firstvisit.mypatient/lists', $uris, true)) { + return $next($request); // Service rechecks live actor, binding and patient scope on every request. + } + return JsonService::fail('权限不足,无法访问我的患者录音内容'); + } + + // Mobile links always share the patient page grant, even before menu registration. if (str_starts_with($accessUri, 'tcm.mobilerecording/')) { $action = substr($accessUri, strlen('tcm.mobilerecording/')); $uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []); diff --git a/server/app/adminapi/logic/tcm/MobileRecordingLogic.php b/server/app/adminapi/logic/tcm/MobileRecordingLogic.php index d29518479..93ecab413 100644 --- a/server/app/adminapi/logic/tcm/MobileRecordingLogic.php +++ b/server/app/adminapi/logic/tcm/MobileRecordingLogic.php @@ -6,11 +6,12 @@ namespace app\adminapi\logic\tcm; use app\adminapi\logic\firstvisit\MyPatientLogic; use app\common\model\tcm\Diagnosis; +use app\common\service\mobilerecording\MobileRecordingStorage; use DomainException; use think\db\Query; use think\facade\Db; -/** Metadata only. This service never accepts, reads or uploads a recording or transcript. */ +/** Backward-compatible metadata API; media lifecycle follows the binding transaction. */ final class MobileRecordingLogic { public const PAGE_PERMISSION = 'firstvisit.myPatient/lists'; @@ -39,7 +40,8 @@ final class MobileRecordingLogic public static function bind(array $params, int $adminId): array { $p = self::bindingInput($params); - return Db::transaction(static function () use ($p, $adminId): array { + $garbage = []; + $result = Db::transaction(static function () use ($p, $adminId, &$garbage): array { // Serialize mutations for this actor, including the first INSERT for an absent key. $info = self::actor($adminId, true); $existing = self::ownedQuery($adminId, $p)->lock(true)->find(); @@ -52,6 +54,9 @@ final class MobileRecordingLogic $now = time(); if ($existing) { $id = (int) $existing['id']; + if ((int) $existing['diagnosis_id'] !== $p['diagnosis_id']) { + $garbage = MobileRecordingMediaLogic::bindingMoved($id, $p['diagnosis_id']); + } self::ownedQuery($adminId, $p)->where('id', $id)->update($p + ['updated_at' => $now]); $row = array_merge($existing, $p, ['updated_at' => $now]); } else { @@ -60,21 +65,27 @@ final class MobileRecordingLogic } return ['binding' => self::deviceRow($row, $patients[$p['diagnosis_id']])]; }); + MobileRecordingStorage::cleanup($garbage); + return $result; } public static function unbind(array $params, int $adminId): array { self::only($params, ['installation_id', 'recording_key']); $identity = self::identity($params); - return Db::transaction(static function () use ($identity, $adminId): array { + $garbage = []; + $result = Db::transaction(static function () use ($identity, $adminId, &$garbage): array { $info = self::actor($adminId, true); $row = self::ownedQuery($adminId, $identity)->lock(true)->find(); if ($row) { self::diagnosis((int) $row['diagnosis_id'], $adminId, $info, true); + $garbage = MobileRecordingMediaLogic::bindingRemoved((int) $row['id']); self::ownedQuery($adminId, $identity)->where('id', (int) $row['id'])->delete(); } return ['unbound' => true]; }); + MobileRecordingStorage::cleanup($garbage); + return $result; } public static function deviceLists(array $params, int $adminId): array @@ -111,10 +122,12 @@ final class MobileRecordingLogic $rows = $query->leftJoin([$adminTable => 'a'], 'a.id = b.admin_id') ->field('b.id,b.diagnosis_id,b.source_platform,b.recorded_at,b.duration_ms,b.created_at,b.updated_at,a.name AS bound_by') ->order('b.recorded_at', 'desc')->order('b.id', 'desc')->page($page, $size)->select()->toArray(); - $items = array_map(static function (array $row): array { + $media = MobileRecordingMediaLogic::summaries(array_column($rows, 'id')); + $items = array_map(static function (array $row) use ($media): array { $dto = self::metadata($row); $dto['bound_by'] = trim((string) ($row['bound_by'] ?? '')) ?: '已停用账号'; - $dto['playback_location'] = 'original_device'; + $dto += $media[$row['id']]; + $dto['playback_location'] = $dto['audio_available'] ? 'server' : 'original_device'; return $dto; }, $rows); return ['lists' => $items, 'count' => $count, 'page_no' => $page, 'page_size' => $size]; @@ -141,7 +154,7 @@ final class MobileRecordingLogic ->where('installation_id', $identity['installation_id'])->where('recording_key', $identity['recording_key']); } - private static function diagnosis(int $id, int $adminId, array $info, bool $lock = false): array + public static function diagnosis(int $id, int $adminId, array $info, bool $lock = false): array { $row = Db::name('tcm_diagnosis')->where('id', $id)->whereNull('delete_time')->where('status', 1) ->lock($lock)->find(); @@ -187,7 +200,7 @@ final class MobileRecordingLogic return substr($digits, 0, 3) . str_repeat('*', $length - 7) . substr($digits, -4); } - private static function identity(array $params): array + public static function identity(array $params): array { $key = $params['recording_key'] ?? null; if (!is_string($key) || !preg_match('/^[0-9a-f]{64}$/D', $key)) { @@ -212,7 +225,7 @@ final class MobileRecordingLogic ]; } - private static function integer($value, int $min, int $max, string $label): int + public static function integer($value, int $min, int $max, string $label): int { if (!(is_int($value) || is_string($value)) || !preg_match('/^(0|[1-9][0-9]{0,14})$/D', (string) $value) || (int) $value < $min || (int) $value > $max) { @@ -221,7 +234,7 @@ final class MobileRecordingLogic return (int) $value; } - private static function only(array $params, array $allowed): void + public static function only(array $params, array $allowed): void { if (array_diff(array_keys($params), $allowed) !== []) { throw new DomainException('请求包含不支持的字段'); diff --git a/server/app/adminapi/logic/tcm/MobileRecordingMediaLogic.php b/server/app/adminapi/logic/tcm/MobileRecordingMediaLogic.php new file mode 100644 index 000000000..e2318a4a9 --- /dev/null +++ b/server/app/adminapi/logic/tcm/MobileRecordingMediaLogic.php @@ -0,0 +1,304 @@ +where('binding_id', $id)->where('kind', $kind)->where('sha256', $sha)->find(); + if ($existing && ((int) $existing['size_bytes'] !== $size || $existing['media_type'] !== $mime)) { + throw new DomainException('相同摘要的文件信息不一致'); + } + // Only one unfinished version per kind; the published version stays readable until replacement commits. + $obsolete = Db::name('mobile_recording_upload')->where('binding_id', $id)->where('kind', $kind) + ->where('state', 'uploading')->where('sha256', '<>', $sha)->column('upload_id'); + self::deleteUploads($obsolete, $garbage); + if ($existing) { + if ($existing['state'] === 'complete') { + try { $file = Storage::open($existing); fclose($file); } + catch (DomainException $e) { + Db::name('mobile_recording_asset')->where('upload_id', $existing['upload_id'])->delete(); + $existing['state'] = 'uploading'; $existing['received_bytes'] = 0; + Db::name('mobile_recording_upload')->where('upload_id', $existing['upload_id']) + ->update(['state' => 'uploading', 'received_bytes' => 0, 'updated_at' => time()]); + } + } + return self::dto(self::synchronize($existing)); + } + $row = ['upload_id' => bin2hex(random_bytes(16)), 'binding_id' => $id, 'diagnosis_id' => $diagnosis, + 'kind' => $kind, 'sha256' => $sha, 'media_type' => $mime, 'size_bytes' => $size, + 'received_bytes' => 0, 'state' => 'uploading', 'created_at' => time(), 'updated_at' => time()]; + Db::name('mobile_recording_upload')->insert($row); + Storage::locked($row['upload_id'], static fn ($file): int => Storage::reconcile($file, 0)); + return self::dto($row); + }); + } + + public static function chunk(array $p, string $bytes, int $actor): array + { + MobileRecordingLogic::only($p, ['upload_id', 'diagnosis_id', 'offset', 'chunk_sha256']); + $uploadId = self::uploadId($p['upload_id'] ?? null); + $diagnosis = self::diagnosisId($p); + $offset = MobileRecordingLogic::integer($p['offset'] ?? null, 0, self::AUDIO_MAX, '分片偏移'); + $sha = self::hash($p['chunk_sha256'] ?? null); + $length = strlen($bytes); + if ($length < 1 || $length > self::CHUNK_SIZE) { throw new DomainException('分片大小必须为 1 至 1048576 字节'); } + if (!hash_equals($sha, hash('sha256', $bytes))) { throw new DomainException('分片摘要校验失败'); } + return self::mutate(static function (array &$garbage) use ($uploadId, $diagnosis, $offset, $bytes, $length, $actor): array { + $row = self::session($uploadId, $diagnosis, $actor); + return Storage::locked($uploadId, static function ($file) use ($row, $offset, $bytes, $length): array { + $received = Storage::reconcile($file, (int) $row['received_bytes']); + if ($offset + $length > (int) $row['size_bytes']) { throw new DomainException('分片超出声明的文件大小'); } + if ($offset < $received && $offset + $length <= $received) { + if (!hash_equals($bytes, Storage::bytes($file, $offset, $length))) { + throw new MobileRecordingResyncException('分片与已接收内容冲突,请查询上传状态'); + } + } elseif ($offset === $received && $row['state'] === 'uploading') { + Storage::write($file, $offset, $bytes); + $received += $length; + } else { throw new MobileRecordingResyncException('分片偏移不连续,请查询上传状态'); } + Db::name('mobile_recording_upload')->where('upload_id', $row['upload_id']) + ->update(['received_bytes' => $received, 'updated_at' => time()]); + return self::dto(array_replace($row, ['received_bytes' => $received])); + }); + }); + } + + public static function complete(array $p, int $actor): array + { + MobileRecordingLogic::only($p, ['upload_id', 'diagnosis_id']); + $uploadId = self::uploadId($p['upload_id'] ?? null); + $diagnosis = self::diagnosisId($p); + return self::mutate(static function (array &$garbage) use ($uploadId, $diagnosis, $actor): array { + $row = self::synchronize(self::session($uploadId, $diagnosis, $actor)); + if ($row['state'] === 'complete') { return self::dto($row); } + if ((int) $row['received_bytes'] !== (int) $row['size_bytes']) { throw new MobileRecordingResyncException('文件尚未上传完整'); } + $error = Storage::locked($uploadId, static function ($file) use ($row): ?string { + try { Storage::validate($file, $row); return null; } + catch (DomainException $e) { + // Persist a reset before returning a validation error, so same-hash retry is recoverable. + if (!ftruncate($file, 0) || !fflush($file)) { throw new \RuntimeException('Upload reset failed'); } + return $e->getMessage(); + } + }); + if ($error !== null) { + Db::name('mobile_recording_upload')->where('upload_id', $uploadId)->update(['received_bytes' => 0, 'updated_at' => time()]); + return ['validation_error' => $error]; + } + Db::name('mobile_recording_upload')->where('upload_id', $uploadId)->update(['state' => 'complete', 'updated_at' => time()]); + $asset = ['binding_id' => $row['binding_id'], 'kind' => $row['kind'], 'upload_id' => $uploadId, 'updated_at' => time()]; + $query = Db::name('mobile_recording_asset')->where('binding_id', $row['binding_id'])->where('kind', $row['kind']); + if ((clone $query)->find()) { $query->update($asset); } else { Db::name('mobile_recording_asset')->insert($asset); } + $oldIds = Db::name('mobile_recording_upload')->where('binding_id', $row['binding_id'])->where('kind', $row['kind']) + ->where('upload_id', '<>', $uploadId)->column('upload_id'); + self::deleteUploads($oldIds, $garbage); + return self::dto(array_replace($row, ['state' => 'complete'])); + }); + } + + public static function status(array $p, int $actor): array + { + MobileRecordingLogic::only($p, ['installation_id', 'recording_key', 'diagnosis_id']); + $identity = MobileRecordingLogic::identity($p); + $diagnosis = self::diagnosisId($p); + return self::mutate(static function (array &$garbage) use ($identity, $diagnosis, $actor): array { + $binding = self::owned($identity, $diagnosis, $actor); + $result = []; + foreach (['audio', 'transcript'] as $kind) { + // A pending replacement is the mobile recovery target, even while the old asset remains published. + $rows = Db::name('mobile_recording_upload')->where('binding_id', $binding['id'])->where('kind', $kind) + ->order('state', 'desc')->select()->toArray(); + $row = $rows ? self::synchronize($rows[0]) : null; + $result[$kind] = $row ? self::assetState($row) : ['state' => 'missing', 'upload_id' => '', 'size_bytes' => 0, + 'received_bytes' => 0, 'sha256' => '', 'media_type' => '']; + } + return $result; + }); + } + + public static function audio(array $p, int $actor, string $range = ''): MobileRecordingStream + { + return self::read($p, $actor, 'audio', static function (array $row) use ($range): MobileRecordingStream { + return new MobileRecordingStream(Storage::open($row), (int) $row['size_bytes'], $row['media_type'], $range); + }); + } + + public static function transcript(array $p, int $actor): array + { + return self::read($p, $actor, 'transcript', static function (array $row): array { + $file = Storage::open($row); + try { $text = Storage::bytes($file, 0, (int) $row['size_bytes']); } + finally { fclose($file); } + return ['text' => $text, 'sha256' => $row['sha256'], 'size_bytes' => (int) $row['size_bytes']]; + }); + } + + private static function read(array $p, int $actor, string $kind, callable $read) + { + MobileRecordingLogic::only($p, ['binding_id', 'diagnosis_id']); + $id = MobileRecordingLogic::integer($p['binding_id'] ?? null, 1, PHP_INT_MAX, '关联标识'); + $anchor = isset($p['diagnosis_id']) ? self::diagnosisId($p) : null; + return Db::transaction(static function () use ($id, $anchor, $actor, $kind, $read) { + $info = MobileRecordingLogic::actor($actor, true); + $binding = Db::name('mobile_recording_binding')->where('id', $id)->lock(true)->find(); + if (!$binding || ($anchor !== null && (int) $binding['diagnosis_id'] !== $anchor)) { + throw new DomainException('录音关联已变更或不可访问'); + } + MobileRecordingLogic::diagnosis((int) $binding['diagnosis_id'], $actor, $info, true); + $asset = Db::name('mobile_recording_asset')->where('binding_id', $id)->where('kind', $kind)->find(); + $row = $asset ? Db::name('mobile_recording_upload')->where('upload_id', $asset['upload_id']) + ->where('binding_id', $id)->where('kind', $kind)->where('state', 'complete')->find() : null; + if (!$row) { throw new DomainException('此录音尚未上传对应内容'); } + return $read($row); + }); + } + + /** Called only after the binding's actor/diagnoses are locked and authorized. */ + public static function bindingMoved(int $id, int $diagnosisId): array + { + $garbage = Db::name('mobile_recording_upload')->where('binding_id', $id)->where('state', 'uploading')->column('upload_id'); + if ($garbage) { Db::name('mobile_recording_upload')->whereIn('upload_id', $garbage)->delete(); } + Db::name('mobile_recording_upload')->where('binding_id', $id)->where('state', 'complete') + ->update(['diagnosis_id' => $diagnosisId, 'updated_at' => time()]); + return $garbage; + } + + /** Remove authorization references in the same transaction as the binding. */ + public static function bindingRemoved(int $id): array + { + $garbage = Db::name('mobile_recording_upload')->where('binding_id', $id)->column('upload_id'); + Db::name('mobile_recording_asset')->where('binding_id', $id)->delete(); + Db::name('mobile_recording_upload')->where('binding_id', $id)->delete(); + return $garbage; + } + + public static function summaries(array $ids): array + { + if (!$ids) { return []; } + $result = []; + foreach ($ids as $id) { $result[$id] = ['audio_available' => false, 'transcript_available' => false, 'upload_state' => 'not_uploaded']; } + $published = Db::name('mobile_recording_asset')->whereIn('binding_id', $ids)->select()->toArray(); + foreach ($published as $asset) { $result[$asset['binding_id']][$asset['kind'] . '_available'] = true; } + $pending = Db::name('mobile_recording_upload')->whereIn('binding_id', $ids)->where('state', 'uploading')->column('binding_id'); + foreach ($result as $id => &$summary) { + $complete = (int) $summary['audio_available'] + (int) $summary['transcript_available']; + $summary['upload_state'] = in_array($id, array_map('intval', $pending), true) ? 'uploading' + : ($complete === 2 ? 'complete' : ($complete === 1 ? 'partial' : 'not_uploaded')); + } + unset($summary); + return $result; + } + + private static function owned(array $identity, int $diagnosis, int $actor): array + { + $info = MobileRecordingLogic::actor($actor, true); + $binding = Db::name('mobile_recording_binding')->where('admin_id', $actor) + ->where('installation_id', $identity['installation_id'])->where('recording_key', $identity['recording_key'])->lock(true)->find(); + return self::authorizeBinding($binding, $diagnosis, $actor, $info); + } + + private static function session(string $id, int $diagnosis, int $actor): array + { + $info = MobileRecordingLogic::actor($actor, true); + $lookup = Db::name('mobile_recording_upload')->where('upload_id', $id)->find(); + $binding = $lookup ? Db::name('mobile_recording_binding')->where('id', $lookup['binding_id'])->where('admin_id', $actor)->lock(true)->find() : null; + self::authorizeBinding($binding, $diagnosis, $actor, $info); + $row = Db::name('mobile_recording_upload')->where('upload_id', $id)->lock(true)->find(); + if (!$row || (int) $row['diagnosis_id'] !== $diagnosis) { throw new DomainException('上传会话已失效,请重新准备上传'); } + return $row; + } + + private static function authorizeBinding(?array $binding, int $diagnosis, int $actor, array $info): array + { + if (!$binding || (int) $binding['diagnosis_id'] !== $diagnosis) { throw new DomainException('录音关联已变更或不可访问'); } + MobileRecordingLogic::diagnosis($diagnosis, $actor, $info, true); + return $binding; + } + + private static function synchronize(array $row): array + { + if ($row['state'] === 'uploading') { + $received = Storage::locked($row['upload_id'], static fn ($file): int => Storage::reconcile($file, (int) $row['received_bytes'])); + if ($received !== (int) $row['received_bytes']) { + Db::name('mobile_recording_upload')->where('upload_id', $row['upload_id'])->update(['received_bytes' => $received, 'updated_at' => time()]); + $row['received_bytes'] = $received; + } + } + return $row; + } + + private static function mutate(callable $operation): array + { + $garbage = []; + $result = Db::transaction(static function () use ($operation, &$garbage): array { return $operation($garbage); }); + Storage::cleanup($garbage); + if (isset($result['validation_error'])) { throw new DomainException($result['validation_error']); } + return $result; + } + + private static function deleteUploads(array $ids, array &$garbage): void + { + if (!$ids) { return; } + Db::name('mobile_recording_upload')->whereIn('upload_id', $ids)->delete(); + $garbage = array_merge($garbage, $ids); + } + + private static function diagnosisId(array $p): int + { + return MobileRecordingLogic::integer($p['diagnosis_id'] ?? null, 1, 4294967295, '诊单标识'); + } + + private static function uploadId($id): string + { + if (!is_string($id) || !preg_match('/^[a-f0-9]{32}$/D', $id)) { throw new DomainException('上传标识无效'); } + return $id; + } + + private static function hash($hash): string + { + if (!is_string($hash) || !preg_match('/^[a-f0-9]{64}$/D', $hash)) { throw new DomainException('文件摘要无效'); } + return $hash; + } + + private static function dto(array $row): array + { + return ['upload_id' => $row['upload_id'], 'received_bytes' => (int) $row['received_bytes'], + 'size_bytes' => (int) $row['size_bytes'], 'state' => $row['state'], 'chunk_size' => self::CHUNK_SIZE]; + } + + private static function assetState(array $row): array + { + return ['state' => $row['state'], 'upload_id' => $row['upload_id'], 'size_bytes' => (int) $row['size_bytes'], + 'received_bytes' => (int) $row['received_bytes'], 'sha256' => $row['sha256'], 'media_type' => $row['media_type']]; + } +} diff --git a/server/app/command/MobileRecordingCleanup.php b/server/app/command/MobileRecordingCleanup.php new file mode 100644 index 000000000..ef3d52233 --- /dev/null +++ b/server/app/command/MobileRecordingCleanup.php @@ -0,0 +1,32 @@ +setName('mobile-recording:cleanup')->setDescription('清理无数据库引用且超过一天的私有录音孤儿文件') + ->addOption('dry-run', null, Option::VALUE_NONE, '只统计,不删除'); + } + + protected function execute(Input $input, Output $output): int + { + try { + $result = MobileRecordingStorage::cleanupOrphans((bool) $input->getOption('dry-run')); + $output->writeln('MOBILE_RECORDING_CLEANUP ' . json_encode($result)); + return $result['errors'] > 0 ? 1 : 0; + } catch (\Throwable $e) { + $output->writeln('MOBILE_RECORDING_CLEANUP storage_or_configuration_error'); + return 1; + } + } +} diff --git a/server/app/common/service/mobilerecording/MobileRecordingResyncException.php b/server/app/common/service/mobilerecording/MobileRecordingResyncException.php new file mode 100644 index 000000000..02de074a8 --- /dev/null +++ b/server/app/common/service/mobilerecording/MobileRecordingResyncException.php @@ -0,0 +1,11 @@ + $committed) { + if (!ftruncate($file, $committed) || !fflush($file)) { throw new RuntimeException('Recording recovery failed'); } + } + return min($size, $committed); + } + + public static function write($file, int $offset, string $bytes): void + { + if (fseek($file, $offset) !== 0) { throw new RuntimeException('Recording seek failed'); } + $written = 0; + while ($written < strlen($bytes)) { + $count = fwrite($file, substr($bytes, $written)); + if ($count === false || $count === 0) { throw new RuntimeException('Recording write failed'); } + $written += $count; + } + if (!fflush($file)) { throw new RuntimeException('Recording flush failed'); } + if (function_exists('fsync') && !fsync($file)) { throw new RuntimeException('Recording sync failed'); } + } + + public static function bytes($file, int $offset, int $length): string + { + if ($length === 0) { return ''; } + if (fseek($file, $offset) !== 0) { throw new RuntimeException('Recording seek failed'); } + $bytes = stream_get_contents($file, $length); + if ($bytes === false || strlen($bytes) !== $length) { throw new RuntimeException('Recording read failed'); } + return $bytes; + } + + /** Integrity and container signatures, not user supplied extensions or a remote URL. */ + public static function validate($file, array $upload): void + { + $size = (int) fstat($file)['size']; + if ($size !== (int) $upload['size_bytes']) { throw new DomainException('上传大小不一致,请重新上传'); } + rewind($file); + $context = hash_init('sha256'); + hash_update_stream($context, $file); + if (!hash_equals((string) $upload['sha256'], hash_final($context))) { + throw new DomainException('文件摘要校验失败,请重新上传'); + } + if ($upload['kind'] === 'transcript') { + $text = self::bytes($file, 0, $size); + if (preg_match('//u', $text) !== 1 || str_contains($text, "\0")) { + throw new DomainException('文字必须是有效的 UTF-8 纯文本'); + } + return; + } + $head = self::bytes($file, 0, min($size, 65536)); + $valid = match ($upload['media_type']) { + 'audio/wav' => $size >= 44 && substr($head, 0, 4) === 'RIFF' && substr($head, 8, 4) === 'WAVE' + && unpack('V', substr($head, 4, 4))[1] + 8 === $size && self::wav($file, $size), + 'audio/flac' => $size > 42 && substr($head, 0, 4) === 'fLaC' && (ord($head[4]) & 127) === 0 + && substr($head, 5, 3) === "\0\0\x22", + 'audio/ogg' => $size > 32 && substr($head, 0, 5) === "OggS\0" + && (str_contains(substr($head, 27, 512), 'OpusHead') || str_contains(substr($head, 27, 512), "\x01vorbis") + || str_contains(substr($head, 27, 512), 'Speex ') || str_contains(substr($head, 27, 512), 'fLaC')), + 'audio/mpeg' => self::mpeg($file, $head, $size), + 'audio/mp4' => self::mp4($file, $size), + 'audio/amr' => ($size > 6 && str_starts_with($head, "#!AMR\n")) + || ($size > 9 && str_starts_with($head, "#!AMR-WB\n")), + default => false, + }; + if (!$valid) { throw new DomainException('音频格式或文件签名无效'); } + } + + private static function wav($file, int $size): bool + { + $fmt = false; + $data = false; + for ($offset = 12; $offset + 8 <= $size;) { + $header = self::bytes($file, $offset, 8); + $length = unpack('V', substr($header, 4))[1]; + if ($offset + 8 + $length > $size) { return false; } + if (substr($header, 0, 4) === 'fmt ' && $length >= 16) { + $values = unpack('vformat/vchannels/Vrate/Vbyte_rate/vblock/vbits', self::bytes($file, $offset + 8, 16)); + $fmt = in_array($values['format'], [1, 3, 65534], true) && $values['channels'] >= 1 + && $values['channels'] <= 32 && $values['rate'] >= 1000 && $values['rate'] <= 768000; + } + if (substr($header, 0, 4) === 'data' && $length > 0) { $data = true; } + $offset += 8 + $length + ($length % 2); + } + return $fmt && $data; + } + + private static function mpeg($file, string $head, int $size): bool + { + $offset = 0; + if (str_starts_with($head, 'ID3')) { + if (strlen($head) < 10) { return false; } + $offset = 10; + for ($i = 6; $i < 10; $i++) { + if (ord($head[$i]) > 127) { return false; } + $offset += ord($head[$i]) << ((9 - $i) * 7); + } + if (ord($head[5]) & 16) { $offset += 10; } + } + if ($offset + 4 > $size) { return false; } + $h = array_values(unpack('C4', self::bytes($file, $offset, 4))); + return $h[0] === 255 && ($h[1] & 224) === 224 && ($h[1] & 24) !== 8 && ($h[1] & 6) !== 0 + && ($h[2] & 240) !== 0 && ($h[2] & 240) !== 240 && ($h[2] & 12) !== 12; + } + + private static function mp4($file, int $size): bool + { + $types = []; + $handlers = []; + $walk = static function (int $start, int $end, int $depth) use ($file, &$walk, &$types, &$handlers): bool { + if ($depth > 5) { return false; } + for ($offset = $start; $offset < $end;) { + if ($offset + 8 > $end) { return false; } + $header = self::bytes($file, $offset, 8); + $length = unpack('N', substr($header, 0, 4))[1]; + $type = substr($header, 4, 4); + $headerSize = 8; + if ($length === 1) { + if ($offset + 16 > $end) { return false; } + $large = unpack('Nhigh/Nlow', self::bytes($file, $offset + 8, 8)); + if ($large['high'] !== 0) { return false; } + $length = $large['low']; $headerSize = 16; + } + if ($length === 0) { $length = $end - $offset; } + if ($length < $headerSize || $offset + $length > $end) { return false; } + if ($depth === 0 && $length > $headerSize) { $types[] = $type; } + if ($type === 'hdlr' && $length >= $headerSize + 12) { + $handlers[] = self::bytes($file, $offset + $headerSize + 8, 4); + } + if (in_array($type, ['moov', 'trak', 'mdia'], true) + && !$walk($offset + $headerSize, $offset + $length, $depth + 1)) { return false; } + $offset += $length; + } + return true; + }; + return $walk(0, $size, 0) && in_array('ftyp', $types, true) && in_array('mdat', $types, true) + && in_array('soun', $handlers, true) && !in_array('vide', $handlers, true); + } + + /** Open only after DB authorization. The caller owns the handle, never the path. */ + public static function open(array $upload) + { + $file = @fopen(self::path((string) $upload['upload_id']), 'rb'); + if (!$file) { throw new DomainException('已上传文件暂不可读取,请重新上传'); } + if ((int) fstat($file)['size'] !== (int) $upload['size_bytes']) { + fclose($file); + throw new DomainException('已上传文件暂不可读取,请重新上传'); + } + return $file; + } + + /** DB rows are removed first; an unlink failure leaves an inaccessible orphan. */ + public static function cleanup(array $ids): void + { + foreach (array_unique($ids) as $id) { + try { $path = self::path((string) $id); if (is_file($path)) { @unlink($path); } } + catch (\Throwable $ignored) { /* Never resurrect a revoked DB reference. */ } + } + } + + /** CLI housekeeping only. Never expires referenced uploads or completed patient assets. */ + public static function cleanupOrphans(bool $dryRun = true): array + { + $counts = ['eligible' => 0, 'deleted' => 0, 'errors' => 0]; + // Fail before touching files if either migration table is unavailable. + \think\facade\Db::name('mobile_recording_upload')->limit(1)->count(); + \think\facade\Db::name('mobile_recording_asset')->limit(1)->count(); + foreach (new \DirectoryIterator(self::root()) as $entry) { + if (!$entry->isFile() || $entry->isLink() || $entry->getMTime() > time() - 86400 + || !preg_match('/^([a-f0-9]{32})\.bin$/D', $entry->getFilename(), $match)) { continue; } + $id = $match[1]; + if (\think\facade\Db::name('mobile_recording_upload')->where('upload_id', $id)->count() > 0 + || \think\facade\Db::name('mobile_recording_asset')->where('upload_id', $id)->count() > 0) { continue; } + $counts['eligible']++; + if (!$dryRun) { + if (@unlink(self::path($id))) { $counts['deleted']++; } else { $counts['errors']++; } + } + } + return $counts; + } +} diff --git a/server/app/common/service/mobilerecording/MobileRecordingStream.php b/server/app/common/service/mobilerecording/MobileRecordingStream.php new file mode 100644 index 000000000..0e0fcbfb5 --- /dev/null +++ b/server/app/common/service/mobilerecording/MobileRecordingStream.php @@ -0,0 +1,62 @@ +file = $file; + $this->init('', 200); + $this->length = $size; + $this->header([ + 'Content-Type' => $mime, 'Content-Disposition' => 'inline; filename="recording"', + 'Cache-Control' => 'private, no-store, max-age=0', 'Pragma' => 'no-cache', + 'X-Content-Type-Options' => 'nosniff', 'Accept-Ranges' => 'bytes', + ]); + if ($range !== '') { + $valid = preg_match('/^bytes=([0-9]{0,15})-([0-9]{0,15})$/D', trim($range), $m) === 1 + && ($m[1] !== '' || $m[2] !== '') && $size > 0; + if ($valid) { + $this->start = $m[1] === '' ? max(0, $size - (int) $m[2]) : (int) $m[1]; + $end = $m[1] === '' || $m[2] === '' ? $size - 1 : min($size - 1, (int) $m[2]); + $valid = $this->start < $size && $end >= $this->start && !($m[1] === '' && (int) $m[2] === 0); + } + if (!$valid) { + $this->code(416); $this->length = 0; + $this->header(['Content-Range' => 'bytes */' . $size]); + } else { + $this->code(206); $this->length = $end - $this->start + 1; + $this->header(['Content-Range' => 'bytes ' . $this->start . '-' . $end . '/' . $size]); + } + } + $this->header(['Content-Length' => (string) $this->length]); + $this->allowCache(false); + } + + protected function sendData(string $data): void + { + if (!is_resource($this->file) || fseek($this->file, $this->start) !== 0) { return; } + try { + $remaining = $this->length; + while ($remaining > 0 && !connection_aborted()) { + $chunk = fread($this->file, min(65536, $remaining)); + if ($chunk === false || $chunk === '') { break; } + echo $chunk; + $remaining -= strlen($chunk); + } + } finally { fclose($this->file); $this->file = null; } + } + + public function __destruct() + { + if (is_resource($this->file)) { fclose($this->file); } + } +} diff --git a/server/config/console.php b/server/config/console.php index 2843ba843..d919ee558 100755 --- a/server/config/console.php +++ b/server/config/console.php @@ -5,6 +5,7 @@ return [ // 指令定义 'commands' => [ + 'mobile-recording:cleanup' => 'app\\command\\MobileRecordingCleanup', 'followup-audio:work' => 'app\\command\\FollowupAudioWork', 'followup-audio:probe' => 'app\\command\\FollowupAudioProbe', 'followup-audio:cleanup' => 'app\\command\\FollowupAudioCleanup', diff --git a/server/config/mobile_recording_media.php b/server/config/mobile_recording_media.php new file mode 100644 index 000000000..c07a57371 --- /dev/null +++ b/server/config/mobile_recording_media.php @@ -0,0 +1,6 @@ + env('mobile_recording_media.storage_root', root_path() . 'storage/private/mobile_recording'), +]; diff --git a/server/database/migrations/2026_11_09_mobile_recording_media.sql b/server/database/migrations/2026_11_09_mobile_recording_media.sql new file mode 100644 index 000000000..e03070a03 --- /dev/null +++ b/server/database/migrations/2026_11_09_mobile_recording_media.sql @@ -0,0 +1,28 @@ +-- Apply after 2026_11_06_mobile_recording_binding.sql to the application's business database. +-- Default prefix is zyt_; substitute only when the application's database.prefix differs. +-- Existing bindings and their metadata remain untouched. Files are outside the public web root. +CREATE TABLE IF NOT EXISTS `zyt_mobile_recording_upload` ( + `upload_id` char(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `binding_id` bigint unsigned NOT NULL, + `diagnosis_id` int unsigned NOT NULL, + `kind` varchar(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `sha256` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `media_type` varchar(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `size_bytes` bigint unsigned NOT NULL, + `received_bytes` bigint unsigned NOT NULL DEFAULT 0, + `state` varchar(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT 'uploading', + `created_at` int unsigned NOT NULL, + `updated_at` int unsigned NOT NULL, + PRIMARY KEY (`upload_id`), + UNIQUE KEY `uq_binding_kind_sha` (`binding_id`, `kind`, `sha256`), + KEY `idx_binding_kind_state` (`binding_id`, `kind`, `state`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `zyt_mobile_recording_asset` ( + `binding_id` bigint unsigned NOT NULL, + `kind` varchar(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `upload_id` char(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `updated_at` int unsigned NOT NULL, + PRIMARY KEY (`binding_id`, `kind`), + UNIQUE KEY `uq_asset_upload` (`upload_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/tests/MobileRecordingBindingTest.php b/server/tests/MobileRecordingBindingTest.php index d2ded137f..59238a68a 100644 --- a/server/tests/MobileRecordingBindingTest.php +++ b/server/tests/MobileRecordingBindingTest.php @@ -37,6 +37,8 @@ foreach ([ ] as $ddl) { Db::execute($ddl); } +require __DIR__ . '/fixtures/mobile_recording_media_schema.php'; + Db::name('admin')->insertAll([ ['id' => 1, 'root' => 1, 'name' => '合成管理员', 'disable' => 0], ['id' => 11, 'root' => 0, 'name' => '合成助理甲', 'disable' => 0], diff --git a/server/tests/MobileRecordingMediaTest.php b/server/tests/MobileRecordingMediaTest.php new file mode 100644 index 000000000..3d281daed --- /dev/null +++ b/server/tests/MobileRecordingMediaTest.php @@ -0,0 +1,271 @@ + PDO::ERRMODE_EXCEPTION]); + $actual = strtolower(rtrim(str_replace('\\', '/', (string) $pdo->query('SELECT @@datadir')->fetchColumn()), '/')); + if ($actual !== strtolower(str_replace('\\', '/', $state['run_root']) . '/data')) { + throw new RuntimeException('Refusing non-scratch datadir'); + } + $pdo->exec('DROP DATABASE IF EXISTS synthetic_mobile_media_test'); + $pdo->exec('CREATE DATABASE synthetic_mobile_media_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'); + $pdo->exec('USE synthetic_mobile_media_test'); + $driver = 'mysql'; + $connection = ['type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => (int) $state['port'], + 'database' => 'synthetic_mobile_media_test', 'username' => 'root', 'password' => '', + 'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true, 'fields_cache' => false, 'trigger_sql' => false]; +} else { + if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { throw new RuntimeException('SQLite PDO required; no production fallback'); } + $connection = ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'zyt_']; +} +$manager->setConfig(['default' => $driver, 'auto_timestamp' => false, 'datetime_format' => false, 'connections' => [$driver => $connection]]); +Container::getInstance()->instance('think\DbManager', $manager); +$storage = sys_get_temp_dir() . '/mobile-media-synthetic-' . bin2hex(random_bytes(8)); +$config = new think\Config(); +$config->set(['data_scope' => ['enabled' => false]], 'project'); +$config->set(['storage_root' => $storage], 'mobile_recording_media'); +Container::getInstance()->instance('config', $config); + +$checks = []; +$expect = static function (bool $ok, string $why) use (&$checks): void { + if (!$ok) { throw new RuntimeException($why); } + $checks[] = $why; +}; +$denied = static function (callable $call, string $why) use ($expect): void { + try { $call(); } catch (DomainException $e) { $expect(true, $why); return; } + throw new RuntimeException('Expected denial: ' . $why); +}; +$controller = static function (string $action, array $params, bool $post = true, string $body = '', array $headers = []) use ($app) { + $request = new think\Request(); + $request->withServer(['REQUEST_METHOD' => $post ? 'POST' : 'GET']); + if ($action === 'chunk' || !$post) { $request->withGet($params); } else { $request->withPost($params); } + $request->withInput($body)->withHeader($headers); + $request->adminInfo = ['admin_id' => 11, 'root' => 0]; + $app->instance('request', $request); + return (new MobileRecordingMediaController($app))->$action(); +}; +$capture = static function ($response): string { + ob_start(); + try { $response->send(); return (string) ob_get_contents(); } + finally { ob_end_clean(); } +}; + +try { + foreach ([ + 'CREATE TABLE zyt_admin (id INT PRIMARY KEY, root INT, name VARCHAR(100), disable INT, delete_time INT NULL)', + 'CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)', + 'CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)', + 'CREATE TABLE zyt_system_menu (id INT PRIMARY KEY, perms VARCHAR(128), is_disable INT)', + 'CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, patient_id INT, patient_name VARCHAR(100), phone VARCHAR(30), assistant_id INT, status INT, delete_time INT NULL)', + 'CREATE TABLE zyt_doctor_appointment (id INT PRIMARY KEY, patient_id INT, doctor_id INT, status INT)', + ] as $sql) { Db::execute($sql . ($driver === 'mysql' ? ' ENGINE=InnoDB' : '')); } + if ($driver === 'mysql') { + $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_06_mobile_recording_binding.sql')); + $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_media.sql')); + $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_media.sql')); + $expect(true, 'real upgrade migration executes and is idempotent on isolated MySQL'); + } else { + Db::execute('CREATE TABLE zyt_mobile_recording_binding (id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INT NOT NULL, + installation_id TEXT NOT NULL, recording_key TEXT NOT NULL, diagnosis_id INT NOT NULL, source_platform TEXT NOT NULL, + recorded_at INT NOT NULL, duration_ms INT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, + UNIQUE(admin_id,installation_id,recording_key))'); + require __DIR__ . '/fixtures/mobile_recording_media_schema.php'; + } + Db::name('admin')->insertAll([ + ['id' => 1, 'root' => 1, 'name' => 'Synthetic root', 'disable' => 0], + ['id' => 11, 'root' => 0, 'name' => 'Synthetic assistant', 'disable' => 0], + ['id' => 12, 'root' => 0, 'name' => 'Synthetic other', 'disable' => 0], + ['id' => 13, 'root' => 0, 'name' => 'Synthetic doctor', 'disable' => 0], + ['id' => 15, 'root' => 0, 'name' => 'Synthetic no-grant', 'disable' => 0], + ]); + Db::name('admin_role')->insertAll([['admin_id' => 11, 'role_id' => 2], ['admin_id' => 12, 'role_id' => 2], ['admin_id' => 13, 'role_id' => 1]]); + Db::name('system_role_menu')->insertAll([['role_id' => 2, 'menu_id' => 1], ['role_id' => 1, 'menu_id' => 1]]); + Db::name('system_menu')->insert(['id' => 1, 'perms' => Binding::PAGE_PERMISSION, 'is_disable' => 0]); + Db::name('tcm_diagnosis')->insertAll([ + ['id' => 101, 'patient_id' => 501, 'patient_name' => 'Synthetic A', 'phone' => '', 'assistant_id' => 11, 'status' => 1], + ['id' => 102, 'patient_id' => 502, 'patient_name' => 'Synthetic B', 'phone' => '', 'assistant_id' => 11, 'status' => 1], + ['id' => 103, 'patient_id' => 503, 'patient_name' => 'Synthetic C', 'phone' => '', 'assistant_id' => 12, 'status' => 1], + ]); + Db::name('doctor_appointment')->insert(['id' => 1, 'patient_id' => 101, 'doctor_id' => 13, 'status' => 3]); + $input = ['installation_id' => '12345678-1234-4123-8123-123456789abc', 'recording_key' => str_repeat('a', 64), + 'diagnosis_id' => 101, 'source_platform' => 'android_bcr', 'recorded_at' => 1700000000000, 'duration_ms' => 65000]; + $identity = array_intersect_key($input, array_flip(['installation_id', 'recording_key'])); + $target = $identity + ['diagnosis_id' => 101]; + $pcm = str_repeat("\x00\x00\x01\x00", 300000); // synthetic silence/tiny impulse, >1 MiB to exercise resume + $wav = 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 8000, 16000, 2, 16) + . 'data' . pack('V', strlen($pcm)) . $pcm; + $prepare = $target + ['kind' => 'audio', 'size_bytes' => strlen($wav), 'sha256' => hash('sha256', $wav), 'media_type' => 'audio/wav']; + $denied(fn () => Media::prepare($prepare, 11), 'unbound recording cannot upload'); + $binding = Binding::bind($input, 11)['binding']; + $id = $binding['id']; + $expect(Binding::patientLists(['diagnosis_id' => 101], 11)['lists'][0]['upload_state'] === 'not_uploaded', 'old metadata row remains compatible before upload'); + foreach ([['file_name' => 'private.wav'], ['path' => '/tmp/secret'], ['admin_id' => 1], ['token' => 'secret']] as $extra) { + $denied(fn () => Media::prepare($prepare + $extra, 11), 'reject filename/path/actor/token extra field'); + } + $denied(fn () => Media::prepare(array_replace($prepare, ['size_bytes' => Media::AUDIO_MAX + 1]), 11), 'audio limit enforced before storage'); + $denied(fn () => Media::prepare(array_replace($prepare, ['kind' => 'transcript', 'media_type' => 'text/plain', 'size_bytes' => Media::TEXT_MAX + 1]), 11), 'text limit enforced'); + $denied(fn () => Media::prepare($prepare, 12), 'same device and key cannot upload another account binding'); + $denied(fn () => Media::prepare($prepare, 15), 'missing live page grant denied'); + $denied(fn () => Media::prepare(array_replace($prepare, ['diagnosis_id' => 103]), 11), 'wrong diagnosis denied'); + $rejected = ['error_code' => 'request_rejected', 'retryable' => false]; + $resync = ['error_code' => 'upload_resync', 'retryable' => true]; + $scopeDenial = $controller('prepare', array_replace($prepare, ['diagnosis_id' => 103]))->getData(); + $expect($scopeDenial['code'] === 0 && $scopeDenial['data'] === $rejected, 'patient scope error is machine-readable and never automatically retryable'); + $methodError = $controller('prepare', $prepare, false)->getData(); + $expect($methodError['code'] === 0 && $methodError['data'] === $rejected, 'wrong method remains non-retryable'); + $session = $controller('prepare', $prepare)->getData(); + $expect($session['code'] === 1, 'actual controller prepare succeeds'); + $session = $session['data']; + $upload = ['upload_id' => $session['upload_id'], 'diagnosis_id' => 101]; + $expect(Media::prepare($prepare, 11)['upload_id'] === $session['upload_id'], 'repeat prepare reuses upload'); + $denied(fn () => Media::complete($upload, 11), 'incomplete upload cannot publish'); + $incomplete = $controller('complete', $upload)->getData(); + $expect($incomplete['code'] === 0 && $incomplete['msg'] === '文件尚未上传完整' && $incomplete['data'] === $resync, + 'incomplete complete preserves message and requests cursor resynchronization'); + $first = substr($wav, 0, Media::CHUNK_SIZE); + $chunk = $upload + ['offset' => 0, 'chunk_sha256' => hash('sha256', $first)]; + $denied(fn () => Media::chunk(array_replace($chunk, ['chunk_sha256' => str_repeat('0', 64)]), $first, 11), 'wrong chunk hash rejected'); + $hashError = $controller('chunk', array_replace($chunk, ['chunk_sha256' => str_repeat('0', 64)]), true, $first, + ['content-type' => 'application/octet-stream'])->getData(); + $expect($hashError['code'] === 0 && $hashError['data'] === $rejected, 'chunk integrity error is not automatically retryable'); + $denied(fn () => Media::chunk($chunk, $first . 'x', 11), 'chunk over one MiB rejected'); + $expect($controller('chunk', $chunk, true, $first, ['content-type' => 'text/plain'])->getData()['code'] === 0, 'binary content type enforced'); + $response = $controller('chunk', $chunk, true, $first, ['content-type' => 'application/octet-stream'])->getData(); + $expect($response['code'] === 1 && $response['data']['received_bytes'] === Media::CHUNK_SIZE, 'actual binary controller receives first chunk'); + $expect(Media::chunk($chunk, $first, 11)['received_bytes'] === Media::CHUNK_SIZE, 'duplicate chunk is idempotent'); + $badFirst = 'X' . substr($first, 1); + $denied(fn () => Media::chunk(array_replace($chunk, ['chunk_sha256' => hash('sha256', $badFirst)]), $badFirst, 11), 'conflicting replay rejected'); + $conflict = $controller('chunk', array_replace($chunk, ['chunk_sha256' => hash('sha256', $badFirst)]), true, $badFirst, + ['content-type' => 'application/octet-stream'])->getData(); + $expect($conflict['code'] === 0 && $conflict['data'] === $resync, 'conflicting replay has structured upload_resync code'); + $gap = $controller('chunk', $upload + ['offset' => Media::CHUNK_SIZE + 1, 'chunk_sha256' => hash('sha256', 'x')], + true, 'x', ['content-type' => 'application/octet-stream'])->getData(); + $expect($gap['code'] === 0 && $gap['data'] === $resync, 'noncontiguous cursor has structured upload_resync code'); + $denied(fn () => Media::chunk(array_replace($chunk, ['offset' => Media::CHUNK_SIZE + 1]), $first, 11), 'gap or size overflow rejected'); + $expect(Media::status($target, 11)['audio']['received_bytes'] === Media::CHUNK_SIZE, 'interruption resumes with server cursor'); + file_put_contents($storage . '/' . $session['upload_id'] . '.bin', 'uncommitted', FILE_APPEND); + $expect(Media::prepare($prepare, 11)['received_bytes'] === Media::CHUNK_SIZE, 'crash after disk write before DB commit rolls back extra bytes'); + $expect(filesize($storage . '/' . $session['upload_id'] . '.bin') === Media::CHUNK_SIZE, 'uncommitted trailing bytes removed'); + $last = substr($wav, Media::CHUNK_SIZE); + Media::chunk($upload + ['offset' => Media::CHUNK_SIZE, 'chunk_sha256' => hash('sha256', $last)], $last, 11); + $expect(Media::complete($upload, 11)['state'] === 'complete', 'full size hash and WAV signature validated'); + $expect(Media::complete($upload, 11)['state'] === 'complete', 'repeat complete idempotent'); + $expect(Media::prepare($prepare, 11)['state'] === 'complete', 'repeat completed prepare reuses object'); + $expect(Binding::patientLists(['diagnosis_id' => 101], 11)['lists'][0]['upload_state'] === 'partial', 'audio alone is partial and available'); + $full = Media::audio(['binding_id' => $id, 'diagnosis_id' => 101], 11); + $expect($full->getHeader('Cache-Control') === 'private, no-store, max-age=0' && $full->getHeader('X-Content-Type-Options') === 'nosniff', 'private response headers'); + $expect($capture($full) === $wav, 'streamed full audio matches synthetic bytes'); + foreach (['bytes=0-31' => substr($wav, 0, 32), 'bytes=-10' => substr($wav, -10), 'bytes=1200030-' => substr($wav, 1200030)] as $range => $expected) { + $stream = $controller('audio', ['binding_id' => $id, 'diagnosis_id' => 101], false, '', ['range' => $range]); + $expect($stream->getCode() === 206 && $capture($stream) === $expected, 'authenticated Range ' . $range); + } + foreach (['bytes=9999999-', 'bytes=9-2', 'bytes=0-1,3-4', 'bytes=-0', 'garbage'] as $range) { + $stream = Media::audio(['binding_id' => $id], 11, $range); + $expect($stream->getCode() === 416 && $capture($stream) === '', 'invalid range safely returns 416'); + } + $denied(fn () => Media::audio(['binding_id' => $id], 12), 'out-of-scope audio denied'); + $expect($capture(Media::audio(['binding_id' => $id], 13, 'bytes=0-3')) === 'RIFF', 'doctor with current patient scope can read'); + $denied(fn () => Media::chunk($chunk, $first, 13), 'reader cannot write another actor upload'); + $denied(fn () => Media::audio(['binding_id' => $id, 'diagnosis_id' => 102], 11), 'archive diagnosis anchor rejects stale page'); + Db::name('system_menu')->where('id', 1)->update(['is_disable' => 1]); + $permissionError = $controller('status', $target, false)->getData(); + $expect($permissionError['code'] === 0 && $permissionError['data'] === $rejected, 'revoked page permission is explicitly non-retryable'); + $denied(fn () => Media::status($target, 11), 'revoked grant checked on status'); + $denied(fn () => Media::audio(['binding_id' => $id], 11), 'revoked grant checked on read'); + Db::name('system_menu')->where('id', 1)->update(['is_disable' => 0]); + Db::name('admin')->where('id', 11)->update(['disable' => 1]); + $denied(fn () => Media::complete($upload, 11), 'disabled account checked on every request'); + Db::name('admin')->where('id', 11)->update(['disable' => 0]); + Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => 1]); + $denied(fn () => Media::audio(['binding_id' => $id], 11), 'deleted diagnosis blocks content'); + Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => null]); + $sendText = static function (string $text, ?string $sha = null) use ($target): array { + $session = Media::prepare($target + ['kind' => 'transcript', 'size_bytes' => strlen($text), 'sha256' => $sha ?? hash('sha256', $text), 'media_type' => 'text/plain'], 11); + $params = ['upload_id' => $session['upload_id'], 'diagnosis_id' => 101]; + if ($text !== '') { Media::chunk($params + ['offset' => 0, 'chunk_sha256' => hash('sha256', $text)], $text, 11); } + return $params; + }; + $textUpload = $sendText("合成转写\n"); + Media::complete($textUpload, 11); + $text = Media::transcript(['binding_id' => $id, 'diagnosis_id' => 101], 11); + $expect(str_contains($text['text'], '