Merge branch '11-5'

This commit is contained in:
Your Name
2026-11-09 11:56:54 +08:00
17 changed files with 1552 additions and 31 deletions
+98 -3
View File
@@ -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<T> { 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<string, string> = {
'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<T>(response: Envelope<T>): 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<MobileRecordingPage> {
return request.get({ url: '/tcm.mobileRecording/patientLists', params })
}, signal?: AbortSignal): Promise<MobileRecordingPage> {
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<Blob> {
checkSignal(signal)
const blob = await request.get<Blob>({
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<unknown>
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<MobileRecordingTranscript> {
checkSignal(signal)
const response = await request.get<Envelope<MobileRecordingTranscript>>({
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}`
}
@@ -1,23 +1,36 @@
<template>
<div class="mobile-recording-panel">
<div class="panel-toolbar">
<span>仅同步关联信息;音频和转写保存在原手机,请在原手机播放。</span>
<span>已上传的录音和文字可在此查看;未上传的内容仍保存在原手机。</span>
<el-button :loading="loading" @click="load">刷新</el-button>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无手机录音关联">
<el-table-column label="来源" width="140">
<el-table-column label="来源" width="130">
<template #default>安卓手机录音</template>
</el-table-column>
<el-table-column label="录音时间(北京时间)" min-width="190">
<template #default="{ row }">{{ recordedTime(row.recorded_at) }}</template>
</el-table-column>
<el-table-column label="时长" width="120">
<el-table-column label="时长" width="100">
<template #default="{ row }">{{ durationText(row.duration_ms) }}</template>
</el-table-column>
<el-table-column label="绑定账号" prop="bound_by" min-width="130" />
<el-table-column label="播放位置" min-width="170">
<template #default>仅原手机可播放</template>
<el-table-column label="绑定账号" prop="bound_by" min-width="120" />
<el-table-column label="上传状态" min-width="130">
<template #default="{ row }">
<el-tag :type="uploadStatus(row).type" effect="light">{{ uploadStatus(row).label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="录音与文字" min-width="220">
<template #default="{ row }">
<div class="media-actions">
<el-button v-if="row.audio_available === true" link type="primary" @click="openMedia(row, 'audio')">播放录音</el-button>
<el-button v-if="row.transcript_available === true" link type="primary" @click="openMedia(row, 'transcript')">查看文字</el-button>
<span v-if="!row.audio_available && !row.transcript_available" class="muted">{{ row.upload_state === 'uploading' ? '上传后刷新查看' : '请在原手机上传' }}</span>
<span v-else-if="!row.audio_available" class="muted">录音待上传</span>
<span v-else-if="!row.transcript_available" class="muted">文字待上传</span>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
@@ -28,12 +41,48 @@
layout="total, prev, pager, next"
@current-change="load"
/>
<el-drawer :model-value="mediaVisible" :title="mediaKind === 'audio' ? '手机录音' : '转写文字'"
size="min(680px, 100vw)" append-to-body destroy-on-close
@update:model-value="onMediaVisibility" @close="closeMedia">
<div class="media-detail">
<div v-if="mediaRow" class="media-meta">
<span>{{ recordedTime(mediaRow.recorded_at) }}(北京时间)</span>
<span>{{ durationText(mediaRow.duration_ms) }} · {{ mediaRow.bound_by }}</span>
</div>
<div v-if="mediaLoading" class="media-loading" role="status" aria-live="polite">
<p>{{ mediaKind === 'audio' ? '正在下载音频,完成后即可播放。大文件可能需要一些时间。' : '正在读取转写文字…' }}</p>
<el-progress :percentage="downloadPercent" :indeterminate="!downloadTotal" :show-text="!!downloadTotal" />
<span v-if="mediaKind === 'audio'" class="muted">{{ downloadProgressText }}</span>
<el-button @click="closeMedia">取消并关闭</el-button>
</div>
<template v-else-if="mediaError">
<el-alert :title="mediaError" type="error" :closable="false" show-icon />
<el-button @click="loadMedia">重新读取</el-button>
</template>
<template v-else-if="mediaReady">
<template v-if="mediaKind === 'audio'">
<audio ref="audioElement" class="audio-player" :src="mediaUrl" controls preload="metadata"
@error="playbackError = '浏览器无法播放此格式,可下载后使用播放器打开。'" />
<el-alert v-if="playbackError" :title="playbackError" type="info" :closable="false" show-icon />
</template>
<template v-else>
<pre v-if="transcriptText.length" class="transcript-text">{{ transcriptText }}</pre>
<el-empty v-else description="已上传的转写文字为空" />
</template>
<a v-if="mediaUrl" class="media-download" :href="mediaUrl" :download="mediaFilename">{{ mediaKind === 'audio' ? '下载录音' : '下载文字' }}</a>
</template>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { mobileRecordingPatientLists, type MobileRecordingMetadata } from '@/api/mobileRecording'
import { computed, onActivated, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'
import {
mobileRecordingAudio, mobileRecordingDownloadName, mobileRecordingPatientLists, mobileRecordingTranscript,
MobileRecordingMediaError, type MobileRecordingMetadata
} from '@/api/mobileRecording'
const props = defineProps<{ diagnosisId: number }>()
const rows = ref<MobileRecordingMetadata[]>([])
@@ -43,10 +92,29 @@ const page = ref(1)
const pageSize = 20
const total = ref(0)
let generation = 0
let listController: AbortController | null = null
let suspended = false
const mediaVisible = ref(false)
const mediaKind = ref<'audio' | 'transcript'>('audio')
const mediaRow = ref<MobileRecordingMetadata | null>(null)
const mediaLoading = ref(false)
const mediaReady = ref(false)
const mediaError = ref('')
const mediaUrl = ref('')
const mediaFilename = ref('')
const transcriptText = ref('')
const playbackError = ref('')
const audioElement = ref<HTMLAudioElement | null>(null)
const downloadLoaded = ref(0)
const downloadTotal = ref(0)
let mediaGeneration = 0
let mediaController: AbortController | null = null
const downloadPercent = computed(() => downloadTotal.value > 0 ? Math.min(100, Math.floor(downloadLoaded.value / downloadTotal.value * 100)) : 0)
const downloadProgressText = computed(() => `已下载 ${sizeText(downloadLoaded.value)}${downloadTotal.value > 0 ? ` / ${sizeText(downloadTotal.value)}` : ''}`)
const timeFormatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
})
function recordedTime(value: number): string {
@@ -60,14 +128,106 @@ function durationText(value: number): string {
const remaining = String(seconds % 60).padStart(2, '0')
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remaining : minutes + ':' + remaining
}
function sizeText(bytes: number): string {
return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`
}
function uploadStatus(row: MobileRecordingMetadata): { label: string; type: 'info' | 'primary' | 'warning' | 'success' } {
switch (row.upload_state) {
case 'uploading': return { label: '上传中', type: 'primary' }
case 'partial': return { label: '部分已上传', type: 'warning' }
case 'complete': return { label: '上传完成', type: 'success' }
default: return { label: '未上传', type: 'info' }
}
}
function clearMediaContent() {
try {
audioElement.value?.pause()
audioElement.value?.removeAttribute('src')
audioElement.value?.load()
} catch { /* Revoke and clear content even if the player is already detached. */ }
if (mediaUrl.value) URL.revokeObjectURL(mediaUrl.value)
mediaUrl.value = ''
mediaFilename.value = ''
transcriptText.value = ''
playbackError.value = ''
mediaReady.value = false
downloadLoaded.value = 0
downloadTotal.value = 0
}
function closeMedia() {
mediaGeneration++
mediaController?.abort()
mediaController = null
clearMediaContent()
mediaVisible.value = false
mediaRow.value = null
mediaLoading.value = false
mediaError.value = ''
}
function onMediaVisibility(visible: boolean) { if (!visible) closeMedia() }
function openMedia(row: MobileRecordingMetadata, kind: 'audio' | 'transcript') {
closeMedia()
if (suspended || row.diagnosis_id !== props.diagnosisId || !rows.value.some(item => item.id === row.id)
|| (kind === 'audio' ? row.audio_available !== true : row.transcript_available !== true)) return
mediaRow.value = { ...row }
mediaKind.value = kind
mediaVisible.value = true
void loadMedia()
}
async function loadMedia() {
const row = mediaRow.value
if (!row || !mediaVisible.value || suspended || row.diagnosis_id !== props.diagnosisId) return
const ticket = ++mediaGeneration
mediaController?.abort()
const controller = new AbortController()
mediaController = controller
const diagnosisId = props.diagnosisId
const kind = mediaKind.value
const current = () => ticket === mediaGeneration && !controller.signal.aborted && !suspended
&& mediaVisible.value && diagnosisId === props.diagnosisId && mediaRow.value?.id === row.id
clearMediaContent()
mediaError.value = ''
mediaLoading.value = true
try {
if (kind === 'audio') {
const blob = await mobileRecordingAudio(row.id, diagnosisId, controller.signal, (loaded, size) => {
if (!current()) return
downloadLoaded.value = Math.max(0, loaded)
downloadTotal.value = size && size > 0 ? size : 0
})
if (!current()) return
mediaUrl.value = URL.createObjectURL(blob)
mediaFilename.value = mobileRecordingDownloadName(row.id, 'audio', blob.type)
} else {
const result = await mobileRecordingTranscript(row.id, diagnosisId, controller.signal)
if (!current()) return
transcriptText.value = result.text
mediaUrl.value = URL.createObjectURL(new Blob([result.text], { type: 'text/plain;charset=utf-8' }))
mediaFilename.value = mobileRecordingDownloadName(row.id, 'transcript')
}
mediaReady.value = true
} catch (failure) {
if (!current()) return
clearMediaContent()
mediaError.value = failure instanceof MobileRecordingMediaError ? failure.message : '读取失败,请确认网络和患者权限后重试。'
} finally {
if (current()) mediaLoading.value = false
}
}
async function load() {
const requestId = ++generation
listController?.abort()
closeMedia()
const controller = new AbortController()
listController = controller
const diagnosisId = props.diagnosisId
const requestedPage = page.value
error.value = ''
rows.value = []
total.value = 0
if (!Number.isInteger(diagnosisId) || diagnosisId <= 0) {
if (suspended || !Number.isInteger(diagnosisId) || diagnosisId <= 0) {
loading.value = false
return
}
@@ -75,8 +235,8 @@ async function load() {
try {
const result = await mobileRecordingPatientLists({
diagnosis_id: diagnosisId, page_no: requestedPage, page_size: pageSize
})
if (requestId !== generation || diagnosisId !== props.diagnosisId) return
}, controller.signal)
if (requestId !== generation || controller.signal.aborted || diagnosisId !== props.diagnosisId) return
const lastPage = Math.max(1, Math.ceil(result.count / pageSize))
if (requestedPage > lastPage) {
page.value = lastPage
@@ -86,17 +246,39 @@ async function load() {
rows.value = result.lists
total.value = result.count
} catch {
if (requestId === generation) error.value = '无法读取手机录音关联,请确认患者权限后重试。'
if (requestId === generation && !controller.signal.aborted) error.value = '无法读取手机录音关联,请确认患者权限后重试。'
} finally {
if (requestId === generation) loading.value = false
}
}
watch(() => props.diagnosisId, () => { page.value = 1; void load() }, { immediate: true })
onBeforeUnmount(() => { generation++ })
function stop() {
generation++
listController?.abort()
listController = null
closeMedia()
rows.value = []
total.value = 0
loading.value = false
}
watch(() => props.diagnosisId, () => { page.value = 1; void load() }, { immediate: true, flush: 'sync' })
onDeactivated(() => { suspended = true; stop() })
onActivated(() => { if (suspended) { suspended = false; void load() } })
onBeforeUnmount(() => { suspended = true; stop() })
</script>
<style scoped>
.mobile-recording-panel { display: flex; flex-direction: column; gap: 12px; }
.panel-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #606266; }
.mobile-recording-panel, .media-detail { display: flex; flex-direction: column; gap: 16px; }
.panel-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #606266; line-height: 1.7; }
.panel-toolbar .el-button { flex-shrink: 0; }
.media-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.media-actions .el-button { margin-left: 0; }
.muted { color: #909399; font-size: 12px; }
.media-meta { display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; background: #f7f9fc; border-radius: 10px; color: #606266; font-size: 13px; }
.media-loading { display: flex; flex-direction: column; gap: 14px; padding: 20px 0; color: #606266; }
.media-loading .el-button, .media-detail > .el-button { align-self: flex-start; }
.audio-player { width: 100%; }
.transcript-text { margin: 0; padding: 16px; max-height: 65vh; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; line-height: 1.9; color: #303133; background: #fafbfc; border: 1px solid #ebeef5; border-radius: 10px; }
.media-download { align-self: flex-start; color: var(--el-color-primary); text-decoration: none; padding: 6px 0; }
.media-download:hover { text-decoration: underline; }
.el-pagination { justify-content: flex-end; }
</style>
+207
View File
@@ -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(['<html>login</html>'], { 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 = '<img src=x onerror=alert(1)>\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 = '<img src=x onerror=alert(1)>\n<script>unsafe()</script>'
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()
})
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace app\adminapi\controller\tcm;
use app\adminapi\controller\BaseAdminController;
use app\adminapi\logic\tcm\MobileRecordingLogic;
use app\adminapi\logic\tcm\MobileRecordingMediaLogic as Logic;
use app\common\service\mobilerecording\MobileRecordingResyncException;
use DomainException;
use think\Response;
final class MobileRecordingMediaController extends BaseAdminController
{
public function prepare() { return $this->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);
}
}
}
@@ -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() ?? []);
@@ -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('请求包含不支持的字段');
@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\tcm;
use app\common\service\mobilerecording\MobileRecordingStorage as Storage;
use app\common\service\mobilerecording\MobileRecordingStream;
use app\common\service\mobilerecording\MobileRecordingResyncException;
use DomainException;
use think\facade\Db;
/** The binding is the authorization anchor; upload IDs are not bearer credentials. */
final class MobileRecordingMediaLogic
{
public const CHUNK_SIZE = 1048576;
public const AUDIO_MAX = 536870912;
public const TEXT_MAX = 8388608;
private const MIMES = ['audio/wav', 'audio/flac', 'audio/ogg', 'audio/mpeg', 'audio/mp4', 'audio/amr'];
public static function prepare(array $p, int $actor): array
{
MobileRecordingLogic::only($p, ['installation_id', 'recording_key', 'diagnosis_id', 'kind', 'size_bytes', 'sha256', 'media_type']);
$identity = MobileRecordingLogic::identity($p);
$diagnosis = self::diagnosisId($p);
$kind = $p['kind'] ?? '';
if (!in_array($kind, ['audio', 'transcript'], true)) { throw new DomainException('上传类型无效'); }
$size = MobileRecordingLogic::integer($p['size_bytes'] ?? null, $kind === 'audio' ? 1 : 0,
$kind === 'audio' ? self::AUDIO_MAX : self::TEXT_MAX, '文件大小');
$sha = self::hash($p['sha256'] ?? null);
$mime = $p['media_type'] ?? '';
if (!is_string($mime) || ($kind === 'audio' ? !in_array($mime, self::MIMES, true) : $mime !== 'text/plain')) {
throw new DomainException('文件媒体类型无效');
}
return self::mutate(static function (array &$garbage) use ($identity, $diagnosis, $kind, $size, $sha, $mime, $actor): array {
$binding = self::owned($identity, $diagnosis, $actor);
$id = (int) $binding['id'];
$existing = Db::name('mobile_recording_upload')->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']];
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\mobilerecording\MobileRecordingStorage;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
final class MobileRecordingCleanup extends Command
{
protected function configure()
{
$this->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;
}
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace app\common\service\mobilerecording;
/** Retry only after re-reading the authoritative cursor via prepare/status. */
final class MobileRecordingResyncException extends \DomainException
{
public const ERROR_CODE = 'upload_resync';
}
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace app\common\service\mobilerecording;
use DomainException;
use RuntimeException;
/** Private, opaque object names. Never return a storage path to an API caller. */
final class MobileRecordingStorage
{
private static function root(): string
{
$root = (string) config('mobile_recording_media.storage_root', root_path() . 'storage/private/mobile_recording');
if ($root === '' || (!is_dir($root) && !@mkdir($root, 0700, true) && !is_dir($root))) {
throw new RuntimeException('Private recording storage unavailable');
}
$real = realpath($root);
$public = realpath(root_path() . 'public');
$normalized = strtolower(str_replace('\\', '/', (string) $real));
$publicNormalized = strtolower(str_replace('\\', '/', (string) $public));
if (!$real || ($public && ($normalized === $publicNormalized || str_starts_with($normalized, $publicNormalized . '/')))) {
throw new RuntimeException('Private recording storage must be outside public');
}
return $real;
}
private static function path(string $id): string
{
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
throw new RuntimeException('Invalid internal recording object');
}
$path = self::root() . DIRECTORY_SEPARATOR . $id . '.bin';
if (is_link($path)) { throw new RuntimeException('Invalid private recording object'); }
return $path;
}
public static function locked(string $id, callable $operation)
{
$path = self::path($id);
$file = @fopen($path, 'c+b');
if (!$file) { throw new RuntimeException('Private recording write failed'); }
@chmod($path, 0600);
try {
if (!flock($file, LOCK_EX)) { throw new RuntimeException('Private recording lock failed'); }
return $operation($file);
} finally {
flock($file, LOCK_UN);
fclose($file);
}
}
/** Commit cursor is authoritative after a process died between disk write and DB commit. */
public static function reconcile($file, int $committed): int
{
$size = (int) fstat($file)['size'];
if ($size > $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;
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace app\common\service\mobilerecording;
/** One authenticated byte range; no buffering of an entire recording. */
final class MobileRecordingStream extends \think\Response
{
private $file;
private int $start = 0;
private int $length = 0;
public function __construct($file, int $size, string $mime, string $range = '')
{
$this->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); }
}
}
+1
View File
@@ -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',
+6
View File
@@ -0,0 +1,6 @@
<?php
// Persistent private storage, never a URL or a directory beneath server/public.
return [
'storage_root' => env('mobile_recording_media.storage_root', root_path() . 'storage/private/mobile_recording'),
];
@@ -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;
@@ -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],
+271
View File
@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
/** Synthetic files and actors only. Never initializes the app or loads .env. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\logic\tcm\MobileRecordingLogic as Binding;
use app\adminapi\logic\tcm\MobileRecordingMediaLogic as Media;
use app\adminapi\controller\tcm\MobileRecordingMediaController;
use think\Container;
use think\facade\Db;
$app = new think\App();
$manager = new think\DbManager();
$driver = 'sqlite';
$pdo = null;
if (isset($argv[1])) {
$state = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR);
if ($state['schema'] !== 'synthetic_mobile_media_test' || (int) $state['port'] === 3306
|| !str_contains(str_replace('\\', '/', $state['run_root']), '/patient-media-mysql-20261109-')) {
throw new RuntimeException('Refusing non-synthetic MySQL target');
}
$pdo = new PDO('mysql:host=127.0.0.1;port=' . (int) $state['port'], 'root', '', [PDO::ATTR_ERRMODE => 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<script>never render HTML</script>");
Media::complete($textUpload, 11);
$text = Media::transcript(['binding_id' => $id, 'diagnosis_id' => 101], 11);
$expect(str_contains($text['text'], '<script>') && strlen($text['text']) === $text['size_bytes'], 'UTF8 transcript round trips as plain text');
$expect(Binding::patientLists(['diagnosis_id' => 101], 11)['lists'][0]['upload_state'] === 'complete', 'both assets report complete');
$invalid = $sendText("\xff\xfe");
$expect(Media::transcript(['binding_id' => $id], 11)['text'] === $text['text'], 'pending replacement preserves last completed transcript');
$denied(fn () => Media::complete($invalid, 11), 'non-UTF8 rejected');
$expect(Media::status($target, 11)['transcript']['received_bytes'] === 0, 'validation failure resets retry cursor');
$badHash = $sendText('abc', str_repeat('0', 64));
$denied(fn () => Media::complete($badHash, 11), 'wrong whole-file SHA rejected');
$empty = $sendText('');
Media::complete($empty, 11);
$expect(Media::transcript(['binding_id' => $id], 11)['text'] === '', 'empty UTF8 transcript supported');
$expect(!is_file($storage . '/' . $textUpload['upload_id'] . '.bin'), 'replaced completed content cleaned up');
$invalidAudio = Media::prepare(array_replace($prepare, ['size_bytes' => 12, 'sha256' => hash('sha256', 'not an audio')]), 11);
$badAudioParams = ['upload_id' => $invalidAudio['upload_id'], 'diagnosis_id' => 101];
Media::chunk($badAudioParams + ['offset' => 0, 'chunk_sha256' => hash('sha256', 'not an audio')], 'not an audio', 11);
$formatError = $controller('complete', $badAudioParams)->getData();
$expect($formatError['code'] === 0 && $formatError['data'] === $rejected, 'claimed WAV with invalid signature rejected without automatic retry');
$expect($capture(Media::audio(['binding_id' => $id], 11, 'bytes=0-3')) === 'RIFF', 'invalid replacement never replaces published audio');
Binding::bind(array_replace($input, ['diagnosis_id' => 102]), 11);
$denied(fn () => Media::complete($badAudioParams, 11), 'old pending rejected after reassignment');
$denied(fn () => Media::status($target, 11), 'old diagnosis upload target rejected');
$denied(fn () => Media::audio(['binding_id' => $id, 'diagnosis_id' => 101], 11), 'old archive anchor rejected after reassignment');
$denied(fn () => Media::audio(['binding_id' => $id], 13), 'old patient doctor cannot read moved media');
$expect($capture(Media::audio(['binding_id' => $id, 'diagnosis_id' => 102], 11, 'bytes=0-3')) === 'RIFF', 'completed media moves with current binding');
Binding::bind($input, 11);
$denied(fn () => Media::complete($badAudioParams, 11), 'A to B to A cannot resurrect old pending');
$expect(!is_file($storage . '/' . $badAudioParams['upload_id'] . '.bin'), 'reassignment cleans cancelled pending bytes');
Binding::unbind($identity, 12);
$expect(Media::status($target, 11)['audio']['state'] === 'complete', 'other account unbind cannot remove assets');
touch($storage . '/' . $session['upload_id'] . '.bin', time() - 90000);
$protected = \app\common\service\mobilerecording\MobileRecordingStorage::cleanupOrphans(false);
$expect($protected['eligible'] === 0 && is_file($storage . '/' . $session['upload_id'] . '.bin'), 'orphan cleanup preserves referenced patient content regardless of age');
Binding::unbind($identity, 11);
$denied(fn () => Media::audio(['binding_id' => $id], 11), 'unbind immediately revokes content reads');
$denied(fn () => Media::chunk($chunk, $first, 11), 'unbind immediately revokes pending writes');
$expect(Db::name('mobile_recording_upload')->count() === 0 && Db::name('mobile_recording_asset')->count() === 0, 'unbind removes all media DB references');
$expect((glob($storage . '/*.bin') ?: []) === [], 'unbind removes private files');
$orphan = $storage . '/' . str_repeat('f', 32) . '.bin';
file_put_contents($orphan, 'synthetic orphan');
touch($orphan, time() - 90000);
$cleanup = \app\common\service\mobilerecording\MobileRecordingStorage::cleanupOrphans(true);
$expect($cleanup['eligible'] === 1 && is_file($orphan), 'orphan cleanup dry run preserves files');
$cleanup = \app\common\service\mobilerecording\MobileRecordingStorage::cleanupOrphans(false);
$expect($cleanup['deleted'] === 1 && !is_file($orphan), 'orphan cleanup removes only aged unreferenced objects');
Binding::bind($input, 11);
Db::execute('DROP TABLE zyt_mobile_recording_upload');
$missingSchema = $controller('prepare', $prepare)->getData();
$expect($missingSchema['code'] === 0 && !str_contains(json_encode($missingSchema), $storage), 'missing migration fails closed without SQL or storage path');
$expect($missingSchema['msg'] === '手机录音上传服务暂不可用,请稍后重试或联系管理员'
&& $missingSchema['data'] === ['error_code' => 'temporarily_unavailable', 'retryable' => true],
'Throwable preserves code0 and message while allowing bounded automatic retry');
$expect(Db::name('mobile_recording_binding')->count() === 1, 'media deployment failure preserves original metadata binding');
$middleware = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
$expect(strpos($middleware, "'tcm.mobilerecordingmedia/'") < strpos($middleware, '$allUri ='), 'media group protected before unregistered-route bypass');
echo json_encode(['status' => 'passed', 'driver' => $driver, 'checks' => count($checks), 'evidence' => $checks,
'storage' => 'temporary synthetic objects cleaned in finally', 'production_connected' => false], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
} finally {
// Only direct objects in the unique directory created above; never walk unrelated directories.
foreach (glob($storage . '/*.bin') ?: [] as $file) { if (!is_link($file)) { @unlink($file); } }
if (is_dir($storage)) { @rmdir($storage); }
}
+10
View File
@@ -0,0 +1,10 @@
<?php
// SQLite-only schema for synthetic in-memory tests; MySQL tests execute the real migration.
\think\facade\Db::execute('CREATE TABLE zyt_mobile_recording_upload (
upload_id TEXT PRIMARY KEY, binding_id INTEGER NOT NULL, diagnosis_id INTEGER NOT NULL,
kind TEXT NOT NULL, sha256 TEXT NOT NULL, media_type TEXT NOT NULL, size_bytes INTEGER NOT NULL,
received_bytes INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT "uploading",
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(binding_id,kind,sha256))');
\think\facade\Db::execute('CREATE TABLE zyt_mobile_recording_asset (
binding_id INTEGER NOT NULL, kind TEXT NOT NULL, upload_id TEXT NOT NULL UNIQUE,
updated_at INTEGER NOT NULL, PRIMARY KEY(binding_id,kind))');