更新
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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))');
|
||||
Reference in New Issue
Block a user