更新
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface MobileRecordingMetadata {
|
||||
id: number
|
||||
diagnosis_id: number
|
||||
source_platform: 'android_bcr'
|
||||
recorded_at: number
|
||||
duration_ms: number
|
||||
bound_by: string
|
||||
playback_location: 'original_device'
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface MobileRecordingPage {
|
||||
lists: MobileRecordingMetadata[]
|
||||
count: number
|
||||
page_no: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export function mobileRecordingPatientLists(params: {
|
||||
diagnosis_id: number
|
||||
page_no: number
|
||||
page_size: number
|
||||
}): Promise<MobileRecordingPage> {
|
||||
return request.get({ url: '/tcm.mobileRecording/patientLists', params })
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="mobile-recording-panel">
|
||||
<div class="panel-toolbar">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="total > pageSize"
|
||||
v-model:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="load"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { mobileRecordingPatientLists, type MobileRecordingMetadata } from '@/api/mobileRecording'
|
||||
|
||||
const props = defineProps<{ diagnosisId: number }>()
|
||||
const rows = ref<MobileRecordingMetadata[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
let generation = 0
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
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 {
|
||||
return value > 0 && Number.isFinite(value) ? timeFormatter.format(new Date(value)) : '未知'
|
||||
}
|
||||
function durationText(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return '未知'
|
||||
const seconds = Math.floor(value / 1000)
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const remaining = String(seconds % 60).padStart(2, '0')
|
||||
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remaining : minutes + ':' + remaining
|
||||
}
|
||||
async function load() {
|
||||
const requestId = ++generation
|
||||
const diagnosisId = props.diagnosisId
|
||||
const requestedPage = page.value
|
||||
error.value = ''
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
if (!Number.isInteger(diagnosisId) || diagnosisId <= 0) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await mobileRecordingPatientLists({
|
||||
diagnosis_id: diagnosisId, page_no: requestedPage, page_size: pageSize
|
||||
})
|
||||
if (requestId !== generation || diagnosisId !== props.diagnosisId) return
|
||||
const lastPage = Math.max(1, Math.ceil(result.count / pageSize))
|
||||
if (requestedPage > lastPage) {
|
||||
page.value = lastPage
|
||||
await load()
|
||||
return
|
||||
}
|
||||
rows.value = result.lists
|
||||
total.value = result.count
|
||||
} catch {
|
||||
if (requestId === generation) error.value = '无法读取手机录音关联,请确认患者权限后重试。'
|
||||
} finally {
|
||||
if (requestId === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
watch(() => props.diagnosisId, () => { page.value = 1; void load() }, { immediate: true })
|
||||
onBeforeUnmount(() => { generation++ })
|
||||
</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; }
|
||||
.el-pagination { justify-content: flex-end; }
|
||||
</style>
|
||||
@@ -800,6 +800,19 @@
|
||||
<el-empty v-else description="请先保存诊单后再查看挂号记录" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane
|
||||
v-if="hasPermission(['firstvisit.myPatient/lists'])"
|
||||
label="手机录音关联"
|
||||
name="mobileRecording"
|
||||
:disabled="!formData.id"
|
||||
lazy
|
||||
>
|
||||
<MobileRecordingPanel
|
||||
v-if="visible && activeTab === 'mobileRecording' && formData.id"
|
||||
:key="Number(formData.id)"
|
||||
:diagnosis-id="Number(formData.id)"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 处方详情(查看病历) -->
|
||||
@@ -840,6 +853,7 @@ import DailyMatrix from './components/DailyMatrix.vue'
|
||||
import FollowupAudioPanel from './components/FollowupAudioPanel.vue'
|
||||
import { followupAudioCapabilities, type FollowupCapabilities } from '@/api/followupAudio'
|
||||
import CaseRecordList from './components/CaseRecordList.vue'
|
||||
import MobileRecordingPanel from './components/MobileRecordingPanel.vue'
|
||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||
import ConsultationTranscriptPanel from './components/ConsultationTranscriptPanel.vue'
|
||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||
|
||||
@@ -98,6 +98,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasPermission(['firstvisit.myPatient/lists'])" class="card section-card">
|
||||
<div class="card-title">手机录音关联</div>
|
||||
<div class="card-body card-body--matrix-align">
|
||||
<MobileRecordingPanel
|
||||
v-if="diagnosisNumericId > 0"
|
||||
:key="diagnosisNumericId"
|
||||
:diagnosis-id="diagnosisNumericId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 视频录制回放记录 -->
|
||||
<div v-if="hasPermission(['tcm.diagnosis/huifang'])" class="card section-card">
|
||||
<div class="card-title">视频录制回放记录</div>
|
||||
@@ -176,6 +186,7 @@ import {
|
||||
import PatientInfoCard from './components/PatientInfoCard.vue'
|
||||
import PatientCaseCard from './components/PatientCaseCard.vue'
|
||||
import DailyMatrix from './components/DailyMatrix.vue'
|
||||
import MobileRecordingPanel from './components/MobileRecordingPanel.vue'
|
||||
import CallRecordPanel from './components/CallRecordPanel.vue'
|
||||
import ImChatRecordPanel from './components/ImChatRecordPanel.vue'
|
||||
import AssignLogPanel from './components/AssignLogPanel.vue'
|
||||
|
||||
@@ -115,6 +115,25 @@ qt_multimedia_hiddenimports = [
|
||||
if importlib.util.find_spec("opencc") is None:
|
||||
raise SystemExit("Install the project dependencies before packaging (missing opencc)")
|
||||
transcript_datas, transcript_binaries, transcript_hiddenimports = collect_all("opencc")
|
||||
# The realtime path remains Tencent. This explicit build profile adds offline
|
||||
# recovery for uploaded recordings whose realtime text is missing/incomplete.
|
||||
# It never installs dependencies or downloads a model during build/runtime.
|
||||
if os.environ.get("DOCTOR_INCLUDE_AUDIO_RECOVERY") == "1":
|
||||
for package in ("faster_whisper", "ctranslate2", "onnxruntime", "av", "tokenizers"):
|
||||
if importlib.util.find_spec(package) is None:
|
||||
raise SystemExit(f"Audio recovery requires the local-asr extra: missing {package}")
|
||||
data, binaries, imports = collect_all(package)
|
||||
transcript_datas += data
|
||||
transcript_binaries += binaries
|
||||
transcript_hiddenimports += imports
|
||||
recovery_model = Path(os.environ.get("DOCTOR_ASR_MODEL_PATH") or RESOURCES / "local-asr" / "model")
|
||||
required = ("model.bin", "config.json", "tokenizer.json")
|
||||
if (not all((recovery_model / name).is_file() and (recovery_model / name).stat().st_size > 0
|
||||
for name in required)
|
||||
or not any((recovery_model / name).is_file() and (recovery_model / name).stat().st_size > 0
|
||||
for name in ("vocabulary.txt", "vocabulary.json"))):
|
||||
raise SystemExit("Audio recovery requires a complete, already installed local model")
|
||||
transcript_datas.append((str(recovery_model.resolve()), "resources/local-asr/model"))
|
||||
client_resources = [
|
||||
(str(entry), f"resources/{entry.name}" if entry.is_dir() else "resources")
|
||||
for entry in RESOURCES.iterdir()
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 录音已上传但文字缺失:修复及生效(2026-11-06)
|
||||
|
||||
录音上传和实时文字保存原本是两条独立流程。个别客户端发生识别中断或文字请求失败时,音频仍会正常上传。本次不改变腾讯云实时识别通路;增加失败收尾重试,以及通话结束后用本机保留录音补全文字的独立恢复队列。
|
||||
|
||||
## 用户操作
|
||||
|
||||
新录音上传后自动检查同一通话的文字;已有完整文字直接保留。若通话还在收尾,有界等待结束后继续。打开「视频录制回放 → 本机录音文件」也会接管旧的待处理记录。上传和文字各有状态;文字失败可以点「重试文字」,不重传录音。保存成功后,在诊单「视频录制回放 → 查看文字」查看。
|
||||
|
||||
识别完成但网络保存失败时,文字暂存在本机队列,重试复用相同恢复编号和文字;成功后清除本地文字草稿,原录音仍保留。补转写不猜测说话人,服务端标为未知说话人。音频识别上限为 250 MB、2 小时,文字上限 100000 字符,超限或未识别到语音会明确失败,不保存截断结果。
|
||||
|
||||
## 服务端同步
|
||||
|
||||
部署以下四个运行文件;沿用已有通话文字表,不新增数据库字段:
|
||||
|
||||
- `server/app/adminapi/controller/tcm/DiagnosisController.php`
|
||||
- `server/app/adminapi/logic/tcm/DiagnosisLogic.php`
|
||||
- `server/app/adminapi/http/middleware/AuthMiddleware.php`
|
||||
- `server/app/adminapi/service/LocalAudioTranscriptRecovery.php`
|
||||
|
||||
新接口 `POST tcm.diagnosis/saveLocalAudioTranscript` 要求账号具备现有 start/upsert/finish 三项转写权限,同时验证当前医生是该通话发起者、通话已结束且上传音频绑定一致。原 session/status 变化时拒绝旧结果;完整文字不覆盖,旧会话分段保留。接口未部署或权限缺失时,客户端不会误报文字已保存。
|
||||
|
||||
## 客户端生效
|
||||
|
||||
源码测试版需在结束通话后退出并重新打开整个应用。本轮已构建 `video_companion/dist`。冻结安装包需要重新构建,单独替换网页资源无法更新原生重试和队列代码。
|
||||
|
||||
补转写使用已有可选 `local-asr` 组件和完整本地模型,不会自动下载安装。当前开发机已验证可用;其他电脑缺少组件会在「文字状态」显示失败原因,录音保持已上传。安装方法见 `local-transcription-setup.md` 的开发环境安装命令;该文其他历史实时通路说明不适用于当前腾讯云模式。
|
||||
|
||||
如需把补转写能力提供给冻结客户端,在构建 Python 环境中安装 `.[build,local-asr]`,准备完整模型,再设置 `DOCTOR_INCLUDE_AUDIO_RECOVERY=1` 后运行现有打包脚本。模型默认取 `app/resources/local-asr/model`,也支持 `DOCTOR_ASR_MODEL_PATH`。此显式配置将打包运行库、VAD 和模型;缺少文件会中止构建。默认安装包仍采用原有腾讯云依赖配置。本轮未生成或验收新 exe。
|
||||
|
||||
## 验证范围
|
||||
|
||||
使用合成通话验证上传与文字状态分离、上传先于挂断、重复提交、保存失败后跨重启复用草稿、原文字保护、身份与音频绑定、服务端事务和迟到实时写入。使用仓库已有公开中文样本运行真实本地模型,验证期间禁网且未输出对话原文。未改写线上通话文字,线上修复效果仍需部署后验证。
|
||||
@@ -3,7 +3,7 @@
|
||||
__all__ = ["DEBUG_MODE", "ONLINE_API_BASE_URL", "__version__"]
|
||||
|
||||
# Single source of truth for runtime, package, installer, and executable versions.
|
||||
__version__ = "1.5.2"
|
||||
__version__ = "1.5.3"
|
||||
|
||||
# 调试模式开启时,登录页显示“演示模式”和“服务器设置”。
|
||||
# 正式发布请保持 False;此时程序只使用下面配置的线上域名。
|
||||
|
||||
@@ -7,6 +7,7 @@ audio file after a successful upload as well as after a failed one.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import sqlite3
|
||||
import threading
|
||||
@@ -22,7 +23,12 @@ from uuid import uuid4
|
||||
|
||||
from platformdirs import user_data_path
|
||||
|
||||
from ..core.errors import ApiBusinessError, ApiTransportError, AuthenticationExpiredError
|
||||
from ..video.local_transcription import LocalTranscriptionError
|
||||
from ..video.recording_transcription import RecordingTranscriber, RecordingTranscriptionError
|
||||
|
||||
_MAX_UPLOAD_WORKERS = 3
|
||||
_TRANSCRIPTION_END_RETRY_DELAYS = (2, 5, 10, 20, 30, 60, 60)
|
||||
_RETRYABLE_STATUSES = {"pending", "failed"}
|
||||
_RECOVERY_LOCK = threading.Lock()
|
||||
_RECOVERED_DATABASES: set[Path] = set()
|
||||
@@ -30,6 +36,10 @@ _MANAGER_LOCK = threading.Lock()
|
||||
_MANAGERS: dict[tuple[int, Path], LocalAudioUploadManager] = {}
|
||||
|
||||
|
||||
class _CallStillEnding(RuntimeError):
|
||||
"""An upload can finish before the realtime transcript and call end drain."""
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(UTC).isoformat(timespec="seconds")
|
||||
|
||||
@@ -79,6 +89,9 @@ class LocalAudioRecord:
|
||||
created_at: str
|
||||
updated_at: str
|
||||
uploaded_at: str
|
||||
transcription_status: str = "pending"
|
||||
transcription_error: str = ""
|
||||
transcription_recovery_id: str = ""
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
@@ -139,6 +152,17 @@ class LocalAudioQueueStore:
|
||||
"ALTER TABLE local_audio_uploads "
|
||||
"ADD COLUMN room_id TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
for name, default in (
|
||||
("transcription_status", "pending"),
|
||||
("transcription_error", ""),
|
||||
("transcription_recovery_id", ""),
|
||||
("transcription_draft", ""),
|
||||
):
|
||||
if name not in columns:
|
||||
connection.execute(
|
||||
f"ALTER TABLE local_audio_uploads ADD COLUMN {name} "
|
||||
f"TEXT NOT NULL DEFAULT '{default}'"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_local_audio_diagnosis "
|
||||
"ON local_audio_uploads(diagnosis_id, id DESC)"
|
||||
@@ -151,6 +175,11 @@ class LocalAudioQueueStore:
|
||||
return
|
||||
with self._connect() as connection:
|
||||
now = _utc_now()
|
||||
connection.execute(
|
||||
"UPDATE local_audio_uploads SET transcription_status = 'pending', "
|
||||
"transcription_error = '上次文字处理未完成,将继续处理。' "
|
||||
"WHERE transcription_status = 'transcribing'"
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE local_audio_uploads
|
||||
@@ -194,6 +223,9 @@ class LocalAudioQueueStore:
|
||||
created_at=str(row["created_at"]),
|
||||
updated_at=str(row["updated_at"]),
|
||||
uploaded_at=str(row["uploaded_at"] or ""),
|
||||
transcription_status=str(row["transcription_status"] or "pending"),
|
||||
transcription_error=str(row["transcription_error"] or ""),
|
||||
transcription_recovery_id=str(row["transcription_recovery_id"] or ""),
|
||||
)
|
||||
|
||||
def begin_recording(
|
||||
@@ -433,6 +465,62 @@ class LocalAudioQueueStore:
|
||||
)
|
||||
return self.require(record_id)
|
||||
|
||||
def claim_transcription(self, record_id: int) -> LocalAudioRecord | None:
|
||||
with self._connect() as connection:
|
||||
cursor = connection.execute(
|
||||
"""
|
||||
UPDATE local_audio_uploads
|
||||
SET transcription_status = 'transcribing', transcription_error = '',
|
||||
transcription_recovery_id = CASE
|
||||
WHEN transcription_recovery_id = '' THEN ?
|
||||
ELSE transcription_recovery_id END,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND status = 'uploaded'
|
||||
AND transcription_status IN ('pending', 'failed')
|
||||
""",
|
||||
(f"audio-{uuid4().hex}", _utc_now(), int(record_id)),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
return None
|
||||
return self.require(record_id)
|
||||
|
||||
def transcription_draft(self, record_id: int) -> str:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT transcription_draft FROM local_audio_uploads WHERE id = ?",
|
||||
(int(record_id),),
|
||||
).fetchone()
|
||||
return str(row[0] or "") if row else ""
|
||||
|
||||
def save_transcription_draft(self, record_id: int, text: str) -> None:
|
||||
# Keep recognized text until the server confirms persistence. A network
|
||||
# retry/restart must not need to run speech recognition a second time.
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE local_audio_uploads SET transcription_draft = ? WHERE id = ?",
|
||||
(text, int(record_id)),
|
||||
)
|
||||
|
||||
def finish_transcription(self, record_id: int, *, error: str = "") -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE local_audio_uploads
|
||||
SET transcription_status = ?, transcription_error = ?, updated_at = ?,
|
||||
transcription_draft = CASE WHEN ? = '' THEN '' ELSE transcription_draft END
|
||||
WHERE id = ?
|
||||
""",
|
||||
("failed" if error else "completed", error[:1000], _utc_now(), error, int(record_id)),
|
||||
)
|
||||
|
||||
def defer_transcription(self, record_id: int) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE local_audio_uploads SET transcription_status = 'pending', "
|
||||
"transcription_error = '等待通话结束后自动补全文字。' WHERE id = ?",
|
||||
(int(record_id),),
|
||||
)
|
||||
|
||||
|
||||
def _upload_url(result: Any) -> str:
|
||||
if not isinstance(result, dict):
|
||||
@@ -455,6 +543,7 @@ class LocalAudioUploadManager:
|
||||
store: LocalAudioQueueStore,
|
||||
*,
|
||||
max_workers: int = _MAX_UPLOAD_WORKERS,
|
||||
transcriber: Callable[[Path], str] | None = None,
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.store = store
|
||||
@@ -463,6 +552,11 @@ class LocalAudioUploadManager:
|
||||
self._active: dict[int, Future[bool]] = {}
|
||||
self._upload_listeners: set[Callable[[LocalAudioRecord], None]] = set()
|
||||
self._lock = threading.Lock()
|
||||
self._transcriber = transcriber or RecordingTranscriber()
|
||||
self._transcription_jobs: queue.Queue[tuple[int, Future[bool]]] = queue.Queue()
|
||||
self._transcription_active: dict[int, Future[bool]] = {}
|
||||
self._transcription_waits: dict[int, int] = {}
|
||||
self._transcription_worker_started = False
|
||||
for index in range(self.max_workers):
|
||||
threading.Thread(
|
||||
target=self._worker,
|
||||
@@ -522,6 +616,147 @@ class LocalAudioUploadManager:
|
||||
if record.status == "pending"
|
||||
]
|
||||
|
||||
def submit_pending_transcriptions(
|
||||
self, *, diagnosis_id: int | None = None
|
||||
) -> list[Future[bool]]:
|
||||
if not callable(getattr(self.repository, "save_local_audio_transcript", None)):
|
||||
return []
|
||||
return [
|
||||
self.submit_transcription(record.id)
|
||||
for record in self.store.list_records(diagnosis_id=diagnosis_id)
|
||||
if record.status == "uploaded" and record.transcription_status == "pending"
|
||||
]
|
||||
|
||||
def submit_transcription(self, record_id: int) -> Future[bool]:
|
||||
record_id = int(record_id)
|
||||
with self._lock:
|
||||
existing = self._transcription_active.get(record_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
future: Future[bool] = Future()
|
||||
self._transcription_active[record_id] = future
|
||||
if not self._transcription_worker_started:
|
||||
self._transcription_worker_started = True
|
||||
threading.Thread(
|
||||
target=self._transcribe_worker, name="local-audio-text", daemon=True
|
||||
).start()
|
||||
self._transcription_jobs.put((record_id, future))
|
||||
return future
|
||||
|
||||
def _transcribe_worker(self) -> None:
|
||||
while True:
|
||||
record_id, future = self._transcription_jobs.get()
|
||||
try:
|
||||
try:
|
||||
succeeded = self._transcribe(record_id)
|
||||
except _CallStillEnding:
|
||||
waited = self._transcription_waits.get(record_id, 0)
|
||||
try:
|
||||
if waited < len(_TRANSCRIPTION_END_RETRY_DELAYS):
|
||||
self.store.defer_transcription(record_id)
|
||||
self._transcription_waits[record_id] = waited + 1
|
||||
# Leave this Future active so repeated clicks coalesce.
|
||||
# A timer requeues the job without blocking other files.
|
||||
timer = threading.Timer(
|
||||
_TRANSCRIPTION_END_RETRY_DELAYS[waited],
|
||||
self._transcription_jobs.put,
|
||||
args=((record_id, future),),
|
||||
)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
continue
|
||||
except Exception:
|
||||
pass # Resolve the Future even if a local queue write fails.
|
||||
with suppress(Exception):
|
||||
self.store.finish_transcription(
|
||||
record_id, error="通话结束状态尚未同步,请稍后重试文字。"
|
||||
)
|
||||
succeeded = False
|
||||
except Exception as error:
|
||||
if isinstance(error, (RecordingTranscriptionError, LocalTranscriptionError)):
|
||||
message = str(error)
|
||||
elif isinstance(error, AuthenticationExpiredError):
|
||||
message = "登录已失效,请重新登录后重试文字;本机录音和已识别文字已保留。"
|
||||
elif isinstance(error, ApiTransportError):
|
||||
message = "文字同步失败,请检查网络及服务端版本后重试文字;录音无需重传。"
|
||||
elif isinstance(error, ApiBusinessError):
|
||||
message = "服务端未接受文字,请检查转写权限、通话状态及服务端版本后重试。"
|
||||
else:
|
||||
message = "文字处理未完成,请检查本地转写安装及服务端接口后重试文字。"
|
||||
with suppress(Exception):
|
||||
self.store.finish_transcription(record_id, error=message)
|
||||
succeeded = False
|
||||
with self._lock:
|
||||
self._transcription_active.pop(record_id, None)
|
||||
self._transcription_waits.pop(record_id, None)
|
||||
if not future.done():
|
||||
future.set_result(succeeded)
|
||||
finally:
|
||||
self._transcription_jobs.task_done()
|
||||
|
||||
def _transcribe(self, record_id: int) -> bool:
|
||||
record = self.store.claim_transcription(record_id)
|
||||
if record is None:
|
||||
current = self.store.get(record_id)
|
||||
return bool(current and current.transcription_status == "completed")
|
||||
save = getattr(self.repository, "save_local_audio_transcript", None)
|
||||
if not callable(save):
|
||||
raise RecordingTranscriptionError("当前服务未提供文字补全接口,请更新应用及服务端。")
|
||||
records = self.repository.list_call_records(record.diagnosis_id)
|
||||
call = next(
|
||||
(row for row in records if isinstance(row, dict)
|
||||
and str(row.get("id")) == str(record.call_record_id)),
|
||||
None,
|
||||
)
|
||||
if call is None:
|
||||
raise RecordingTranscriptionError("未找到对应通话记录,请检查账号和诊单后重试文字。")
|
||||
if call.get("transcription_status") == "completed" and str(call.get("transcript_text") or "").strip():
|
||||
self.store.finish_transcription(record.id)
|
||||
self._notify_uploaded(record.id)
|
||||
return True
|
||||
user = self.repository.get_current_user()
|
||||
if (int(getattr(user, "id", 0) or 0) <= 0
|
||||
or str(call.get("caller_id")) != str(user.id)
|
||||
or call.get("caller_type") != "doctor"):
|
||||
raise RecordingTranscriptionError("请使用发起这次通话的医生账号补全文字。")
|
||||
if int(call.get("status") or 0) == 1:
|
||||
raise _CallStillEnding()
|
||||
if int(call.get("status") or 0) != 2:
|
||||
raise RecordingTranscriptionError("通话尚未结束,请结束通话后重试文字。")
|
||||
if not record.uploaded_url or int(call.get("local_audio_status") or 0) != 2:
|
||||
raise RecordingTranscriptionError("服务端尚未确认录音上传,请刷新后重试文字。")
|
||||
audio_urls = call.get("local_audio_urls_list")
|
||||
if not isinstance(audio_urls, list):
|
||||
try:
|
||||
audio_urls = json.loads(str(call.get("local_audio_urls") or "[]"))
|
||||
except (TypeError, ValueError):
|
||||
audio_urls = []
|
||||
if not isinstance(audio_urls, list) or record.uploaded_url not in audio_urls:
|
||||
raise RecordingTranscriptionError("上传录音与通话记录不匹配,请刷新后核实。")
|
||||
text = self.store.transcription_draft(record.id)
|
||||
if not text:
|
||||
text = self._transcriber(record.file_path)
|
||||
if not isinstance(text, str) or not text.strip() or len(text) > 100_000:
|
||||
raise RecordingTranscriptionError("未生成可保存的文字,请检查录音及本地模型后重试。")
|
||||
self.store.save_transcription_draft(record.id, text)
|
||||
result = save(
|
||||
diagnosis_id=record.diagnosis_id,
|
||||
call_record_id=record.call_record_id,
|
||||
recovery_id=record.transcription_recovery_id,
|
||||
expected_session_id=str(call.get("transcription_session_id") or ""),
|
||||
expected_status=str(call.get("transcription_status") or ""),
|
||||
audio_url=record.uploaded_url,
|
||||
transcript_text=text,
|
||||
)
|
||||
if (not isinstance(result, dict)
|
||||
or str(result.get("call_record_id")) != str(record.call_record_id)
|
||||
or result.get("transcription_status") != "completed"
|
||||
or not (result.get("saved") is True or result.get("already_complete") is True)):
|
||||
raise RecordingTranscriptionError("服务端未确认文字保存,已识别文字已保留,请重试文字。")
|
||||
self.store.finish_transcription(record.id)
|
||||
self._notify_uploaded(record.id)
|
||||
return True
|
||||
|
||||
def _worker(self) -> None:
|
||||
while True:
|
||||
record_id, future = self._jobs.get()
|
||||
@@ -573,6 +808,8 @@ class LocalAudioUploadManager:
|
||||
return False
|
||||
self.store.mark_uploaded(record.id, uploaded_url)
|
||||
self._notify_uploaded(record.id)
|
||||
if callable(getattr(self.repository, "save_local_audio_transcript", None)):
|
||||
self.submit_transcription(record.id)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -3092,6 +3092,41 @@ class RemoteDoctorRepository:
|
||||
)
|
||||
return self.client.post("tcm.diagnosis/finishCallTranscription", body)
|
||||
|
||||
def save_local_audio_transcript(
|
||||
self,
|
||||
*,
|
||||
diagnosis_id: int,
|
||||
call_record_id: int,
|
||||
recovery_id: str,
|
||||
expected_session_id: str,
|
||||
expected_status: str,
|
||||
audio_url: str,
|
||||
transcript_text: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically persist offline text for an uploaded, ended call only."""
|
||||
if (isinstance(diagnosis_id, bool) or diagnosis_id <= 0
|
||||
or isinstance(call_record_id, bool) or call_record_id <= 0):
|
||||
raise ValueError("diagnosis_id and call_record_id must be positive")
|
||||
if not re.fullmatch(r"audio-[0-9a-f]{32}", recovery_id):
|
||||
raise ValueError("invalid audio recovery id")
|
||||
if len(expected_session_id) > 128 or expected_status not in {
|
||||
"", "pending", "running", "partial", "failed", "completed",
|
||||
}:
|
||||
raise ValueError("invalid expected transcription state")
|
||||
if not audio_url.strip() or len(audio_url) > 2000:
|
||||
raise ValueError("uploaded audio URL is required")
|
||||
if not transcript_text.strip() or len(transcript_text) > 100_000:
|
||||
raise ValueError("recording transcript is empty or too long")
|
||||
return self.client.post("tcm.diagnosis/saveLocalAudioTranscript", {
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"call_record_id": call_record_id,
|
||||
"recovery_id": recovery_id,
|
||||
"expected_session_id": expected_session_id,
|
||||
"expected_status": expected_status,
|
||||
"audio_url": audio_url,
|
||||
"transcript_text": transcript_text.strip(),
|
||||
})
|
||||
|
||||
# Compatibility aliases keep UI naming independent from endpoint history.
|
||||
def my_self(self) -> Session:
|
||||
"""Compatibility alias for :meth:`get_session`."""
|
||||
|
||||
@@ -68,6 +68,7 @@ from .design_system import PALETTE, clinical_stylesheet
|
||||
from .diagnosis_editors import FlowLayout
|
||||
from .diagnosis_media import shared_image_manager
|
||||
from .diagnosis_terms import FIELD_DICTIONARIES, format_timestamp, shared_terms
|
||||
from .image_drop import can_drop_images, capture_image_drop
|
||||
|
||||
DIAGNOSIS_QSS = r"""
|
||||
QDialog#DiagnosisDialogRoot {
|
||||
@@ -994,6 +995,11 @@ QDialog#DiagnosisPrescriptionEditor QSpinBox:focus,
|
||||
QDialog#DiagnosisPrescriptionEditor QDoubleSpinBox:focus {
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QWidget#DiagnosisNotesTimeline QPushButton[imageDropActive="true"] {
|
||||
background-color: #EEF1FA;
|
||||
border: 2px dashed #4F63D9;
|
||||
color: #4F63D9;
|
||||
}
|
||||
"""
|
||||
|
||||
# Normalize the legacy component palette in one place. Keeping the selectors
|
||||
@@ -2833,6 +2839,8 @@ class NotesTimeline(QWidget):
|
||||
|
||||
add_requested = Signal()
|
||||
upload_requested = Signal(str)
|
||||
images_dropped = Signal(object, str)
|
||||
image_drop_failed = Signal(str)
|
||||
delete_attachment_requested = Signal(int, str, str)
|
||||
open_attachment_requested = Signal(str)
|
||||
preview_images_requested = Signal(object, int)
|
||||
@@ -2842,6 +2850,9 @@ class NotesTimeline(QWidget):
|
||||
self.setObjectName("DiagnosisNotesTimeline")
|
||||
self._editable = editable
|
||||
self._can_delete = editable
|
||||
self._upload_available = True
|
||||
self._upload_busy = False
|
||||
self._drop_targets: dict[QWidget, str] = {}
|
||||
self._image_generation = 0
|
||||
self.root = QVBoxLayout(self)
|
||||
self.root.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -2862,6 +2873,10 @@ class NotesTimeline(QWidget):
|
||||
self.tongue_button.clicked.connect(lambda: self.upload_requested.emit("tongue_images"))
|
||||
self.report_button.clicked.connect(lambda: self.upload_requested.emit("report_files"))
|
||||
self.root.addWidget(self.toolbar)
|
||||
self.drop_hint = QLabel("可拖入图片;拖到“上传舌象”或“上传报告”按钮可直接分类。")
|
||||
self.drop_hint.setObjectName("DiagnosisDialogGuidance")
|
||||
self.drop_hint.setWordWrap(True)
|
||||
self.root.addWidget(self.drop_hint)
|
||||
self.nodes = QWidget()
|
||||
self.nodes_layout = QVBoxLayout(self.nodes)
|
||||
self.nodes_layout.setContentsMargins(22, 0, 0, 0)
|
||||
@@ -2872,12 +2887,89 @@ class NotesTimeline(QWidget):
|
||||
self.empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty.setMinimumHeight(80)
|
||||
self.root.addWidget(self.empty)
|
||||
self.register_image_drop_target(self)
|
||||
self.register_image_drop_target(self.tongue_button, "tongue_images")
|
||||
self.register_image_drop_target(self.report_button, "report_files")
|
||||
self.tongue_button.setToolTip("点击选择文件,或把图片拖到此处上传为舌象")
|
||||
self.report_button.setToolTip("点击选择报告,或把图片拖到此处上传为报告")
|
||||
self.set_editable(editable)
|
||||
|
||||
def set_editable(self, editable: bool, *, can_delete: bool | None = None) -> None:
|
||||
self._editable = editable
|
||||
self._can_delete = editable if can_delete is None else bool(can_delete)
|
||||
self.toolbar.setVisible(self._editable)
|
||||
self.drop_hint.setVisible(self._editable and self._upload_available)
|
||||
self._sync_image_drop_targets()
|
||||
|
||||
def set_upload_available(self, available: bool) -> None:
|
||||
self._upload_available = bool(available)
|
||||
self.drop_hint.setVisible(self._editable and self._upload_available)
|
||||
self._sync_image_drop_targets()
|
||||
|
||||
def set_upload_busy(self, busy: bool) -> None:
|
||||
self._upload_busy = bool(busy)
|
||||
self._sync_image_drop_targets()
|
||||
|
||||
def register_image_drop_target(self, widget: QWidget, image_type: str = "") -> None:
|
||||
"""Include scroll viewport blank space as well as the note timeline."""
|
||||
self._drop_targets[widget] = image_type
|
||||
widget.installEventFilter(self)
|
||||
self._sync_image_drop_targets()
|
||||
|
||||
def _sync_image_drop_targets(self) -> None:
|
||||
enabled = self._editable and self._upload_available and not self._upload_busy
|
||||
for widget in self._drop_targets:
|
||||
widget.setAcceptDrops(enabled)
|
||||
self.tongue_button.setEnabled(enabled)
|
||||
self.report_button.setEnabled(enabled)
|
||||
self.add_button.setEnabled(self._editable and not self._upload_busy)
|
||||
if not enabled:
|
||||
self._highlight_drop_target(None)
|
||||
|
||||
def _highlight_drop_target(self, active: QWidget | None) -> None:
|
||||
for button in (self.tongue_button, self.report_button):
|
||||
highlighted = button is active
|
||||
if bool(button.property("imageDropActive")) != highlighted:
|
||||
button.setProperty("imageDropActive", highlighted)
|
||||
repolish(button)
|
||||
text = (
|
||||
"松开鼠标上传图片;未指定类别时可选择舌象或报告。"
|
||||
if active is not None else "可拖入图片;拖到“上传舌象”或“上传报告”按钮可直接分类。"
|
||||
)
|
||||
self.drop_hint.setText(text)
|
||||
|
||||
def eventFilter(self, watched: Any, event: QEvent) -> bool: # noqa: N802 - Qt API
|
||||
if watched in self._drop_targets and event.type() in {
|
||||
QEvent.Type.DragEnter, QEvent.Type.DragMove, QEvent.Type.DragLeave, QEvent.Type.Drop,
|
||||
}:
|
||||
if event.type() == QEvent.Type.DragLeave:
|
||||
self._highlight_drop_target(None)
|
||||
event.accept()
|
||||
return True
|
||||
if (not self._editable or not self._upload_available or self._upload_busy
|
||||
or not (event.possibleActions() & Qt.DropAction.CopyAction)
|
||||
or not can_drop_images(event.mimeData())):
|
||||
self._highlight_drop_target(None)
|
||||
event.ignore()
|
||||
return True
|
||||
if event.type() == QEvent.Type.Drop:
|
||||
self._highlight_drop_target(None)
|
||||
try:
|
||||
payload = capture_image_drop(event.mimeData())
|
||||
except ValueError as error:
|
||||
self.image_drop_failed.emit(str(error))
|
||||
event.ignore()
|
||||
return True
|
||||
# Copy semantics are essential: uploading never moves the source.
|
||||
event.setDropAction(Qt.DropAction.CopyAction)
|
||||
event.accept()
|
||||
self.images_dropped.emit(payload, self._drop_targets[watched])
|
||||
else:
|
||||
self._highlight_drop_target(watched)
|
||||
event.setDropAction(Qt.DropAction.CopyAction)
|
||||
event.accept()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def set_state(self, text: str) -> None:
|
||||
self.empty.setText(text)
|
||||
|
||||
@@ -6,7 +6,8 @@ import mimetypes
|
||||
import re
|
||||
import sqlite3
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from contextlib import nullcontext, suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -84,6 +85,7 @@ from ..diagnosis_media import (
|
||||
RecordingPlayerDialog,
|
||||
safe_image_sources,
|
||||
)
|
||||
from ..image_drop import ImageDrop, materialize_image_drop
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..theme import main_window_target_rect
|
||||
from ..widgets import (
|
||||
@@ -110,6 +112,13 @@ class _UploadProgressRelay(QObject):
|
||||
processing = Signal()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _NoteAttachmentUploadResult:
|
||||
saved_count: int
|
||||
total_count: int
|
||||
error: str = ""
|
||||
|
||||
|
||||
_PHONE_PERMISSION = "tcm.diagnosis/phonePlain"
|
||||
_PATIENT_ORDERS_PERMISSION = "tcm.diagnosis/patientOrders"
|
||||
_ORDER_DETAIL_PERMISSION = "tcm.prescriptionOrder/detail"
|
||||
@@ -880,6 +889,8 @@ class DiagnosisDialog(QDialog):
|
||||
self._order_detail_generation = 0
|
||||
self._daily_mutation_generation = 0
|
||||
self._notes_mutation_generation = 0
|
||||
self._notes_mutation_busy = False
|
||||
self._notes_refresh_message: tuple[str, str] | None = None
|
||||
self._media_generation = 0
|
||||
self._prescription_detail_generation = 0
|
||||
self._prescription_detail_target = 0
|
||||
@@ -1506,12 +1517,17 @@ class DiagnosisDialog(QDialog):
|
||||
timeline = NotesTimeline(editable=True)
|
||||
timeline.add_requested.connect(self._add_doctor_note)
|
||||
timeline.upload_requested.connect(self._upload_doctor_note_material)
|
||||
timeline.images_dropped.connect(self._drop_doctor_note_images)
|
||||
timeline.image_drop_failed.connect(lambda text: self._show_message(text, "warning"))
|
||||
timeline.delete_attachment_requested.connect(self._delete_doctor_note_attachment)
|
||||
timeline.open_attachment_requested.connect(self._open_safe_resource)
|
||||
timeline.preview_images_requested.connect(self._preview_note_images)
|
||||
self.drawer_notes_timeline = timeline
|
||||
self._notes_timelines.append(timeline)
|
||||
return self._wrap_tab("DiagnosisTabNotes", timeline)
|
||||
scroll = self._wrap_tab("DiagnosisTabNotes", timeline)
|
||||
timeline.register_image_drop_target(scroll.viewport())
|
||||
timeline.register_image_drop_target(scroll.widget())
|
||||
return scroll
|
||||
|
||||
def _build_prescription_tab(self) -> QWidget:
|
||||
page = QWidget()
|
||||
@@ -1836,6 +1852,8 @@ class DiagnosisDialog(QDialog):
|
||||
and self._can_note_delete
|
||||
),
|
||||
)
|
||||
timeline.set_upload_available(callable(getattr(self.repository, "upload_material", None)))
|
||||
timeline.set_upload_busy(self._notes_mutation_busy)
|
||||
self.prescribe_button.setVisible(self._editable and self._can_prescribe)
|
||||
self.video_upload_button.setVisible(self._editable and self._can_video_upload)
|
||||
self.local_audio_queue_button.setVisible(self._can_video_upload)
|
||||
@@ -2140,6 +2158,8 @@ class DiagnosisDialog(QDialog):
|
||||
self._order_detail_generation += 1
|
||||
self._daily_mutation_generation += 1
|
||||
self._notes_mutation_generation += 1
|
||||
self._notes_mutation_busy = False
|
||||
self._notes_refresh_message = None
|
||||
self._media_generation += 1
|
||||
self._prescription_detail_generation += 1
|
||||
self._offset_generation += 1
|
||||
@@ -2506,6 +2526,8 @@ class DiagnosisDialog(QDialog):
|
||||
self._order_detail_generation += 1
|
||||
self._daily_mutation_generation += 1
|
||||
self._notes_mutation_generation += 1
|
||||
self._notes_mutation_busy = False
|
||||
self._notes_refresh_message = None
|
||||
self._media_generation += 1
|
||||
self._prescription_detail_generation += 1
|
||||
self._offset_generation += 1
|
||||
@@ -2940,7 +2962,12 @@ class DiagnosisDialog(QDialog):
|
||||
for panel in self._chat_panels:
|
||||
panel.set_messages(rows)
|
||||
if not self._standalone_readonly and self._current_tab_key() == key:
|
||||
self._clear_message()
|
||||
if key == "notes" and self._notes_refresh_message is not None:
|
||||
self._show_message(*self._notes_refresh_message)
|
||||
else:
|
||||
self._clear_message()
|
||||
if key == "notes":
|
||||
self._notes_refresh_message = None
|
||||
if key == "video":
|
||||
self._flush_video_reload_if_pending(diagnosis_id)
|
||||
|
||||
@@ -3245,18 +3272,19 @@ class DiagnosisDialog(QDialog):
|
||||
self._show_message(friendly_error(error), "danger")
|
||||
|
||||
def _add_doctor_note(self) -> None:
|
||||
if not self._editable or not self._can_note_add:
|
||||
if not self._editable or not self._can_note_add or self._notes_mutation_busy:
|
||||
return
|
||||
content, accepted = QInputDialog.getMultiLineText(
|
||||
self, "新增 / 追加诊疗笔记", "笔记内容(最多 500 字)"
|
||||
)
|
||||
if not accepted:
|
||||
return
|
||||
diagnosis_id = self._diagnosis_id
|
||||
self._run_notes_mutation(
|
||||
lambda: invoke(
|
||||
self.repository,
|
||||
"add_doctor_note",
|
||||
diagnosis_id=self._diagnosis_id,
|
||||
diagnosis_id=diagnosis_id,
|
||||
content=content,
|
||||
),
|
||||
"诊疗笔记已保存。",
|
||||
@@ -3267,6 +3295,7 @@ class DiagnosisDialog(QDialog):
|
||||
image_type not in {"tongue_images", "report_files"}
|
||||
or not self._editable
|
||||
or not self._can_note_add
|
||||
or self._notes_mutation_busy
|
||||
):
|
||||
return
|
||||
if not all(
|
||||
@@ -3284,21 +3313,84 @@ class DiagnosisDialog(QDialog):
|
||||
path, _selected = QFileDialog.getOpenFileName(self, caption, "", file_filter)
|
||||
if not path:
|
||||
return
|
||||
image_file = Path(path).suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
|
||||
self._upload_note_attachments(
|
||||
image_type, lambda: nullcontext([Path(path)]),
|
||||
upload_type="image" if image_file else image_type,
|
||||
)
|
||||
|
||||
def _drop_doctor_note_images(self, payload: ImageDrop, image_type: str) -> None:
|
||||
if (not self._editable or not self._can_note_add or self._diagnosis_id <= 0
|
||||
or self._notes_mutation_busy):
|
||||
return
|
||||
diagnosis_id, generation = self._diagnosis_id, self._generation
|
||||
# End the OS drag before displaying a category chooser. Capture identity
|
||||
# now so a queued drop cannot attach files to a newly opened patient.
|
||||
QTimer.singleShot(0, self, lambda: self._accept_dropped_note_images(
|
||||
payload, image_type, diagnosis_id, generation
|
||||
))
|
||||
|
||||
def _choose_dropped_image_type(self, count: int) -> str:
|
||||
chooser = QMessageBox(self)
|
||||
chooser.setWindowTitle("选择图片类别")
|
||||
chooser.setText(f"已拖入 {count} 张图片,请选择保存类别。")
|
||||
tongue = chooser.addButton("上传为舌象", QMessageBox.ButtonRole.AcceptRole)
|
||||
report = chooser.addButton("上传为报告", QMessageBox.ButtonRole.ActionRole)
|
||||
chooser.addButton("取消", QMessageBox.ButtonRole.RejectRole)
|
||||
chooser.exec()
|
||||
return "tongue_images" if chooser.clickedButton() is tongue else (
|
||||
"report_files" if chooser.clickedButton() is report else ""
|
||||
)
|
||||
|
||||
def _accept_dropped_note_images(
|
||||
self, payload: ImageDrop, image_type: str, diagnosis_id: int, generation: int
|
||||
) -> None:
|
||||
if (diagnosis_id != self._diagnosis_id or generation != self._generation
|
||||
or not self._editable or not self._can_note_add or self._notes_mutation_busy):
|
||||
return
|
||||
if not image_type:
|
||||
image_type = self._choose_dropped_image_type(payload.count)
|
||||
# A modal chooser can process a patient change or permissions refresh.
|
||||
if (image_type not in {"tongue_images", "report_files"}
|
||||
or diagnosis_id != self._diagnosis_id or generation != self._generation):
|
||||
return
|
||||
self._upload_note_attachments(
|
||||
image_type, lambda: materialize_image_drop(payload), upload_type="image"
|
||||
)
|
||||
|
||||
def _upload_note_attachments(self, image_type: str, sources: Any, *, upload_type: str) -> None:
|
||||
if (not self._editable or not self._can_note_add or self._diagnosis_id <= 0
|
||||
or self._notes_mutation_busy or image_type not in {"tongue_images", "report_files"}):
|
||||
return
|
||||
if not all(callable(getattr(self.repository, name, None))
|
||||
for name in ("upload_material", "add_doctor_note")):
|
||||
self._show_message("附件上传接口不可用。", "warning")
|
||||
return
|
||||
diagnosis_id = self._diagnosis_id
|
||||
repository = self.repository
|
||||
|
||||
def operation() -> Any:
|
||||
reference = invoke(
|
||||
self.repository,
|
||||
"upload_material",
|
||||
path=path,
|
||||
material_type=image_type,
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"content": "",
|
||||
image_type: [str(reference)],
|
||||
}
|
||||
return invoke(self.repository, "add_doctor_note", **kwargs)
|
||||
with sources() as paths:
|
||||
references: list[str] = []
|
||||
error_text = ""
|
||||
for path in paths:
|
||||
try:
|
||||
reference = invoke(repository, "upload_material", path=str(path), material_type=upload_type)
|
||||
if not reference:
|
||||
raise ValueError("服务器未返回图片地址。")
|
||||
references.append(str(reference))
|
||||
except Exception as error:
|
||||
if not references:
|
||||
raise
|
||||
error_text = friendly_error(error)
|
||||
break
|
||||
if not references:
|
||||
raise ValueError("没有可上传的图片。")
|
||||
# Persist successful images even if a later upload failed, then
|
||||
# report the exact partial result instead of silently losing it.
|
||||
invoke(repository, "add_doctor_note", diagnosis_id=diagnosis_id,
|
||||
content="", **{image_type: references})
|
||||
return _NoteAttachmentUploadResult(len(references), len(paths), error_text)
|
||||
|
||||
self._run_notes_mutation(operation, "附件已上传并写入诊疗笔记。")
|
||||
|
||||
@@ -3308,6 +3400,7 @@ class DiagnosisDialog(QDialog):
|
||||
if (
|
||||
not self._editable
|
||||
or not self._can_note_delete
|
||||
or self._notes_mutation_busy
|
||||
or note_id <= 0
|
||||
or image_type not in {"tongue_images", "report_files"}
|
||||
or not callable(getattr(self.repository, "delete_doctor_note_image", None))
|
||||
@@ -3334,22 +3427,47 @@ class DiagnosisDialog(QDialog):
|
||||
)
|
||||
|
||||
def _run_notes_mutation(self, operation: Any, success_text: str) -> None:
|
||||
if self._notes_mutation_busy:
|
||||
self._show_message("正在上传或保存诊疗笔记,请稍候。", "info")
|
||||
return
|
||||
self._notes_mutation_busy = True
|
||||
self._notes_refresh_message = None
|
||||
for timeline in self._notes_timelines:
|
||||
timeline.set_upload_busy(True)
|
||||
self._notes_mutation_generation += 1
|
||||
generation = self._notes_mutation_generation
|
||||
diagnosis_id = self._diagnosis_id
|
||||
self._show_message("正在更新诊疗笔记…", "info")
|
||||
run_async(
|
||||
operation,
|
||||
on_success=lambda _result: self._notes_mutation_success(
|
||||
success_text, diagnosis_id, generation
|
||||
on_success=lambda result: self._notes_mutation_success(
|
||||
success_text, diagnosis_id, generation, result
|
||||
),
|
||||
on_error=lambda error: self._mutation_error(error, diagnosis_id, generation, "notes"),
|
||||
on_finished=lambda: self._notes_mutation_finished(diagnosis_id, generation),
|
||||
)
|
||||
|
||||
def _notes_mutation_success(self, text: str, diagnosis_id: int, generation: int) -> None:
|
||||
def _notes_mutation_finished(self, diagnosis_id: int, generation: int) -> None:
|
||||
if generation != self._notes_mutation_generation or diagnosis_id != self._diagnosis_id:
|
||||
return
|
||||
self._show_message(text, "success")
|
||||
self._notes_mutation_busy = False
|
||||
for timeline in self._notes_timelines:
|
||||
timeline.set_upload_busy(False)
|
||||
|
||||
def _notes_mutation_success(
|
||||
self, text: str, diagnosis_id: int, generation: int, result: Any = None
|
||||
) -> None:
|
||||
if generation != self._notes_mutation_generation or diagnosis_id != self._diagnosis_id:
|
||||
return
|
||||
if isinstance(result, _NoteAttachmentUploadResult) and result.error:
|
||||
message = (
|
||||
f"已保存 {result.saved_count}/{result.total_count} 张。第 {result.saved_count + 1} 张上传失败:"
|
||||
f"{result.error} 请只重试未成功的图片。", "warning"
|
||||
)
|
||||
else:
|
||||
message = (text, "success")
|
||||
self._notes_refresh_message = message
|
||||
self._show_message(*message)
|
||||
self._loaded_tabs.discard("notes")
|
||||
self._ensure_tab_loaded("notes", force=True)
|
||||
|
||||
|
||||
@@ -48,6 +48,18 @@ _STATUS_COLORS = {
|
||||
"failed": "#BE4B58",
|
||||
"invalid": "#606163",
|
||||
}
|
||||
_TRANSCRIPTION_LABELS = {
|
||||
"pending": "待生成",
|
||||
"transcribing": "生成中",
|
||||
"completed": "已保存",
|
||||
"failed": "生成失败",
|
||||
}
|
||||
_TRANSCRIPTION_COLORS = {
|
||||
"pending": "#A9691D",
|
||||
"transcribing": "#1A1C1F",
|
||||
"completed": "#287B65",
|
||||
"failed": "#BE4B58",
|
||||
}
|
||||
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
|
||||
|
||||
_LOCAL_AUDIO_QSS = """
|
||||
@@ -140,7 +152,7 @@ def _display_time(value: str) -> str:
|
||||
|
||||
|
||||
class LocalAudioQueueDialog(QDialog):
|
||||
"""Persistent local-audio list with concurrent upload and retry."""
|
||||
"""Persistent local-audio list with separate upload and transcription status."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -174,7 +186,7 @@ class LocalAudioQueueDialog(QDialog):
|
||||
)
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(760, 480)
|
||||
self.resize(1160, 620)
|
||||
self.resize(1280, 620)
|
||||
self.setStyleSheet(clinical_stylesheet(_LOCAL_AUDIO_QSS))
|
||||
self._build_ui()
|
||||
|
||||
@@ -183,6 +195,9 @@ class LocalAudioQueueDialog(QDialog):
|
||||
self.poll_timer.timeout.connect(self.refresh_records)
|
||||
self.poll_timer.start()
|
||||
self.manager.submit_pending(diagnosis_id=self.diagnosis_id)
|
||||
resume_transcriptions = getattr(self.manager, "submit_pending_transcriptions", None)
|
||||
if callable(resume_transcriptions):
|
||||
resume_transcriptions(diagnosis_id=self.diagnosis_id)
|
||||
self.refresh_records(force=True)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
@@ -226,12 +241,15 @@ class LocalAudioQueueDialog(QDialog):
|
||||
self.summary_uploading = self._summary_label()
|
||||
self.summary_uploaded = self._summary_label()
|
||||
self.summary_failed = self._summary_label()
|
||||
self.summary_transcription = self._summary_label()
|
||||
self.summary_transcription.setToolTip("已上传但文字尚未生成或保存的录音数量。")
|
||||
for label in (
|
||||
self.summary_total,
|
||||
self.summary_pending,
|
||||
self.summary_uploading,
|
||||
self.summary_uploaded,
|
||||
self.summary_failed,
|
||||
self.summary_transcription,
|
||||
):
|
||||
summary_layout.addWidget(label)
|
||||
summary_layout.addStretch(1)
|
||||
@@ -265,6 +283,7 @@ class LocalAudioQueueDialog(QDialog):
|
||||
"音频文件",
|
||||
"大小",
|
||||
"上传状态",
|
||||
"文字状态",
|
||||
"失败原因",
|
||||
"操作",
|
||||
]
|
||||
@@ -275,8 +294,9 @@ class LocalAudioQueueDialog(QDialog):
|
||||
self._file_column = 4 if self._global_scope else 3
|
||||
self._size_column = 5 if self._global_scope else 4
|
||||
self._status_column = 6 if self._global_scope else 5
|
||||
self._error_column = 7 if self._global_scope else 6
|
||||
self._action_column = 8 if self._global_scope else 7
|
||||
self._transcription_column = 7 if self._global_scope else 6
|
||||
self._error_column = 8 if self._global_scope else 7
|
||||
self._action_column = 9 if self._global_scope else 8
|
||||
|
||||
self.table = QTableWidget(0, len(headers))
|
||||
self.table.setObjectName("LocalAudioQueueTable")
|
||||
@@ -302,7 +322,8 @@ class LocalAudioQueueDialog(QDialog):
|
||||
self.table.setColumnWidth(self._room_column, 180)
|
||||
self.table.setColumnWidth(self._size_column, 88)
|
||||
self.table.setColumnWidth(self._status_column, 100)
|
||||
self.table.setColumnWidth(self._action_column, 190)
|
||||
self.table.setColumnWidth(self._transcription_column, 100)
|
||||
self.table.setColumnWidth(self._action_column, 230)
|
||||
card_layout.addWidget(self.table)
|
||||
root.addWidget(card, 1)
|
||||
|
||||
@@ -310,8 +331,12 @@ class LocalAudioQueueDialog(QDialog):
|
||||
footer.setObjectName("LocalAudioQueueFooter")
|
||||
footer_layout = QHBoxLayout(footer)
|
||||
footer_layout.setContentsMargins(14, 9, 14, 9)
|
||||
hint = QLabel("已上传文件仍保留在本机;请按机构的数据保留制度定期归档。")
|
||||
hint = QLabel(
|
||||
"文字保存至对应诊单「视频录制回放 → 查看文字」;原始录音转写未分离说话人。\n"
|
||||
"已上传文件仍保留在本机,请按机构的数据保留制度定期归档。"
|
||||
)
|
||||
hint.setObjectName("LocalAudioQueueHint")
|
||||
hint.setWordWrap(True)
|
||||
footer_layout.addWidget(hint, 1)
|
||||
close_button = QPushButton("关闭")
|
||||
close_button.setObjectName("LocalAudioQueueCloseButton")
|
||||
@@ -344,6 +369,8 @@ class LocalAudioQueueDialog(QDialog):
|
||||
record.uploaded_url,
|
||||
record.exists,
|
||||
record.attempts,
|
||||
getattr(record, "transcription_status", "pending"),
|
||||
getattr(record, "transcription_error", ""),
|
||||
self._room_fetch_state(record),
|
||||
)
|
||||
for record in records
|
||||
@@ -377,10 +404,33 @@ class LocalAudioQueueDialog(QDialog):
|
||||
f"已尝试 {record.attempts} 次"
|
||||
+ (f"\nCOS:{record.uploaded_url}" if record.uploaded_url else "")
|
||||
)
|
||||
error_item = self._set_item(
|
||||
row, self._error_column, record.error_text or "—"
|
||||
transcription_status = getattr(record, "transcription_status", "pending")
|
||||
transcription_item = self._set_item(
|
||||
row,
|
||||
self._transcription_column,
|
||||
_TRANSCRIPTION_LABELS.get(transcription_status, transcription_status)
|
||||
if record.status == "uploaded"
|
||||
else "等待上传",
|
||||
)
|
||||
error_item.setToolTip(record.error_text)
|
||||
transcription_item.setForeground(
|
||||
QColor(
|
||||
_TRANSCRIPTION_COLORS.get(transcription_status, "#606163")
|
||||
if record.status == "uploaded"
|
||||
else "#606163"
|
||||
)
|
||||
)
|
||||
transcription_item.setToolTip(
|
||||
getattr(record, "transcription_error", "")
|
||||
or "文字保存在对应诊单「视频录制回放 → 查看文字」;原始录音转写未分离说话人。"
|
||||
)
|
||||
reasons = []
|
||||
if record.error_text:
|
||||
reasons.append(f"上传:{record.error_text}")
|
||||
transcription_error = getattr(record, "transcription_error", "")
|
||||
if transcription_error:
|
||||
reasons.append(f"文字:{transcription_error}")
|
||||
error_item = self._set_item(row, self._error_column, ";".join(reasons) or "—")
|
||||
error_item.setToolTip("\n".join(reasons))
|
||||
self.table.setCellWidget(row, self._action_column, self._actions(record))
|
||||
|
||||
def _room_fetch_state(self, record: LocalAudioRecord) -> str:
|
||||
@@ -548,6 +598,12 @@ class LocalAudioQueueDialog(QDialog):
|
||||
self.summary_uploading.setText(f"上传中 {counts['uploading']}")
|
||||
self.summary_uploaded.setText(f"已上传 {counts['uploaded']}")
|
||||
self.summary_failed.setText(f"失败 {counts['failed']}")
|
||||
transcription_pending = sum(
|
||||
record.status == "uploaded"
|
||||
and getattr(record, "transcription_status", "pending") != "completed"
|
||||
for record in records
|
||||
)
|
||||
self.summary_transcription.setText(f"文字未完成 {transcription_pending}")
|
||||
self.upload_pending_button.setEnabled(counts["pending"] > 0)
|
||||
self.retry_failed_button.setEnabled(counts["failed"] > 0)
|
||||
|
||||
@@ -571,6 +627,23 @@ class LocalAudioQueueDialog(QDialog):
|
||||
busy = QPushButton("上传中")
|
||||
busy.setEnabled(False)
|
||||
layout.addWidget(busy)
|
||||
elif record.status == "uploaded":
|
||||
transcription_status = getattr(record, "transcription_status", "pending")
|
||||
if transcription_status in {"pending", "failed"}:
|
||||
text_action = "重试文字" if transcription_status == "failed" else "补全文字"
|
||||
transcribe = QPushButton(text_action)
|
||||
transcribe.setAccessibleName(f"{text_action} {record.file_path.name}")
|
||||
if transcription_status == "failed":
|
||||
transcribe.setProperty("variant", "danger")
|
||||
transcribe.setEnabled(callable(getattr(self.manager, "submit_transcription", None)))
|
||||
transcribe.clicked.connect(
|
||||
lambda _checked=False, record_id=record.id: self._transcribe_one(record_id)
|
||||
)
|
||||
layout.addWidget(transcribe)
|
||||
elif transcription_status == "transcribing":
|
||||
busy = QPushButton("文字生成中")
|
||||
busy.setEnabled(False)
|
||||
layout.addWidget(busy)
|
||||
if record.exists:
|
||||
reveal = QPushButton("打开位置")
|
||||
reveal.setAccessibleName(f"打开录音文件位置 {record.file_path.name}")
|
||||
@@ -598,6 +671,18 @@ class LocalAudioQueueDialog(QDialog):
|
||||
self.manager.submit(record_id)
|
||||
self.refresh_records(force=True)
|
||||
|
||||
def _transcribe_one(self, record_id: int) -> None:
|
||||
submit = getattr(self.manager, "submit_transcription", None)
|
||||
if not callable(submit):
|
||||
return
|
||||
try:
|
||||
submit(record_id)
|
||||
except (OSError, RuntimeError, LookupError, ValueError) as error:
|
||||
self.summary_transcription.setText("文字重试失败")
|
||||
self.summary_transcription.setToolTip(str(error))
|
||||
return
|
||||
self.refresh_records(force=True)
|
||||
|
||||
def _retry_all_failed(self) -> None:
|
||||
for record in self._records():
|
||||
if record.status != "failed":
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Copy drag data on the GUI thread, then resolve images in an upload worker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Literal
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QMimeData
|
||||
from PySide6.QtGui import QImage, QImageReader, QPixmap
|
||||
|
||||
MAX_IMAGE_COUNT = 20
|
||||
MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
MAX_IMAGE_PIXELS = 40_000_000
|
||||
_RASTER_FORMATS = {
|
||||
"png", "jpeg", "jpg", "webp", "gif", "bmp", "tiff", "tif", "ico", "avif", "heic",
|
||||
}
|
||||
# Match server/config/project.php's file_image whitelist without a backend change.
|
||||
_UPLOAD_IMAGE_FORMATS = {"png", "jpeg", "jpg", "webp", "gif", "ico"}
|
||||
_IMAGE_MIMES = tuple(f"image/{name}" for name in _RASTER_FORMATS)
|
||||
_SAVE_LOCALLY = "无法读取此图片链接,链接可能需要登录或已过期,请先保存本地再拖入。"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ImageSource:
|
||||
kind: Literal["local", "url", "data", "bytes"]
|
||||
value: str | bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageDrop:
|
||||
"""Immutable, Qt-object-free snapshot safe to pass to an upload worker."""
|
||||
|
||||
sources: tuple[_ImageSource, ...]
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self.sources)
|
||||
|
||||
|
||||
def can_drop_images(mime: QMimeData) -> bool:
|
||||
"""Cheap hover check; never open files, fetch URLs or decode an image."""
|
||||
|
||||
if mime.hasImage() or any(mime.hasFormat(name) for name in _IMAGE_MIMES):
|
||||
return True
|
||||
if mime.hasUrls() and any(
|
||||
url.isLocalFile() or url.scheme().lower() in {"http", "https", "data"}
|
||||
for url in mime.urls()
|
||||
):
|
||||
return True
|
||||
if mime.hasHtml():
|
||||
return "<img" in mime.html().lower()
|
||||
text = mime.text().strip() if mime.hasText() else ""
|
||||
return text.lower().startswith(("https://", "http://", "data:image/"))
|
||||
|
||||
|
||||
class _ImageTags(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.sources: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag == "img":
|
||||
self.sources.append(dict(attrs).get("src") or "")
|
||||
_check_count(len(self.sources))
|
||||
|
||||
|
||||
def _check_count(count: int) -> None:
|
||||
if not 1 <= count <= MAX_IMAGE_COUNT:
|
||||
raise ValueError(f"请一次拖入 1 至 {MAX_IMAGE_COUNT} 张图片。")
|
||||
|
||||
|
||||
def _check_bytes(content: bytes) -> None:
|
||||
if not content:
|
||||
raise ValueError("拖入的图片内容为空。")
|
||||
if len(content) > MAX_IMAGE_BYTES:
|
||||
raise ValueError("每张图片不能超过 10 MiB,请压缩后再拖入。")
|
||||
|
||||
|
||||
def _http_url(value: str) -> str:
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
valid = (
|
||||
parsed.scheme.lower() in {"http", "https"}
|
||||
and bool(parsed.hostname)
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
and not any(character.isspace() or ord(character) < 32 for character in value)
|
||||
)
|
||||
# Accessing port also rejects malformed/non-numeric ports before networking.
|
||||
_ = parsed.port
|
||||
except ValueError:
|
||||
valid = False
|
||||
if not valid:
|
||||
raise ValueError("仅支持 HTTP/HTTPS 图片链接,请先保存本地再拖入。")
|
||||
return value
|
||||
|
||||
|
||||
def _reference(value: str) -> _ImageSource:
|
||||
value = value.strip()
|
||||
if value.lower().startswith("data:image/"):
|
||||
# Bound encoded content before retaining or decoding another copy.
|
||||
if len(value) > ((MAX_IMAGE_BYTES + 2) // 3) * 4 + 256:
|
||||
raise ValueError("每张图片不能超过 10 MiB,请压缩后再拖入。")
|
||||
return _ImageSource("data", value)
|
||||
return _ImageSource("url", _http_url(value))
|
||||
|
||||
|
||||
def capture_image_drop(mime: QMimeData) -> ImageDrop:
|
||||
"""Snapshot while the drop event owns its MIME data; retain no Qt objects."""
|
||||
|
||||
urls = mime.urls() if mime.hasUrls() else []
|
||||
if any(url.isLocalFile() for url in urls):
|
||||
_check_count(len(urls))
|
||||
return ImageDrop(tuple(
|
||||
_ImageSource("local", url.toLocalFile())
|
||||
if url.isLocalFile() else _reference(url.toString())
|
||||
for url in urls
|
||||
))
|
||||
|
||||
# Multiple formats describe the same picture. Keep only one representation.
|
||||
for name in ("image/png", "image/jpeg", *_IMAGE_MIMES):
|
||||
if mime.hasFormat(name):
|
||||
content = bytes(mime.data(name))
|
||||
_check_bytes(content)
|
||||
return ImageDrop((_ImageSource("bytes", content),))
|
||||
|
||||
if mime.hasImage():
|
||||
image = mime.imageData()
|
||||
if isinstance(image, QPixmap):
|
||||
image = image.toImage()
|
||||
if not isinstance(image, QImage) or image.isNull():
|
||||
raise ValueError("无法读取拖入的图片,请先保存本地再拖入。")
|
||||
_check_pixels(image.width(), image.height())
|
||||
buffer = QBuffer()
|
||||
if not buffer.open(QIODevice.OpenModeFlag.WriteOnly) or not image.save(buffer, "PNG"):
|
||||
raise ValueError("无法复制拖入的图片,请先保存本地再拖入。")
|
||||
content = bytes(buffer.data())
|
||||
buffer.close()
|
||||
_check_bytes(content)
|
||||
return ImageDrop((_ImageSource("bytes", content),))
|
||||
|
||||
if mime.hasHtml():
|
||||
parser = _ImageTags()
|
||||
parser.feed(mime.html())
|
||||
if parser.sources:
|
||||
return ImageDrop(tuple(_reference(value) for value in parser.sources))
|
||||
|
||||
if urls:
|
||||
_check_count(len(urls))
|
||||
return ImageDrop(tuple(_reference(url.toString()) for url in urls))
|
||||
if mime.hasText():
|
||||
text = mime.text().strip()
|
||||
if text.lower().startswith(("https://", "http://", "data:image/")):
|
||||
return ImageDrop((_reference(text),))
|
||||
raise ValueError("请拖入图片文件或图片内容,不支持此拖拽内容。")
|
||||
|
||||
|
||||
def _check_pixels(width: int, height: int) -> None:
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("无法识别图片尺寸,请更换有效图片。")
|
||||
if width * height > MAX_IMAGE_PIXELS:
|
||||
raise ValueError("图片像素过大(最多 4000 万像素),请缩小后再拖入。")
|
||||
|
||||
|
||||
def _prepare_image(content: bytes) -> tuple[bytes, str]:
|
||||
"""Validate the raster and convert formats unsupported by the upload endpoint."""
|
||||
|
||||
_check_bytes(content)
|
||||
buffer = QBuffer()
|
||||
buffer.setData(QByteArray(content))
|
||||
buffer.open(QIODevice.OpenModeFlag.ReadOnly)
|
||||
reader = QImageReader(buffer)
|
||||
reader.setDecideFormatFromContent(True)
|
||||
image_format = bytes(reader.format()).decode("ascii", errors="replace").lower()
|
||||
if image_format not in _RASTER_FORMATS:
|
||||
raise ValueError("拖入内容不是受支持的图片,不支持 SVG、网页或其他文件。")
|
||||
size = reader.size()
|
||||
_check_pixels(size.width(), size.height())
|
||||
image = reader.read()
|
||||
if image.isNull():
|
||||
raise ValueError("图片内容损坏或不完整,请更换有效图片。")
|
||||
_check_pixels(image.width(), image.height())
|
||||
if image_format not in _UPLOAD_IMAGE_FORMATS:
|
||||
output = QBuffer()
|
||||
if not output.open(QIODevice.OpenModeFlag.WriteOnly) or not image.save(output, "PNG"):
|
||||
raise ValueError("无法转换拖入的图片,请先保存为 PNG 或 JPG 再拖入。")
|
||||
content = bytes(output.data())
|
||||
output.close()
|
||||
# A small source can expand when converted, so enforce the upload limit again.
|
||||
_check_bytes(content)
|
||||
image_format = "png"
|
||||
return content, "jpg" if image_format == "jpeg" else image_format
|
||||
|
||||
|
||||
def _new_download_client() -> httpx.Client:
|
||||
# This client is deliberately unrelated to the authenticated business API.
|
||||
return httpx.Client(
|
||||
timeout=httpx.Timeout(15.0, connect=5.0),
|
||||
follow_redirects=False,
|
||||
trust_env=False,
|
||||
headers={"Accept": "image/*"},
|
||||
)
|
||||
|
||||
|
||||
def _download_image(url: str) -> bytes:
|
||||
try:
|
||||
with _new_download_client() as client:
|
||||
for _ in range(6):
|
||||
url = _http_url(url)
|
||||
client.cookies.clear()
|
||||
with client.stream("GET", url) as response:
|
||||
if response.is_redirect:
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
raise ValueError(_SAVE_LOCALLY)
|
||||
url = _http_url(urljoin(url, location))
|
||||
continue
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "").split(";")[0].lower()
|
||||
if content_type == "image/svg+xml":
|
||||
raise ValueError("不支持 SVG 图片,请先保存为 PNG 或 JPG 再拖入。")
|
||||
if content_type and not (
|
||||
content_type.startswith("image/")
|
||||
or content_type == "application/octet-stream"
|
||||
):
|
||||
raise ValueError(_SAVE_LOCALLY)
|
||||
length = response.headers.get("content-length", "")
|
||||
if length.isdigit() and int(length) > MAX_IMAGE_BYTES:
|
||||
raise ValueError("每张图片不能超过 10 MiB,请压缩后再拖入。")
|
||||
content = bytearray()
|
||||
for chunk in response.iter_bytes(chunk_size=64 * 1024):
|
||||
if len(content) + len(chunk) > MAX_IMAGE_BYTES:
|
||||
raise ValueError("每张图片不能超过 10 MiB,请压缩后再拖入。")
|
||||
content.extend(chunk)
|
||||
return bytes(content)
|
||||
except httpx.InvalidURL as exc:
|
||||
raise ValueError("图片链接无效,仅支持 HTTP/HTTPS 图片链接,请先保存本地再拖入。") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ValueError(_SAVE_LOCALLY) from exc
|
||||
raise ValueError("图片链接重定向次数过多,请先保存本地再拖入。")
|
||||
|
||||
|
||||
def _decode_data_url(value: str) -> bytes:
|
||||
header, separator, encoded = value.partition(",")
|
||||
mime_type = header[5:].split(";")[0].lower()
|
||||
if not separator or not header.lower().endswith(";base64") or mime_type not in _IMAGE_MIMES:
|
||||
raise ValueError("仅支持 PNG、JPG 等图片的 base64 内容,不支持 SVG。")
|
||||
try:
|
||||
content = base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, binascii.Error) as exc:
|
||||
raise ValueError("拖入的图片编码无效,请先保存本地再拖入。") from exc
|
||||
_check_bytes(content)
|
||||
return content
|
||||
|
||||
|
||||
def _source_bytes(source: _ImageSource) -> bytes:
|
||||
if source.kind == "bytes":
|
||||
assert isinstance(source.value, bytes)
|
||||
return source.value
|
||||
assert isinstance(source.value, str)
|
||||
if source.kind == "data":
|
||||
return _decode_data_url(source.value)
|
||||
if source.kind == "url":
|
||||
return _download_image(source.value)
|
||||
path = Path(source.value)
|
||||
try:
|
||||
if not path.is_file():
|
||||
raise ValueError("请拖入图片文件,不能拖入文件夹或不存在的文件。")
|
||||
with path.open("rb") as stream:
|
||||
content = stream.read(MAX_IMAGE_BYTES + 1)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"无法读取图片文件“{path.name}”,请确认文件仍存在且可访问。") from exc
|
||||
_check_bytes(content)
|
||||
return content
|
||||
|
||||
|
||||
@contextmanager
|
||||
def materialize_image_drop(drop: ImageDrop) -> Iterator[list[Path]]:
|
||||
"""Validate every item before yielding; clean up copies even when uploading fails."""
|
||||
|
||||
_check_count(drop.count)
|
||||
with TemporaryDirectory(prefix="doctor-image-drop-") as directory:
|
||||
paths: list[Path] = []
|
||||
for index, source in enumerate(drop.sources, start=1):
|
||||
content = _source_bytes(source)
|
||||
content, image_format = _prepare_image(content)
|
||||
path = Path(directory) / f"dropped-image-{index:02d}.{image_format}"
|
||||
path.write_bytes(content)
|
||||
paths.append(path)
|
||||
yield paths
|
||||
@@ -651,14 +651,26 @@ class OrderedCallLifecycle:
|
||||
return future
|
||||
|
||||
def finish_transcription(self, *, status: str = "completed") -> Future[bool]:
|
||||
"""Flush and finalize the current transcript exactly once."""
|
||||
"""Finalize once successfully, allowing retries of a failed same-session write."""
|
||||
|
||||
clean_status = str(status or "completed").strip().lower()
|
||||
if clean_status not in {"completed", "partial", "failed"}:
|
||||
raise ValueError("transcription status is invalid")
|
||||
with self._lock:
|
||||
if self._transcription_finish_future is not None:
|
||||
return self._transcription_finish_future
|
||||
previous = self._transcription_finish_future
|
||||
if previous is not None and (
|
||||
not previous.done()
|
||||
or (
|
||||
not previous.cancelled()
|
||||
and previous.exception() is None
|
||||
and previous.result() is True
|
||||
)
|
||||
):
|
||||
return previous
|
||||
if self._end_future is not None:
|
||||
raise RuntimeError("video call has already ended")
|
||||
# Retain the finish marker after failures so neither new capture nor
|
||||
# new segments can reopen this session. Only this final write retries.
|
||||
if self._transcription_start_future is None or not self._transcription_session_id:
|
||||
return _settled_future(False)
|
||||
method = getattr(self.repository, "finish_call_transcription", None)
|
||||
@@ -675,7 +687,7 @@ class OrderedCallLifecycle:
|
||||
return False
|
||||
if record_id is None:
|
||||
raise ValueError("server did not return the current call_record_id")
|
||||
_call_repository_method(
|
||||
result = _call_repository_method(
|
||||
method,
|
||||
{
|
||||
"diagnosis_id": self.request.diagnosis_id,
|
||||
@@ -685,6 +697,14 @@ class OrderedCallLifecycle:
|
||||
"status": clean_status,
|
||||
},
|
||||
)
|
||||
if result is False:
|
||||
raise RuntimeError("call transcription finalization failed")
|
||||
if isinstance(result, Mapping):
|
||||
saved_status = result.get("status")
|
||||
if saved_status == "failed":
|
||||
raise RuntimeError("本次通话未保存到完整文字,请在本机录音文件中重试文字。")
|
||||
if saved_status == "partial" and clean_status == "completed":
|
||||
raise RuntimeError("本次通话文字仅部分保存,请在本机录音文件中补全文字。")
|
||||
with self._lock:
|
||||
self._transcription_active = False
|
||||
self.logger.info(
|
||||
@@ -706,10 +726,9 @@ class OrderedCallLifecycle:
|
||||
with self._lock:
|
||||
if self._end_future is not None:
|
||||
return self._end_future
|
||||
if (
|
||||
self._transcription_start_future is not None
|
||||
and self._transcription_finish_future is None
|
||||
):
|
||||
if self._transcription_start_future is not None:
|
||||
# A failed earlier finish must not suppress the final fallback.
|
||||
# In-flight and successful finishes remain deduplicated.
|
||||
self.finish_transcription(status="partial")
|
||||
method = getattr(self.repository, "end_call", None)
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Offline recovery of retained call recordings, without uploading audio again."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from ..core.transcript_text import simplified_transcript
|
||||
from .local_transcription import (
|
||||
MAX_PCM_BYTES,
|
||||
SAMPLE_RATE,
|
||||
FasterWhisperEngine,
|
||||
SpeechEngine,
|
||||
)
|
||||
|
||||
MAX_RECORDING_SECONDS = 2 * 60 * 60
|
||||
MAX_RECORDING_BYTES = 250 * 1024 * 1024
|
||||
MAX_RECORDING_TEXT = 100_000
|
||||
|
||||
|
||||
class RecordingTranscriptionError(RuntimeError):
|
||||
"""Fixed messages which never include audio, transcript or decoder details."""
|
||||
|
||||
|
||||
def recording_pcm(path: Path) -> Iterator[bytes]:
|
||||
"""Decode in bounded blocks; reject oversized/corrupt input without a partial save."""
|
||||
if not path.is_file() or not 0 < path.stat().st_size <= MAX_RECORDING_BYTES:
|
||||
raise RecordingTranscriptionError("本机录音不存在、为空或超过 250 MB,无法补转写。")
|
||||
try:
|
||||
import av
|
||||
|
||||
with av.open(str(path)) as container:
|
||||
if not container.streams.audio:
|
||||
raise RecordingTranscriptionError("录音中没有音频轨道。")
|
||||
resampler = av.AudioResampler(format="s16", layout="mono", rate=SAMPLE_RATE)
|
||||
pending = bytearray()
|
||||
decoded_bytes = 0
|
||||
|
||||
def append_frames(frames):
|
||||
nonlocal decoded_bytes
|
||||
for frame in frames:
|
||||
pcm = frame.to_ndarray().tobytes()
|
||||
decoded_bytes += len(pcm)
|
||||
if decoded_bytes > MAX_RECORDING_SECONDS * SAMPLE_RATE * 2:
|
||||
raise RecordingTranscriptionError("录音超过 2 小时,无法自动补转写。")
|
||||
pending.extend(pcm)
|
||||
while len(pending) >= MAX_PCM_BYTES:
|
||||
yield bytes(pending[:MAX_PCM_BYTES])
|
||||
del pending[:MAX_PCM_BYTES]
|
||||
|
||||
for frame in container.decode(audio=0):
|
||||
yield from append_frames(resampler.resample(frame))
|
||||
yield from append_frames(resampler.resample(None))
|
||||
if pending:
|
||||
yield bytes(pending)
|
||||
if not decoded_bytes:
|
||||
raise RecordingTranscriptionError("录音中没有可读取的音频。")
|
||||
except RecordingTranscriptionError:
|
||||
raise
|
||||
except ImportError:
|
||||
raise RecordingTranscriptionError("本地音频解码组件未安装,请修复本地转写安装。") from None
|
||||
except Exception:
|
||||
raise RecordingTranscriptionError("录音解码失败,请检查本机文件是否完整。") from None
|
||||
|
||||
|
||||
class RecordingTranscriber:
|
||||
"""Used by one queue worker; the installed local model is reused between jobs."""
|
||||
|
||||
def __init__(self, engine: SpeechEngine | None = None) -> None:
|
||||
self._engine = engine
|
||||
|
||||
def __call__(self, path: Path) -> str:
|
||||
if self._engine is None:
|
||||
self._engine = FasterWhisperEngine()
|
||||
parts: list[str] = []
|
||||
total = 0
|
||||
for pcm in recording_pcm(path):
|
||||
raw = self._engine.transcribe(pcm)
|
||||
if not isinstance(raw, str):
|
||||
raise RecordingTranscriptionError("录音转写失败,尚未保存文字。")
|
||||
text = simplified_transcript(raw.strip())
|
||||
if not text:
|
||||
continue
|
||||
total += len(text) + bool(parts)
|
||||
if total > MAX_RECORDING_TEXT:
|
||||
raise RecordingTranscriptionError("转写文字超过保存上限,尚未保存,请联系管理员处理。")
|
||||
parts.append(text)
|
||||
if not parts:
|
||||
raise RecordingTranscriptionError("未识别到语音文字,请检查录音内容后重试。")
|
||||
# Mixed call audio cannot establish which person spoke. Do not invent roles.
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""OS drag events use the existing note upload/save path and stay patient-scoped."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PySide6.QtCore import QMimeData, QPoint, QPointF, Qt, QUrl
|
||||
from PySide6.QtGui import QDragEnterEvent, QDropEvent, QImage
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from test_diagnosis_drawer_visual import ActionRepository, _open_dialog, _select_tab
|
||||
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
from doctor_workstation.ui.image_drop import capture_image_drop
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_async(monkeypatch):
|
||||
def run(function, *args, on_success=None, on_error=None, on_finished=None, **_kwargs):
|
||||
try:
|
||||
result = function(*args)
|
||||
except Exception as error:
|
||||
if on_error:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(diagnosis_module, "run_async", run)
|
||||
|
||||
|
||||
class UploadRepository(ActionRepository):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.uploads = []
|
||||
self.fail_at = 0
|
||||
|
||||
def get_doctor_notes(self, diagnosis_id):
|
||||
self.notes_calls += 1
|
||||
return []
|
||||
|
||||
def upload_material(self, path, material_type):
|
||||
assert Path(path).is_file()
|
||||
assert not QImage(path).isNull()
|
||||
self.uploads.append((path, material_type))
|
||||
if len(self.uploads) == self.fail_at:
|
||||
raise RuntimeError("图片上传失败")
|
||||
return f"https://media.example.invalid/{len(self.uploads)}.png"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(application):
|
||||
repository = UploadRepository()
|
||||
dialog = _open_dialog(application, (1280, 900), mode="edit", repository=repository)
|
||||
_select_tab(dialog, "notes")
|
||||
try:
|
||||
yield dialog, repository
|
||||
finally:
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def image_mime(tmp_path, count=1):
|
||||
paths = []
|
||||
for index in range(count):
|
||||
path = tmp_path / f"image-{index}.png"
|
||||
picture = QImage(12, 12, QImage.Format.Format_RGB32)
|
||||
picture.fill(Qt.GlobalColor.blue)
|
||||
assert picture.save(str(path))
|
||||
paths.append(path)
|
||||
mime = QMimeData()
|
||||
mime.setUrls([QUrl.fromLocalFile(str(path)) for path in paths])
|
||||
return mime, paths
|
||||
|
||||
|
||||
def send_drop(target, mime):
|
||||
actions = Qt.DropAction.CopyAction | Qt.DropAction.MoveAction
|
||||
enter = QDragEnterEvent(QPoint(8, 8), actions, mime, Qt.MouseButton.LeftButton,
|
||||
Qt.KeyboardModifier.NoModifier)
|
||||
QApplication.sendEvent(target, enter)
|
||||
if not enter.isAccepted():
|
||||
return False
|
||||
event = QDropEvent(QPointF(8, 8), actions, mime, Qt.MouseButton.LeftButton,
|
||||
Qt.KeyboardModifier.NoModifier)
|
||||
event.setDropAction(Qt.DropAction.MoveAction)
|
||||
QApplication.sendEvent(target, event)
|
||||
QApplication.processEvents()
|
||||
assert event.dropAction() == Qt.DropAction.CopyAction
|
||||
return event.isAccepted()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["tongue_images", "report_files"])
|
||||
def test_drop_multiple_files_on_button_uploads_then_saves_one_note(workspace, tmp_path, kind):
|
||||
dialog, repository = workspace
|
||||
mime, original_paths = image_mime(tmp_path, 2)
|
||||
timeline = dialog.drawer_notes_timeline
|
||||
target = timeline.tongue_button if kind == "tongue_images" else timeline.report_button
|
||||
assert send_drop(target, mime)
|
||||
assert [item[1] for item in repository.uploads] == ["image", "image"]
|
||||
note = [payload for name, payload in repository.action_calls if name == "note"]
|
||||
assert note == [(501, "", {kind: [
|
||||
"https://media.example.invalid/1.png", "https://media.example.invalid/2.png",
|
||||
]})]
|
||||
assert all(path.exists() for path in original_paths)
|
||||
assert all(not Path(path).exists() for path, _kind in repository.uploads)
|
||||
assert not dialog._notes_mutation_busy
|
||||
assert repository.notes_calls >= 2
|
||||
|
||||
|
||||
def test_drop_memory_image_on_blank_scroll_area_chooses_report(workspace, monkeypatch):
|
||||
dialog, repository = workspace
|
||||
selected = []
|
||||
monkeypatch.setattr(dialog, "_choose_dropped_image_type", lambda count: selected.append(count) or "report_files")
|
||||
mime = QMimeData()
|
||||
image = QImage(10, 10, QImage.Format.Format_RGB32)
|
||||
image.fill(Qt.GlobalColor.red)
|
||||
mime.setImageData(image)
|
||||
assert send_drop(dialog._tab_pages["notes"].viewport(), mime)
|
||||
assert selected == [1]
|
||||
assert repository.uploads[0][1] == "image"
|
||||
assert any("report_files" in payload[2] for name, payload in repository.action_calls if name == "note")
|
||||
|
||||
|
||||
def test_cancel_category_does_not_upload_or_save(workspace, tmp_path, monkeypatch):
|
||||
dialog, repository = workspace
|
||||
mime, _paths = image_mime(tmp_path)
|
||||
monkeypatch.setattr(dialog, "_choose_dropped_image_type", lambda _count: "")
|
||||
assert send_drop(dialog.drawer_notes_timeline, mime)
|
||||
assert not repository.uploads
|
||||
assert not any(name == "note" for name, _payload in repository.action_calls)
|
||||
|
||||
|
||||
def test_readonly_or_missing_note_permission_rejects_drop(workspace, tmp_path):
|
||||
dialog, repository = workspace
|
||||
mime, _paths = image_mime(tmp_path)
|
||||
timeline = dialog.drawer_notes_timeline
|
||||
for editable, permitted in ((False, True), (True, False)):
|
||||
dialog._editable, dialog._can_note_add = editable, permitted
|
||||
timeline.set_editable(editable and permitted)
|
||||
assert not timeline.acceptDrops()
|
||||
assert not send_drop(timeline, mime)
|
||||
dialog._drop_doctor_note_images(capture_image_drop(mime), "tongue_images")
|
||||
QApplication.processEvents()
|
||||
assert not repository.uploads
|
||||
|
||||
|
||||
def test_upload_busy_rejects_second_drop_and_does_not_cross_patient(workspace, tmp_path, monkeypatch):
|
||||
dialog, repository = workspace
|
||||
mime, _paths = image_mime(tmp_path)
|
||||
pending = []
|
||||
monkeypatch.setattr(diagnosis_module, "run_async", lambda operation, **callbacks: pending.append((operation, callbacks)))
|
||||
assert send_drop(dialog.drawer_notes_timeline.tongue_button, mime)
|
||||
assert dialog._notes_mutation_busy
|
||||
assert not send_drop(dialog.drawer_notes_timeline, mime)
|
||||
assert len(pending) == 1
|
||||
operation, callbacks = pending[0]
|
||||
dialog._diagnosis_id = 777
|
||||
dialog._generation += 1
|
||||
dialog._notes_mutation_generation += 1
|
||||
result = operation()
|
||||
callbacks["on_success"](result)
|
||||
callbacks["on_finished"]()
|
||||
notes = [payload for name, payload in repository.action_calls if name == "note"]
|
||||
assert notes[0][0] == 501
|
||||
assert dialog._diagnosis_id == 777
|
||||
|
||||
|
||||
def test_queued_drop_does_not_attach_after_patient_switch(workspace, tmp_path):
|
||||
dialog, repository = workspace
|
||||
mime, _paths = image_mime(tmp_path)
|
||||
dialog._drop_doctor_note_images(capture_image_drop(mime), "tongue_images")
|
||||
dialog._diagnosis_id = 777
|
||||
dialog._generation += 1
|
||||
QApplication.processEvents()
|
||||
assert not repository.uploads
|
||||
|
||||
|
||||
def test_later_upload_failure_saves_prior_images_and_reports_partial_result(workspace, tmp_path):
|
||||
dialog, repository = workspace
|
||||
repository.fail_at = 2
|
||||
mime, _paths = image_mime(tmp_path, 3)
|
||||
assert send_drop(dialog.drawer_notes_timeline.tongue_button, mime)
|
||||
notes = [payload for name, payload in repository.action_calls if name == "note"]
|
||||
assert notes == [(501, "", {"tongue_images": ["https://media.example.invalid/1.png"]})]
|
||||
assert len(repository.uploads) == 2
|
||||
assert "已保存 1/3 张" in dialog.drawer_banner.label.text()
|
||||
assert "只重试未成功" in dialog.drawer_banner.label.text()
|
||||
assert not dialog._notes_mutation_busy
|
||||
|
||||
|
||||
def test_move_only_drag_is_rejected_to_preserve_original(workspace, tmp_path):
|
||||
dialog, repository = workspace
|
||||
mime, paths = image_mime(tmp_path)
|
||||
target = dialog.drawer_notes_timeline.tongue_button
|
||||
enter = QDragEnterEvent(QPoint(8, 8), Qt.DropAction.MoveAction, mime,
|
||||
Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier)
|
||||
QApplication.sendEvent(target, enter)
|
||||
assert not enter.isAccepted()
|
||||
event = QDropEvent(QPointF(8, 8), Qt.DropAction.MoveAction, mime,
|
||||
Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier)
|
||||
assert dialog.drawer_notes_timeline.eventFilter(target, event)
|
||||
assert not event.isAccepted()
|
||||
QApplication.processEvents()
|
||||
assert not repository.uploads
|
||||
assert paths[0].exists()
|
||||
|
||||
|
||||
def test_report_picture_uses_image_upload_endpoint_but_saves_as_report(workspace, tmp_path):
|
||||
dialog, _repository = workspace
|
||||
calls = []
|
||||
|
||||
def api(request):
|
||||
endpoint = request.url.path.removeprefix("/adminapi/")
|
||||
calls.append(endpoint)
|
||||
if endpoint == "upload/image":
|
||||
assert request.method == "POST"
|
||||
assert b"image/png" in request.content
|
||||
result = {"uri": "https://media.example.invalid/report.png"}
|
||||
elif endpoint == "doctor.appointment/addDoctorNote":
|
||||
assert json.loads(request.content) == {
|
||||
"diagnosis_id": 501, "content": "",
|
||||
"report_files": ["https://media.example.invalid/report.png"],
|
||||
}
|
||||
result = {"id": 123}
|
||||
else:
|
||||
assert endpoint == "doctor.appointment/doctorNotes"
|
||||
result = []
|
||||
return httpx.Response(200, json={"code": 1, "data": result})
|
||||
|
||||
client = ApiClient("https://example.test/adminapi/", transport=httpx.MockTransport(api))
|
||||
dialog.repository = RemoteDoctorRepository(client)
|
||||
mime, _paths = image_mime(tmp_path)
|
||||
assert send_drop(dialog.drawer_notes_timeline.report_button, mime)
|
||||
assert calls == ["upload/image", "doctor.appointment/addDoctorNote", "doctor.appointment/doctorNotes"]
|
||||
@@ -0,0 +1,430 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import FrozenInstanceError
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import shiboken6
|
||||
from PySide6.QtCore import QBuffer, QIODevice, QMimeData, QUrl
|
||||
from PySide6.QtGui import QImage, QPixmap
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.ui import image_drop
|
||||
from doctor_workstation.ui.image_drop import (
|
||||
can_drop_images,
|
||||
capture_image_drop,
|
||||
materialize_image_drop,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _image(width: int = 12, height: int = 8) -> QImage:
|
||||
image = QImage(width, height, QImage.Format.Format_RGB32)
|
||||
image.fill(0xFF668877)
|
||||
return image
|
||||
|
||||
|
||||
def _image_bytes(image_format: str, width: int = 12, height: int = 8) -> bytes:
|
||||
buffer = QBuffer()
|
||||
assert buffer.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
assert _image(width, height).save(buffer, image_format)
|
||||
return bytes(buffer.data())
|
||||
|
||||
|
||||
def _png(width: int = 12, height: int = 8) -> bytes:
|
||||
return _image_bytes("PNG", width, height)
|
||||
|
||||
|
||||
def _local_mime(*paths: Path) -> QMimeData:
|
||||
mime = QMimeData()
|
||||
mime.setUrls([QUrl.fromLocalFile(str(path)) for path in paths])
|
||||
return mime
|
||||
|
||||
|
||||
def _raw_mime(payload: bytes, media_type: str = "image/png") -> QMimeData:
|
||||
mime = QMimeData()
|
||||
mime.setData(media_type, payload)
|
||||
return mime
|
||||
|
||||
|
||||
def _mock_download(monkeypatch: pytest.MonkeyPatch, handler: object) -> None:
|
||||
monkeypatch.setattr(
|
||||
image_drop,
|
||||
"_new_download_client",
|
||||
lambda: httpx.Client(transport=httpx.MockTransport(handler), follow_redirects=False),
|
||||
)
|
||||
|
||||
|
||||
def test_local_images_are_validated_by_content_and_copied_without_touching_originals(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = tmp_path / "第一张 图片.jpg"
|
||||
second = tmp_path / "second.png"
|
||||
first.write_bytes(_png())
|
||||
second.write_bytes(_png(10, 20))
|
||||
mime = _local_mime(first, second)
|
||||
assert can_drop_images(mime)
|
||||
drop = capture_image_drop(mime)
|
||||
assert drop.count == 2
|
||||
with materialize_image_drop(drop) as paths:
|
||||
assert len(paths) == 2
|
||||
assert all(path.suffix == ".png" for path in paths)
|
||||
assert paths[0].read_bytes() == first.read_bytes()
|
||||
assert paths[1].read_bytes() == second.read_bytes()
|
||||
assert all(path.parent != tmp_path for path in paths)
|
||||
assert all(not path.exists() for path in paths)
|
||||
assert first.exists() and second.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_kind", ["local", "raw"])
|
||||
def test_bmp_is_converted_to_server_supported_png(tmp_path: Path, source_kind: str) -> None:
|
||||
original = tmp_path / "image.bmp"
|
||||
content = _image_bytes("BMP")
|
||||
original.write_bytes(content)
|
||||
mime = _local_mime(original) if source_kind == "local" else _raw_mime(content, "image/bmp")
|
||||
with materialize_image_drop(capture_image_drop(mime)) as paths:
|
||||
assert paths[0].suffix == ".png"
|
||||
assert paths[0].read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
|
||||
converted = QImage(str(paths[0]))
|
||||
assert converted.size() == _image().size()
|
||||
assert converted.pixelColor(0, 0) == _image().pixelColor(0, 0)
|
||||
assert original.read_bytes() == content
|
||||
assert not paths[0].exists()
|
||||
|
||||
|
||||
def test_conversion_rechecks_byte_limit_and_cleans_up(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# A genuine 1-pixel BMP is smaller than its genuine PNG representation.
|
||||
content = _image_bytes("BMP", 1, 1)
|
||||
assert len(content) < len(_png(1, 1))
|
||||
monkeypatch.setattr(image_drop, "MAX_IMAGE_BYTES", len(content))
|
||||
monkeypatch.setattr(
|
||||
image_drop, "TemporaryDirectory",
|
||||
lambda prefix: TemporaryDirectory(prefix=prefix, dir=tmp_path),
|
||||
)
|
||||
drop = capture_image_drop(_raw_mime(content, "image/bmp"))
|
||||
with pytest.raises(ValueError, match="10 MiB"), materialize_image_drop(drop):
|
||||
pytest.fail("converted image exceeded upload limit")
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_hover_never_reads_files_downloads_or_decodes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def unexpected(*args: object, **kwargs: object) -> None:
|
||||
pytest.fail("hover must not access images")
|
||||
|
||||
monkeypatch.setattr(Path, "is_file", unexpected)
|
||||
monkeypatch.setattr(image_drop, "_download_image", unexpected)
|
||||
monkeypatch.setattr(image_drop, "QImageReader", unexpected)
|
||||
assert can_drop_images(_local_mime(tmp_path / "not-yet-existing.png"))
|
||||
assert can_drop_images(_raw_mime(b"undecodable"))
|
||||
mime = QMimeData()
|
||||
mime.setHtml('<a href="https://site.invalid"><img src="https://img.invalid/a.png"></a>')
|
||||
assert can_drop_images(mime)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pixmap", [False, True])
|
||||
def test_memory_snapshot_survives_mime_deletion_and_works_in_worker(
|
||||
application: QApplication, pixmap: bool,
|
||||
) -> None:
|
||||
original = _image()
|
||||
mime = QMimeData()
|
||||
mime.setImageData(QPixmap.fromImage(original) if pixmap else original)
|
||||
drop = capture_image_drop(mime)
|
||||
original.fill(0)
|
||||
shiboken6.delete(mime)
|
||||
assert drop.count == 1
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
drop.sources = ()
|
||||
|
||||
def resolve() -> bytes:
|
||||
with materialize_image_drop(drop) as paths:
|
||||
return paths[0].read_bytes()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as worker:
|
||||
content = worker.submit(resolve).result(timeout=10)
|
||||
assert content == _png()
|
||||
|
||||
|
||||
def test_local_files_take_precedence_over_duplicate_memory_picture(tmp_path: Path) -> None:
|
||||
path = tmp_path / "image.png"
|
||||
path.write_bytes(_png())
|
||||
mime = _local_mime(path)
|
||||
mime.setImageData(_image(3, 3))
|
||||
drop = capture_image_drop(mime)
|
||||
assert drop.count == 1
|
||||
with materialize_image_drop(drop) as paths:
|
||||
assert paths[0].read_bytes() == path.read_bytes()
|
||||
|
||||
|
||||
def test_raw_image_snapshot_keeps_no_mime_reference() -> None:
|
||||
payload = _png()
|
||||
mime = _raw_mime(payload)
|
||||
drop = capture_image_drop(mime)
|
||||
mime.setData("image/png", b"modified")
|
||||
shiboken6.delete(mime)
|
||||
with materialize_image_drop(drop) as paths:
|
||||
assert paths[0].read_bytes() == payload
|
||||
|
||||
|
||||
def test_browser_image_src_wins_over_enclosing_link_and_uri_list(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
urls: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
urls.append(str(request.url))
|
||||
assert "authorization" not in request.headers
|
||||
assert "cookie" not in request.headers
|
||||
return httpx.Response(200, content=_png(), headers={"Content-Type": "image/png"})
|
||||
|
||||
_mock_download(monkeypatch, handler)
|
||||
mime = QMimeData()
|
||||
mime.setUrls([QUrl("https://site.invalid/article")])
|
||||
mime.setHtml(
|
||||
'<a href="https://site.invalid/article">'
|
||||
'<img src="https://cdn.invalid/a.png?width=2&height=3"></a>'
|
||||
)
|
||||
with materialize_image_drop(capture_image_drop(mime)) as paths:
|
||||
assert len(paths) == 1
|
||||
assert urls == ["https://cdn.invalid/a.png?width=2&height=3"]
|
||||
|
||||
|
||||
def test_multiple_html_images_and_base64_data_are_preserved() -> None:
|
||||
payload = _png()
|
||||
encoded = base64.b64encode(payload).decode("ascii")
|
||||
mime = QMimeData()
|
||||
mime.setHtml(f'<img src="data:image/png;base64,{encoded}">' * 2)
|
||||
drop = capture_image_drop(mime)
|
||||
assert drop.count == 2
|
||||
with materialize_image_drop(drop) as paths:
|
||||
assert [path.read_bytes() for path in paths] == [payload, payload]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("representation", ["text", "url"])
|
||||
def test_remote_image_url_can_be_dropped(
|
||||
monkeypatch: pytest.MonkeyPatch, representation: str,
|
||||
) -> None:
|
||||
_mock_download(monkeypatch, lambda _: httpx.Response(200, content=_png()))
|
||||
mime = QMimeData()
|
||||
if representation == "url":
|
||||
mime.setUrls([QUrl("https://cdn.invalid/image?id=123")])
|
||||
else:
|
||||
mime.setText("https://cdn.invalid/image?id=123")
|
||||
assert can_drop_images(mime)
|
||||
with materialize_image_drop(capture_image_drop(mime)) as paths:
|
||||
assert paths[0].read_bytes() == _png()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["C:/private/photo.png", "file:///C:/private/photo.png", "hello"])
|
||||
def test_plain_text_is_not_interpreted_as_local_file(text: str) -> None:
|
||||
mime = QMimeData()
|
||||
mime.setText(text)
|
||||
assert not can_drop_images(mime)
|
||||
with pytest.raises(ValueError, match="请拖入图片"):
|
||||
capture_image_drop(mime)
|
||||
|
||||
|
||||
def test_mixed_valid_and_invalid_files_fail_before_yield_and_clean_up(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
good, bad = tmp_path / "good.png", tmp_path / "fake.png"
|
||||
good.write_bytes(_png())
|
||||
bad.write_text("not a real image", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
image_drop, "TemporaryDirectory",
|
||||
lambda prefix: TemporaryDirectory(prefix=prefix, dir=tmp_path),
|
||||
)
|
||||
with (
|
||||
pytest.raises(ValueError, match="不是受支持的图片"),
|
||||
materialize_image_drop(capture_image_drop(_local_mime(good, bad))),
|
||||
):
|
||||
pytest.fail("must validate the full batch before uploading anything")
|
||||
assert sorted(path.name for path in tmp_path.iterdir()) == ["fake.png", "good.png"]
|
||||
|
||||
|
||||
def test_temp_files_are_cleaned_when_upload_raises() -> None:
|
||||
with (
|
||||
pytest.raises(RuntimeError, match="upload failed"),
|
||||
materialize_image_drop(capture_image_drop(_raw_mime(_png()))) as paths,
|
||||
):
|
||||
raise RuntimeError("upload failed")
|
||||
assert not paths[0].parent.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["local", "html"])
|
||||
def test_batch_limit_is_checked_without_io(tmp_path: Path, kind: str) -> None:
|
||||
if kind == "local":
|
||||
mime = _local_mime(*(tmp_path / f"{index}.png" for index in range(21)))
|
||||
else:
|
||||
mime = QMimeData()
|
||||
mime.setHtml('<img src="https://cdn.invalid/a.png">' * 21)
|
||||
with pytest.raises(ValueError, match="20"):
|
||||
capture_image_drop(mime)
|
||||
|
||||
|
||||
def test_exactly_twenty_images_are_allowed(tmp_path: Path) -> None:
|
||||
path = tmp_path / "image.png"
|
||||
path.write_bytes(_png())
|
||||
with materialize_image_drop(capture_image_drop(_local_mime(*([path] * 20)))) as paths:
|
||||
assert len(paths) == 20
|
||||
|
||||
|
||||
def test_local_byte_limit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(image_drop, "MAX_IMAGE_BYTES", 100)
|
||||
path = tmp_path / "large.png"
|
||||
path.write_bytes(b"a" * 101)
|
||||
with (
|
||||
pytest.raises(ValueError, match="10 MiB"),
|
||||
materialize_image_drop(capture_image_drop(_local_mime(path))),
|
||||
):
|
||||
pytest.fail("oversized image accepted")
|
||||
|
||||
|
||||
def test_raw_byte_limit_is_checked_during_capture(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(image_drop, "MAX_IMAGE_BYTES", 10)
|
||||
with pytest.raises(ValueError, match="10 MiB"):
|
||||
capture_image_drop(_raw_mime(b"a" * 11))
|
||||
|
||||
|
||||
def test_pixel_limit_is_checked_for_encoded_and_memory_images(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(image_drop, "MAX_IMAGE_PIXELS", 50)
|
||||
with (
|
||||
pytest.raises(ValueError, match="像素过大"),
|
||||
materialize_image_drop(capture_image_drop(_raw_mime(_png()))),
|
||||
):
|
||||
pytest.fail("large raster accepted")
|
||||
mime = QMimeData()
|
||||
mime.setImageData(_image())
|
||||
with pytest.raises(ValueError, match="像素过大"):
|
||||
capture_image_drop(mime)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
b'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>',
|
||||
b"<html><body>login</body></html>",
|
||||
b"not an image",
|
||||
])
|
||||
def test_non_raster_content_cannot_masquerade_as_png(payload: bytes) -> None:
|
||||
with (
|
||||
pytest.raises(ValueError, match="不是受支持的图片"),
|
||||
materialize_image_drop(capture_image_drop(_raw_mime(payload))),
|
||||
):
|
||||
pytest.fail("invalid image accepted")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
"data:image/svg+xml;base64,PHN2Zy8+",
|
||||
"data:image/png;base64,not-base64!",
|
||||
"data:image/png,not-base64",
|
||||
])
|
||||
def test_invalid_data_images_fail(value: str) -> None:
|
||||
mime = QMimeData()
|
||||
mime.setText(value)
|
||||
with pytest.raises(ValueError), materialize_image_drop(capture_image_drop(mime)):
|
||||
pytest.fail("invalid data image accepted")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("redirect", ["file:///C:/private/image.png", "javascript:alert(1)"])
|
||||
def test_unsafe_redirects_are_never_requested(
|
||||
monkeypatch: pytest.MonkeyPatch, redirect: str,
|
||||
) -> None:
|
||||
requests: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(str(request.url))
|
||||
return httpx.Response(302, headers={"Location": redirect})
|
||||
|
||||
_mock_download(monkeypatch, handler)
|
||||
mime = QMimeData()
|
||||
mime.setText("https://cdn.invalid/a.png")
|
||||
with (
|
||||
pytest.raises(ValueError, match="HTTP/HTTPS"),
|
||||
materialize_image_drop(capture_image_drop(mime)),
|
||||
):
|
||||
pytest.fail("unsafe redirect accepted")
|
||||
assert requests == ["https://cdn.invalid/a.png"]
|
||||
|
||||
|
||||
def test_redirects_do_not_forward_cookies(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
requests: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(str(request.url))
|
||||
assert "cookie" not in request.headers
|
||||
assert "authorization" not in request.headers
|
||||
if len(requests) == 1:
|
||||
return httpx.Response(302, headers={"Location": "/b.png", "Set-Cookie": "token=secret"})
|
||||
return httpx.Response(200, content=_png())
|
||||
|
||||
_mock_download(monkeypatch, handler)
|
||||
mime = QMimeData()
|
||||
mime.setText("https://cdn.invalid/a.png")
|
||||
with materialize_image_drop(capture_image_drop(mime)) as paths:
|
||||
assert paths[0].read_bytes() == _png()
|
||||
assert len(requests) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status,headers", [
|
||||
(401, {}),
|
||||
(403, {}),
|
||||
(200, {"Content-Type": "text/html"}),
|
||||
])
|
||||
def test_login_and_webpage_sources_explain_save_locally(
|
||||
monkeypatch: pytest.MonkeyPatch, status: int, headers: dict[str, str],
|
||||
) -> None:
|
||||
_mock_download(monkeypatch, lambda _: httpx.Response(status, headers=headers, content=b"login"))
|
||||
mime = QMimeData()
|
||||
mime.setText("https://site.invalid/private.png")
|
||||
with (
|
||||
pytest.raises(ValueError, match="先保存本地再拖入"),
|
||||
materialize_image_drop(capture_image_drop(mime)),
|
||||
):
|
||||
pytest.fail("login page accepted")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("declared_length", [True, False])
|
||||
def test_download_size_limit_uses_headers_and_actual_stream(
|
||||
monkeypatch: pytest.MonkeyPatch, declared_length: bool,
|
||||
) -> None:
|
||||
monkeypatch.setattr(image_drop, "MAX_IMAGE_BYTES", 100)
|
||||
|
||||
class OversizedStream(httpx.SyncByteStream):
|
||||
def __iter__(self):
|
||||
yield b"a" * 60
|
||||
yield b"b" * 60
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
headers = {"Content-Length": "120"} if declared_length else {}
|
||||
return httpx.Response(200, headers=headers, stream=OversizedStream())
|
||||
|
||||
_mock_download(monkeypatch, handler)
|
||||
mime = QMimeData()
|
||||
mime.setText("https://cdn.invalid/a.png")
|
||||
with (
|
||||
pytest.raises(ValueError, match="10 MiB"),
|
||||
materialize_image_drop(capture_image_drop(mime)),
|
||||
):
|
||||
pytest.fail("oversized response accepted")
|
||||
|
||||
|
||||
def test_embedded_url_credentials_are_rejected() -> None:
|
||||
mime = QMimeData()
|
||||
mime.setText("https://token:secret@cdn.invalid/a.png")
|
||||
with pytest.raises(ValueError, match="HTTP/HTTPS"):
|
||||
capture_image_drop(mime)
|
||||
@@ -342,7 +342,7 @@ def test_local_audio_dialog_lists_status_and_retry_controls(tmp_path: Path) -> N
|
||||
application.processEvents()
|
||||
try:
|
||||
assert dialog.objectName() == "LocalAudioQueueDialog"
|
||||
assert dialog.table.columnCount() == 8
|
||||
assert dialog.table.columnCount() == 9
|
||||
assert dialog.table.rowCount() == 2
|
||||
assert dialog.summary_failed.text() == "失败 1"
|
||||
assert dialog.summary_uploaded.text() == "已上传 1"
|
||||
@@ -350,7 +350,8 @@ def test_local_audio_dialog_lists_status_and_retry_controls(tmp_path: Path) -> N
|
||||
assert dialog.table.horizontalHeaderItem(1).text() == "通话记录 ID"
|
||||
assert dialog.table.horizontalHeaderItem(2).text() == "房间号"
|
||||
assert dialog.table.horizontalHeaderItem(5).text() == "上传状态"
|
||||
assert dialog.table.horizontalHeaderItem(6).text() == "失败原因"
|
||||
assert dialog.table.horizontalHeaderItem(6).text() == "文字状态"
|
||||
assert dialog.table.horizontalHeaderItem(7).text() == "失败原因"
|
||||
assert dialog.table.columnWidth(dialog._room_column) == 180
|
||||
rooms = {
|
||||
dialog.table.item(row, 2).text() for row in range(dialog.table.rowCount())
|
||||
@@ -400,7 +401,7 @@ def test_global_local_audio_dialog_lists_all_diagnoses_and_outcomes(
|
||||
application.processEvents()
|
||||
try:
|
||||
assert dialog.title_label.text() == "本机录音上传管理"
|
||||
assert dialog.table.columnCount() == 9
|
||||
assert dialog.table.columnCount() == 10
|
||||
assert dialog.table.rowCount() == 2
|
||||
assert dialog.table.horizontalHeaderItem(1).text() == "诊单 ID"
|
||||
assert dialog.table.horizontalHeaderItem(2).text() == "通话记录 ID"
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import Future
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QPushButton
|
||||
|
||||
from doctor_workstation.ui.dialogs.local_audio_queue import LocalAudioQueueDialog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _record(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
status: str = "uploaded",
|
||||
transcription_status: str = "pending",
|
||||
transcription_error: str = "",
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=1,
|
||||
diagnosis_id=8169,
|
||||
call_record_id=901,
|
||||
room_id="12345678",
|
||||
file_path=tmp_path / "synthetic-recording.webm",
|
||||
size_bytes=2048,
|
||||
status=status,
|
||||
error_text="",
|
||||
uploaded_url="cos://recordings/synthetic.webm" if status == "uploaded" else "",
|
||||
attempts=1,
|
||||
created_at="2026-11-06T01:00:00+00:00",
|
||||
exists=False,
|
||||
transcription_status=transcription_status,
|
||||
transcription_error=transcription_error,
|
||||
)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self, record: SimpleNamespace) -> None:
|
||||
self.record = record
|
||||
self.upload_retries: list[int] = []
|
||||
|
||||
def list_records(self, *, diagnosis_id: int | None) -> list[Any]:
|
||||
if diagnosis_id is None or diagnosis_id == self.record.diagnosis_id:
|
||||
return [self.record]
|
||||
return []
|
||||
|
||||
def retry(self, record_id: int) -> None:
|
||||
self.upload_retries.append(record_id)
|
||||
|
||||
|
||||
class _LegacyManager:
|
||||
def __init__(self, store: _Store) -> None:
|
||||
self.store = store
|
||||
self.uploads: list[int] = []
|
||||
self.pending_upload_scopes: list[int | None] = []
|
||||
|
||||
def submit_pending(self, *, diagnosis_id: int | None) -> list[Future[bool]]:
|
||||
self.pending_upload_scopes.append(diagnosis_id)
|
||||
return []
|
||||
|
||||
def submit(self, record_id: int) -> Future[bool]:
|
||||
self.uploads.append(record_id)
|
||||
return Future()
|
||||
|
||||
|
||||
class _Manager(_LegacyManager):
|
||||
def __init__(self, store: _Store) -> None:
|
||||
super().__init__(store)
|
||||
self.transcriptions: list[int] = []
|
||||
self.pending_transcription_scopes: list[int | None] = []
|
||||
|
||||
def submit_pending_transcriptions(
|
||||
self, *, diagnosis_id: int | None
|
||||
) -> list[Future[bool]]:
|
||||
self.pending_transcription_scopes.append(diagnosis_id)
|
||||
return []
|
||||
|
||||
def submit_transcription(self, record_id: int) -> Future[bool]:
|
||||
self.transcriptions.append(record_id)
|
||||
self.store.record.transcription_status = "transcribing"
|
||||
self.store.record.transcription_error = ""
|
||||
return Future()
|
||||
|
||||
|
||||
def _buttons(dialog: LocalAudioQueueDialog) -> dict[str, QPushButton]:
|
||||
host = dialog.table.cellWidget(0, dialog._action_column)
|
||||
assert host is not None
|
||||
return {button.text(): button for button in host.findChildren(QPushButton)}
|
||||
|
||||
|
||||
def test_uploaded_record_with_failed_text_can_retry_without_uploading_again(
|
||||
application: QApplication, tmp_path: Path
|
||||
) -> None:
|
||||
record = _record(
|
||||
tmp_path,
|
||||
transcription_status="failed",
|
||||
transcription_error="文字保存失败,请稍后重试",
|
||||
)
|
||||
store = _Store(record)
|
||||
manager = _Manager(store)
|
||||
dialog = LocalAudioQueueDialog(object(), 8169, store=store, manager=manager)
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
try:
|
||||
assert dialog.table.item(0, dialog._status_column).text() == "已上传"
|
||||
assert dialog.table.item(0, dialog._transcription_column).text() == "生成失败"
|
||||
assert dialog.table.item(0, dialog._error_column).text() == (
|
||||
"文字:文字保存失败,请稍后重试"
|
||||
)
|
||||
assert dialog.summary_uploaded.text() == "已上传 1"
|
||||
assert dialog.summary_failed.text() == "失败 0"
|
||||
assert dialog.summary_transcription.text() == "文字未完成 1"
|
||||
assert not dialog.retry_failed_button.isEnabled()
|
||||
|
||||
QTest.mouseClick(_buttons(dialog)["重试文字"], Qt.MouseButton.LeftButton)
|
||||
application.processEvents()
|
||||
|
||||
assert manager.transcriptions == [record.id]
|
||||
assert manager.uploads == []
|
||||
assert store.upload_retries == []
|
||||
assert record.status == "uploaded"
|
||||
assert dialog.table.item(0, dialog._status_column).text() == "已上传"
|
||||
assert dialog.table.item(0, dialog._transcription_column).text() == "生成中"
|
||||
assert not _buttons(dialog)["文字生成中"].isEnabled()
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_transcribing_controls_are_disabled_and_polling_detects_completion(
|
||||
application: QApplication, tmp_path: Path
|
||||
) -> None:
|
||||
record = _record(tmp_path, transcription_status="transcribing")
|
||||
store = _Store(record)
|
||||
manager = _Manager(store)
|
||||
dialog = LocalAudioQueueDialog(object(), 8169, store=store, manager=manager)
|
||||
try:
|
||||
assert not _buttons(dialog)["文字生成中"].isEnabled()
|
||||
QTest.mouseClick(_buttons(dialog)["文字生成中"], Qt.MouseButton.LeftButton)
|
||||
assert manager.transcriptions == []
|
||||
|
||||
record.transcription_status = "completed"
|
||||
dialog.refresh_records()
|
||||
|
||||
assert dialog.table.item(0, dialog._transcription_column).text() == "已保存"
|
||||
assert dialog.summary_transcription.text() == "文字未完成 0"
|
||||
assert _buttons(dialog) == {}
|
||||
hint = dialog.findChild(QLabel, "LocalAudioQueueHint")
|
||||
assert hint is not None
|
||||
assert "视频录制回放 → 查看文字" in hint.text()
|
||||
assert "未分离说话人" in hint.text()
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("diagnosis_id", [8169, None])
|
||||
def test_pending_text_resumes_for_dialog_scope_and_has_separate_action(
|
||||
application: QApplication, tmp_path: Path, diagnosis_id: int | None
|
||||
) -> None:
|
||||
record = _record(tmp_path)
|
||||
store = _Store(record)
|
||||
manager = _Manager(store)
|
||||
dialog = LocalAudioQueueDialog(object(), diagnosis_id, store=store, manager=manager)
|
||||
try:
|
||||
assert manager.pending_transcription_scopes == [diagnosis_id]
|
||||
assert dialog.table.horizontalHeaderItem(dialog._transcription_column).text() == (
|
||||
"文字状态"
|
||||
)
|
||||
assert dialog.table.item(0, dialog._transcription_column).text() == "待生成"
|
||||
assert _buttons(dialog)["补全文字"].isEnabled()
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_unuploaded_record_waits_for_upload_and_keeps_upload_retry(
|
||||
application: QApplication, tmp_path: Path
|
||||
) -> None:
|
||||
record = _record(tmp_path, status="failed")
|
||||
record.error_text = "上传连接中断"
|
||||
store = _Store(record)
|
||||
dialog = LocalAudioQueueDialog(object(), 8169, store=store, manager=_Manager(store))
|
||||
try:
|
||||
assert dialog.table.item(0, dialog._transcription_column).text() == "等待上传"
|
||||
assert dialog.table.item(0, dialog._error_column).text() == "上传:上传连接中断"
|
||||
assert set(_buttons(dialog)) == {"重试"}
|
||||
assert dialog.summary_transcription.text() == "文字未完成 0"
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_polling_detects_text_error_changes_without_upload_changes(
|
||||
application: QApplication, tmp_path: Path
|
||||
) -> None:
|
||||
record = _record(tmp_path, transcription_status="failed", transcription_error="请求失败")
|
||||
store = _Store(record)
|
||||
dialog = LocalAudioQueueDialog(object(), 8169, store=store, manager=_Manager(store))
|
||||
try:
|
||||
record.transcription_error = "转写服务暂不可用"
|
||||
dialog.refresh_records()
|
||||
assert dialog.table.item(0, dialog._error_column).text() == "文字:转写服务暂不可用"
|
||||
assert dialog.table.item(0, dialog._transcription_column).toolTip() == (
|
||||
"转写服务暂不可用"
|
||||
)
|
||||
finally:
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_dialog_accepts_legacy_manager_without_transcription_methods(
|
||||
application: QApplication, tmp_path: Path
|
||||
) -> None:
|
||||
store = _Store(_record(tmp_path))
|
||||
manager = _LegacyManager(store)
|
||||
dialog = LocalAudioQueueDialog(object(), 8169, store=store, manager=manager)
|
||||
try:
|
||||
assert manager.pending_upload_scopes == [8169]
|
||||
assert not _buttons(dialog)["补全文字"].isEnabled()
|
||||
finally:
|
||||
dialog.close()
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Synthetic recording recovery: durable text, exact call binding, no duplicate upload."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.core.errors import ApiTransportError
|
||||
from doctor_workstation.services import local_audio_queue as queue_module
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
from doctor_workstation.services.local_audio_queue import (
|
||||
LocalAudioQueueStore,
|
||||
LocalAudioUploadManager,
|
||||
)
|
||||
from doctor_workstation.services.repository import RemoteDoctorRepository
|
||||
from doctor_workstation.video import recording_transcription as recording
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self):
|
||||
self.call = {
|
||||
"id": 71, "caller_id": 5, "caller_type": "doctor", "status": 2,
|
||||
"local_audio_status": 2, "transcription_status": "partial",
|
||||
"local_audio_urls_list": ["https://audio.example.test/recording.webm"],
|
||||
"transcription_session_id": "live-session", "transcript_text": "旧的片段",
|
||||
}
|
||||
self.uploads = 0
|
||||
self.saves = []
|
||||
self.fail_save = False
|
||||
self.ack_id = 71
|
||||
|
||||
def get_current_user(self):
|
||||
return SimpleNamespace(id=5)
|
||||
|
||||
def list_call_records(self, _diagnosis_id):
|
||||
return [self.call.copy()]
|
||||
|
||||
def upload_call_recording(self, **kwargs):
|
||||
self.uploads += 1
|
||||
return {"file_url": "https://audio.example.test/recording.webm"}
|
||||
|
||||
def save_local_audio_transcript(self, **payload):
|
||||
self.saves.append(payload)
|
||||
if self.fail_save:
|
||||
raise ApiTransportError("synthetic network failure")
|
||||
return {"call_record_id": self.ack_id, "transcription_status": "completed", "saved": True}
|
||||
|
||||
|
||||
def ready(store, *, uploaded=True):
|
||||
record = store.begin_recording(
|
||||
session_id="synthetic-local", diagnosis_id=9, call_record_id=71, mime_type="audio/webm"
|
||||
)
|
||||
record.file_path.write_bytes(b"synthetic audio")
|
||||
store.finalize_recording(record.id, size_bytes=15)
|
||||
if uploaded:
|
||||
store.mark_uploaded(record.id, "https://audio.example.test/recording.webm")
|
||||
return record.id
|
||||
|
||||
|
||||
def test_upload_automatically_recovers_missing_text_without_reupload(tmp_path):
|
||||
store = LocalAudioQueueStore(tmp_path)
|
||||
record_id = ready(store, uploaded=False)
|
||||
repository = Repository()
|
||||
saved = threading.Event()
|
||||
manager = LocalAudioUploadManager(repository, store, transcriber=lambda _path: "合成测试文字")
|
||||
manager.add_upload_listener(
|
||||
lambda row: saved.set() if row.transcription_status == "completed" else None
|
||||
)
|
||||
assert manager.submit(record_id).result(3)
|
||||
assert saved.wait(3)
|
||||
assert repository.uploads == 1
|
||||
assert repository.saves[0]["call_record_id"] == 71
|
||||
assert repository.saves[0]["expected_session_id"] == "live-session"
|
||||
assert repository.saves[0]["expected_status"] == "partial"
|
||||
assert store.require(record_id).transcription_status == "completed"
|
||||
assert store.require(record_id).exists
|
||||
|
||||
|
||||
def test_failed_save_keeps_draft_and_id_across_restart(tmp_path):
|
||||
store = LocalAudioQueueStore(tmp_path)
|
||||
record_id = ready(store)
|
||||
repository = Repository()
|
||||
repository.fail_save = True
|
||||
recognized = []
|
||||
|
||||
def transcribe(path):
|
||||
recognized.append(path)
|
||||
return "保留原始文字,0.5 毫克。"
|
||||
|
||||
manager = LocalAudioUploadManager(repository, store, transcriber=transcribe)
|
||||
assert not manager.submit_transcription(record_id).result(3)
|
||||
first = store.require(record_id)
|
||||
assert first.status == "uploaded"
|
||||
assert first.transcription_status == "failed"
|
||||
assert store.transcription_draft(record_id) == "保留原始文字,0.5 毫克。"
|
||||
repository.fail_save = False
|
||||
restarted = LocalAudioQueueStore(tmp_path)
|
||||
retry = LocalAudioUploadManager(repository, restarted, transcriber=transcribe)
|
||||
assert retry.submit_transcription(record_id).result(3)
|
||||
assert len(recognized) == 1
|
||||
assert repository.uploads == 0
|
||||
assert repository.saves[0]["recovery_id"] == repository.saves[1]["recovery_id"]
|
||||
assert not restarted.transcription_draft(record_id)
|
||||
|
||||
|
||||
def test_existing_complete_text_skips_recognition_and_save(tmp_path):
|
||||
store = LocalAudioQueueStore(tmp_path)
|
||||
record_id = ready(store)
|
||||
repository = Repository()
|
||||
repository.call["transcription_status"] = "completed"
|
||||
|
||||
def forbidden(_path):
|
||||
pytest.fail("must not overwrite an existing completed transcript")
|
||||
|
||||
manager = LocalAudioUploadManager(repository, store, transcriber=forbidden)
|
||||
assert manager.submit_transcription(record_id).result(3)
|
||||
assert not repository.saves
|
||||
|
||||
|
||||
@pytest.mark.parametrize("changes", [
|
||||
{"caller_id": 6}, {"status": 0}, {"local_audio_status": 0}, {"id": 72},
|
||||
{"local_audio_urls_list": ["https://audio.example.test/other-call.webm"]},
|
||||
])
|
||||
def test_wrong_owner_active_or_missing_call_does_not_transcribe(tmp_path, changes):
|
||||
store = LocalAudioQueueStore(tmp_path)
|
||||
record_id = ready(store)
|
||||
repository = Repository()
|
||||
repository.call.update(changes)
|
||||
recognized = []
|
||||
manager = LocalAudioUploadManager(repository, store, transcriber=lambda path: recognized.append(path))
|
||||
assert not manager.submit_transcription(record_id).result(3)
|
||||
assert not recognized
|
||||
assert not repository.saves
|
||||
assert store.require(record_id).status == "uploaded"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["", " ", "x" * 100_001], ids=["empty", "blank", "oversized"])
|
||||
def test_empty_or_oversized_asr_is_never_marked_saved(tmp_path, text):
|
||||
store = LocalAudioQueueStore(tmp_path)
|
||||
record_id = ready(store)
|
||||
repository = Repository()
|
||||
manager = LocalAudioUploadManager(repository, store, transcriber=lambda _path: text)
|
||||
assert not manager.submit_transcription(record_id).result(3)
|
||||
assert store.require(record_id).transcription_status == "failed"
|
||||
assert not repository.saves
|
||||
|
||||
|
||||
def test_wrong_call_ack_preserves_draft_as_failed(tmp_path):
|
||||
store = LocalAudioQueueStore(tmp_path)
|
||||
record_id = ready(store)
|
||||
repository = Repository()
|
||||
repository.ack_id = 72
|
||||
manager = LocalAudioUploadManager(repository, store, transcriber=lambda _path: "合成文字")
|
||||
assert not manager.submit_transcription(record_id).result(3)
|
||||
assert store.transcription_draft(record_id) == "合成文字"
|
||||
assert store.require(record_id).transcription_status == "failed"
|
||||
|
||||
|
||||
def test_duplicate_submit_recognizes_once(tmp_path):
|
||||
store = LocalAudioQueueStore(tmp_path)
|
||||
record_id = ready(store)
|
||||
repository = Repository()
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
|
||||
def transcribe(_path):
|
||||
entered.set()
|
||||
assert release.wait(3)
|
||||
return "合成文字"
|
||||
|
||||
manager = LocalAudioUploadManager(repository, store, transcriber=transcribe)
|
||||
first = manager.submit_transcription(record_id)
|
||||
assert entered.wait(3)
|
||||
assert manager.submit_transcription(record_id) is first
|
||||
release.set()
|
||||
assert first.result(3)
|
||||
assert len(repository.saves) == 1
|
||||
|
||||
|
||||
def test_interrupted_work_resumes_with_saved_draft(tmp_path):
|
||||
store = LocalAudioQueueStore(tmp_path)
|
||||
record_id = ready(store)
|
||||
claimed = store.claim_transcription(record_id)
|
||||
store.save_transcription_draft(record_id, "识别已完成但进程意外退出")
|
||||
queue_module._RECOVERED_DATABASES.discard(store.database_path)
|
||||
restarted = LocalAudioQueueStore(tmp_path)
|
||||
assert restarted.require(record_id).transcription_status == "pending"
|
||||
assert restarted.require(record_id).transcription_recovery_id == claimed.transcription_recovery_id
|
||||
manager = LocalAudioUploadManager(Repository(), restarted)
|
||||
assert manager.submit_pending_transcriptions(diagnosis_id=9)[0].result(3)
|
||||
assert restarted.require(record_id).transcription_status == "completed"
|
||||
|
||||
|
||||
def test_upload_finishes_before_call_end_then_text_recovers_automatically(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(queue_module, "_TRANSCRIPTION_END_RETRY_DELAYS", (0.01, 0.01))
|
||||
store = LocalAudioQueueStore(tmp_path)
|
||||
record_id = ready(store, uploaded=False)
|
||||
repository = Repository()
|
||||
calls = []
|
||||
saved = threading.Event()
|
||||
|
||||
def call_state(_diagnosis_id):
|
||||
calls.append(True)
|
||||
return [{**repository.call, "status": 1 if len(calls) == 1 else 2}]
|
||||
|
||||
repository.list_call_records = call_state
|
||||
manager = LocalAudioUploadManager(repository, store, transcriber=lambda _path: "合成文字")
|
||||
manager.add_upload_listener(
|
||||
lambda row: saved.set() if row.transcription_status == "completed" else None
|
||||
)
|
||||
assert manager.submit(record_id).result(3)
|
||||
assert saved.wait(3)
|
||||
assert len(calls) == 2
|
||||
assert repository.uploads == 1
|
||||
assert len(repository.saves) == 1
|
||||
|
||||
|
||||
def test_waiting_for_call_end_is_bounded_and_retry_remains_available(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(queue_module, "_TRANSCRIPTION_END_RETRY_DELAYS", (0.01, 0.01))
|
||||
store = LocalAudioQueueStore(tmp_path)
|
||||
record_id = ready(store)
|
||||
repository = Repository()
|
||||
repository.call["status"] = 1
|
||||
recognized = []
|
||||
manager = LocalAudioUploadManager(repository, store, transcriber=lambda path: recognized.append(path))
|
||||
assert not manager.submit_transcription(record_id).result(3)
|
||||
assert not recognized
|
||||
assert store.require(record_id).transcription_status == "failed"
|
||||
repository.call["status"] = 2
|
||||
manager._transcriber = lambda _path: "合成文字"
|
||||
assert manager.submit_transcription(record_id).result(3)
|
||||
|
||||
|
||||
def wav(path: Path, samples: int, *, rate=16_000, channels=1):
|
||||
with wave.open(str(path), "wb") as output:
|
||||
output.setnchannels(channels)
|
||||
output.setsampwidth(2)
|
||||
output.setframerate(rate)
|
||||
output.writeframes(b"\x10\x00" * samples * channels)
|
||||
|
||||
|
||||
def test_decoder_keeps_tail_and_resamples_stereo(tmp_path):
|
||||
pytest.importorskip("av")
|
||||
source = tmp_path / "synthetic.wav"
|
||||
wav(source, 48_000 * 25, rate=48_000, channels=2)
|
||||
chunks = list(recording.recording_pcm(source))
|
||||
assert [len(chunk) for chunk in chunks] == [384_000, 384_000, 32_000]
|
||||
|
||||
|
||||
def test_decoder_rejects_corrupt_or_overlong_audio(tmp_path, monkeypatch):
|
||||
pytest.importorskip("av")
|
||||
source = tmp_path / "synthetic.wav"
|
||||
source.write_bytes(b"not audio")
|
||||
with pytest.raises(recording.RecordingTranscriptionError, match="解码失败"):
|
||||
list(recording.recording_pcm(source))
|
||||
wav(source, 32_000)
|
||||
monkeypatch.setattr(recording, "MAX_RECORDING_SECONDS", 1)
|
||||
with pytest.raises(recording.RecordingTranscriptionError, match="超过"):
|
||||
list(recording.recording_pcm(source))
|
||||
|
||||
|
||||
def test_recording_transcriber_preserves_text_and_never_labels_speakers(tmp_path):
|
||||
pytest.importorskip("av")
|
||||
source = tmp_path / "synthetic.wav"
|
||||
wav(source, 16_000 * 13)
|
||||
responses = iter(["沒有過敏,0.5 mg。", "不是 5 mg。"])
|
||||
engine = SimpleNamespace(transcribe=lambda _pcm: next(responses))
|
||||
assert recording.RecordingTranscriber(engine)(source) == "没有过敏,0.5 mg。\n不是 5 mg。"
|
||||
|
||||
|
||||
def test_repository_sends_exact_binding_and_compare_state():
|
||||
calls = []
|
||||
|
||||
def handle(request):
|
||||
assert request.url.path == "/adminapi/tcm.diagnosis/saveLocalAudioTranscript"
|
||||
calls.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"code": 1, "data": {
|
||||
"call_record_id": 71, "transcription_status": "completed", "saved": True,
|
||||
}})
|
||||
|
||||
client = ApiClient("https://example.test/adminapi/", transport=httpx.MockTransport(handle))
|
||||
repository = RemoteDoctorRepository(client)
|
||||
payload = dict(
|
||||
diagnosis_id=9, call_record_id=71, recovery_id="audio-" + "a" * 32,
|
||||
expected_session_id="live-session", expected_status="partial",
|
||||
audio_url="https://audio.example.test/recording.webm", transcript_text="合成测试文字",
|
||||
)
|
||||
assert repository.save_local_audio_transcript(**payload)["saved"]
|
||||
assert calls == [payload]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Finalization retries retain one sealed capture session and one call identity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.video.launcher import VideoCallRequest
|
||||
from doctor_workstation.video.lifecycle import OrderedCallLifecycle
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self, outcomes=(), block_attempt=0):
|
||||
self.outcomes = list(outcomes)
|
||||
self.block_attempt = block_attempt
|
||||
self.attempts = []
|
||||
self.events = []
|
||||
self.entered = threading.Event()
|
||||
self.release = threading.Event()
|
||||
|
||||
def start_call(self, **_payload):
|
||||
return {"call_record_id": 71}
|
||||
|
||||
def start_call_transcription(self, **_payload):
|
||||
return True
|
||||
|
||||
def upsert_call_transcript_segments(self, **_payload):
|
||||
return True
|
||||
|
||||
def finish_call_transcription(self, **payload):
|
||||
self.attempts.append(payload)
|
||||
self.events.append("finish")
|
||||
if len(self.attempts) == self.block_attempt:
|
||||
self.entered.set()
|
||||
assert self.release.wait(3), "test did not release finalization"
|
||||
outcome = self.outcomes.pop(0) if self.outcomes else True
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
def end_call(self, **_payload):
|
||||
self.events.append("end")
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_lifecycle():
|
||||
lifecycles = []
|
||||
|
||||
def make(**options):
|
||||
repository = Repository(**options)
|
||||
request = VideoCallRequest(
|
||||
sdk_app_id=1400123456,
|
||||
user_id="doctor-test",
|
||||
user_sig="test-ticket",
|
||||
target_user_id="patient-test",
|
||||
diagnosis_id=12,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, repository, logging.getLogger(__name__))
|
||||
lifecycles.append((lifecycle, repository))
|
||||
assert lifecycle.start_transcription("tr-finish-retry").result(timeout=2)
|
||||
assert lifecycle.save_transcript_segment(
|
||||
{"segment_id": "synthetic-1", "text": "synthetic fixture"}
|
||||
).result(timeout=2)
|
||||
return lifecycle, repository
|
||||
|
||||
yield make
|
||||
for lifecycle, repository in lifecycles:
|
||||
repository.release.set()
|
||||
lifecycle.end("test-cleanup").result(timeout=3)
|
||||
assert lifecycle.wait(3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [
|
||||
RuntimeError("temporary storage failure"), False,
|
||||
{"status": "failed", "segment_count": 0},
|
||||
{"status": "partial", "segment_count": 1},
|
||||
])
|
||||
def test_failed_finish_retries_same_snapshot_without_reopening_capture(make_lifecycle, failure):
|
||||
lifecycle, repository = make_lifecycle(outcomes=[failure, True])
|
||||
failed = lifecycle.finish_transcription()
|
||||
with pytest.raises(RuntimeError):
|
||||
failed.result(timeout=2)
|
||||
with pytest.raises(RuntimeError, match="already stopped"):
|
||||
lifecycle.start_transcription("tr-finish-retry")
|
||||
with pytest.raises(RuntimeError, match="already stopped"):
|
||||
lifecycle.save_transcript_segment({"segment_id": "late", "text": "synthetic fixture"})
|
||||
|
||||
retry = lifecycle.finish_transcription()
|
||||
assert retry is not failed
|
||||
assert retry.result(timeout=2)
|
||||
assert lifecycle.finish_transcription() is retry
|
||||
assert repository.attempts == [
|
||||
{"diagnosis_id": 12, "call_record_id": 71,
|
||||
"transcription_session_id": "tr-finish-retry",
|
||||
"expected_segment_count": 1, "status": "completed"},
|
||||
] * 2
|
||||
|
||||
|
||||
def test_concurrent_finish_retries_share_one_running_write(make_lifecycle):
|
||||
lifecycle, repository = make_lifecycle(
|
||||
outcomes=[RuntimeError("temporary storage failure"), True], block_attempt=2,
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
lifecycle.finish_transcription().result(timeout=2)
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=4) as callers:
|
||||
futures = list(callers.map(lambda _: lifecycle.finish_transcription(), range(4)))
|
||||
assert repository.entered.wait(2)
|
||||
assert all(future is futures[0] for future in futures)
|
||||
assert len(repository.attempts) == 2
|
||||
finally:
|
||||
repository.release.set()
|
||||
assert futures[0].result(timeout=2)
|
||||
|
||||
|
||||
def test_cancelled_finish_can_retry_without_reopening_capture(make_lifecycle):
|
||||
lifecycle, repository = make_lifecycle()
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
|
||||
def occupy_worker():
|
||||
entered.set()
|
||||
assert release.wait(3)
|
||||
|
||||
lifecycle._worker.submit("test-block", occupy_worker)
|
||||
try:
|
||||
assert entered.wait(2)
|
||||
cancelled = lifecycle.finish_transcription()
|
||||
assert cancelled.cancel()
|
||||
retry = lifecycle.finish_transcription()
|
||||
assert retry is not cancelled
|
||||
finally:
|
||||
release.set()
|
||||
assert retry.result(timeout=2)
|
||||
assert len(repository.attempts) == 1
|
||||
|
||||
|
||||
def test_end_retries_failed_finish_before_ending_exact_call(make_lifecycle):
|
||||
lifecycle, repository = make_lifecycle(outcomes=[RuntimeError("temporary failure"), True])
|
||||
with pytest.raises(RuntimeError):
|
||||
lifecycle.finish_transcription().result(timeout=2)
|
||||
assert lifecycle.end("synthetic-hangup").result(timeout=2)
|
||||
assert repository.events == ["finish", "finish", "end"]
|
||||
assert repository.attempts[-1]["status"] == "partial"
|
||||
assert repository.attempts[-1]["transcription_session_id"] == "tr-finish-retry"
|
||||
assert lifecycle.finish_transcription().result(timeout=2)
|
||||
assert len(repository.attempts) == 2
|
||||
|
||||
|
||||
def test_failed_finish_cannot_submit_after_end_worker_has_stopped(make_lifecycle):
|
||||
lifecycle, repository = make_lifecycle(
|
||||
outcomes=[RuntimeError("first failure"), RuntimeError("fallback failure")],
|
||||
)
|
||||
with pytest.raises(RuntimeError):
|
||||
lifecycle.finish_transcription().result(timeout=2)
|
||||
assert lifecycle.end("synthetic-hangup").result(timeout=2)
|
||||
with pytest.raises(RuntimeError, match="already ended"):
|
||||
lifecycle.finish_transcription()
|
||||
assert len(repository.attempts) == 2
|
||||
+60
-60
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<title>视频面诊</title>
|
||||
<script type="module" crossorigin src="./assets/index-BSFyyCww.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-B6TT7yDM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Do0GoLfO.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1699,10 +1699,26 @@ async function stopTranscription(
|
||||
return
|
||||
}
|
||||
const finalStatus = sdkStopped && segmentsComplete && !transcriptionHadInterruption ? status : 'partial'
|
||||
transcriptionFinalized = true
|
||||
const persisted = await requestTranscriptionStop(sessionId, finalStatus)
|
||||
let persisted = false
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
if (sessionId !== transcriptionSessionId) return
|
||||
if (attempt > 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, attempt * 500))
|
||||
if (sessionId !== transcriptionSessionId) return
|
||||
}
|
||||
persisted = await requestTranscriptionStop(sessionId, finalStatus)
|
||||
if (persisted) break
|
||||
}
|
||||
if (sessionId !== transcriptionSessionId) return
|
||||
transcriptionState.value = persisted && !transcriptionError.value ? 'idle' : 'error'
|
||||
if (!persisted) {
|
||||
transcriptionState.value = 'error'
|
||||
notice.value = '本次面诊对话文字归档失败,请在通话记录中核实'
|
||||
// Keep the session and unsaved snapshots available; a failed ACK is not
|
||||
// proof that the server finalized them. The native end fallback retries.
|
||||
throw new Error(notice.value)
|
||||
}
|
||||
transcriptionFinalized = true
|
||||
transcriptionState.value = !transcriptionError.value ? 'idle' : 'error'
|
||||
notice.value = transcriptionError.value || (persisted && finalStatus === 'completed'
|
||||
? '本次面诊对话文字已保存到视频记录'
|
||||
: '本次转写可能不完整,请在通话记录中核实')
|
||||
|
||||
@@ -42,7 +42,9 @@ function harness(options = {}) {
|
||||
if(!native.session)native.session=event.sessionId;
|
||||
if(native.session!==event.sessionId||native.sealed)native.violations.push(event);
|
||||
} else if(event.event==='transcription-stop'){
|
||||
if(native.session!==event.sessionId||native.sealed)native.violations.push(event);
|
||||
// Capture is sealed immediately, but the same finish may retry after
|
||||
// a negative or missing ACK; it does not create a second session.
|
||||
if(native.session!==event.sessionId)native.violations.push(event);
|
||||
native.sealed=true;
|
||||
} else if(['transcription-segment','transcription-ready'].includes(event.event)){
|
||||
if(!native.ready||native.session!==event.sessionId||native.sealed)native.violations.push(event);
|
||||
@@ -305,3 +307,53 @@ test('cloud capture status remains accurate and partial post-call text can be re
|
||||
assert.equal(status('error','partial',true),'partial')
|
||||
assert.equal(status('stopping','partial',true),'stopping')
|
||||
})
|
||||
|
||||
|
||||
test('finish retries negative ACKs in the same session and finalizes only after success', async () => {
|
||||
const h=harness();await start(h)
|
||||
const stopped=h.stop();await settle();await h.advance(600)
|
||||
const session=h.state().session
|
||||
assert.equal(h.state().finalized,false)
|
||||
h.stopAck(false);await settle();await h.advance(500)
|
||||
assert.equal(h.state().finalized,false)
|
||||
assert.equal(h.state().ready,true)
|
||||
h.stopAck(false);await settle();await h.advance(1000)
|
||||
const finishes=h.events.filter(e=>e.event==='transcription-stop')
|
||||
assert.equal(finishes.length,3)
|
||||
assert.deepEqual(finishes.map(e=>e.sessionId),[session,session,session])
|
||||
h.stopAck();await stopped
|
||||
assert.equal(h.state().finalized,true)
|
||||
assert.equal(h.state().state,'idle')
|
||||
assert.equal(h.state().ready,false)
|
||||
assert.deepEqual(h.native.violations,[])
|
||||
})
|
||||
|
||||
test('exhausted finish ACK failures retain pending text and surface failure without false completion', async () => {
|
||||
const h=harness();await start(h);h.segment(raw('unsaved'))
|
||||
const stopped=h.stop()
|
||||
const rejected=assert.rejects(stopped,/归档失败/)
|
||||
await settle();await h.advance(15000)
|
||||
h.stopAck(false);await settle();await h.advance(500)
|
||||
h.stopAck(false);await settle();await h.advance(1000)
|
||||
h.stopAck(false);await rejected
|
||||
assert.equal(h.events.filter(e=>e.event==='transcription-stop').length,3)
|
||||
assert.equal(h.state().pending,1)
|
||||
assert.equal(h.state().ready,true)
|
||||
assert.equal(h.state().finalized,false)
|
||||
assert.equal(h.state().state,'error')
|
||||
assert.equal(h.state().session,'tr-test-1')
|
||||
assert.deepEqual(h.native.violations,[])
|
||||
})
|
||||
|
||||
test('a missing finish ACK times out and retries without clearing the current session', async () => {
|
||||
const h=harness();await start(h)
|
||||
const stopped=h.stop();await settle();await h.advance(600)
|
||||
await h.advance(15500)
|
||||
assert.equal(h.events.filter(e=>e.event==='transcription-stop').length,2)
|
||||
assert.equal(h.state().finalized,false)
|
||||
assert.equal(h.state().ready,true)
|
||||
h.stopAck();await stopped
|
||||
assert.equal(h.state().finalized,true)
|
||||
assert.equal(h.state().state,'idle')
|
||||
assert.deepEqual(h.native.violations,[])
|
||||
})
|
||||
|
||||
@@ -393,6 +393,21 @@ class DiagnosisController extends BaseAdminController
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** @notes 将本机保留录音补转写的完整文字保存到已结束的通话 */
|
||||
public function saveLocalAudioTranscript()
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
return $this->fail('请求方式错误');
|
||||
}
|
||||
$params = $this->request->post('', null, null);
|
||||
$params['admin_id'] = (int)$this->adminId;
|
||||
$result = DiagnosisLogic::saveLocalAudioTranscript($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(DiagnosisLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** @notes 完成当前通话转写并固化对话文字 */
|
||||
public function finishCallTranscription()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\controller\tcm;
|
||||
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\logic\tcm\MobileRecordingLogic;
|
||||
use DomainException;
|
||||
|
||||
/** Authenticated metadata sync; no upload, transcript or arbitrary file URL endpoint. */
|
||||
final class MobileRecordingController extends BaseAdminController
|
||||
{
|
||||
public function bind()
|
||||
{
|
||||
return $this->handle(true, fn (array $params): array => MobileRecordingLogic::bind($params, $this->adminId));
|
||||
}
|
||||
|
||||
public function unbind()
|
||||
{
|
||||
return $this->handle(true, fn (array $params): array => MobileRecordingLogic::unbind($params, $this->adminId));
|
||||
}
|
||||
|
||||
public function deviceLists()
|
||||
{
|
||||
return $this->handle(false, fn (array $params): array => MobileRecordingLogic::deviceLists($params, $this->adminId));
|
||||
}
|
||||
|
||||
public function patientLists()
|
||||
{
|
||||
return $this->handle(false, fn (array $params): array => MobileRecordingLogic::patientLists($params, $this->adminId));
|
||||
}
|
||||
|
||||
private function handle(bool $post, callable $operation)
|
||||
{
|
||||
if ($post ? !$this->request->isPost() : !$this->request->isGet()) {
|
||||
return $this->fail('请求方式错误');
|
||||
}
|
||||
try {
|
||||
// Explicit current page permission, including before any row lookup.
|
||||
MobileRecordingLogic::actor((int) $this->adminId);
|
||||
if ($this->request->file()) {
|
||||
throw new DomainException('手机录音关联不接受文件上传');
|
||||
}
|
||||
$params = $post ? $this->request->post('', null, null) : $this->request->get();
|
||||
return $this->data($operation($params));
|
||||
} catch (DomainException $e) {
|
||||
return $this->fail($e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
// Do not expose SQL, identifiers, paths or server configuration on schema/deployment errors.
|
||||
return $this->fail('手机录音关联服务暂不可用,请稍后重试或联系管理员');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,16 @@ class AuthMiddleware
|
||||
return JsonService::fail('权限不足,无法查看面诊记录');
|
||||
}
|
||||
|
||||
// Full-audio recovery exercises all three realtime write capabilities.
|
||||
// Protect it explicitly before the unregistered-route compatibility branch.
|
||||
if ($accessUri === 'tcm.diagnosis/savelocalaudiotranscript') {
|
||||
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
|
||||
$required = ['tcm.diagnosis/startcalltranscription',
|
||||
'tcm.diagnosis/upsertcalltranscriptsegments', 'tcm.diagnosis/finishcalltranscription'];
|
||||
return array_diff($required, $uris) === []
|
||||
? $next($request) : JsonService::fail('权限不足,无法保存录音补转写');
|
||||
}
|
||||
|
||||
// This group must never use the legacy unregistered-route bypass.
|
||||
if (str_starts_with($accessUri, 'tcm.followupaudio/')) {
|
||||
$allowed = ['capabilities', 'uploadsession', 'uploadchunk', 'uploadcomplete',
|
||||
@@ -135,6 +145,16 @@ class AuthMiddleware
|
||||
return JsonService::fail('权限不足,无法访问或操作');
|
||||
}
|
||||
|
||||
// Metadata-only 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() ?? []);
|
||||
if (in_array($action, ['bind', 'unbind', 'devicelists', 'patientlists'], true)
|
||||
&& in_array('firstvisit.mypatient/lists', $uris, true)) {
|
||||
return $next($request); // Controller re-reads grants; logic enforces the current patient scope.
|
||||
}
|
||||
return JsonService::fail('权限不足,无法访问我的患者录音关联');
|
||||
}
|
||||
// 全部路由
|
||||
$allUri = $this->formatUrl($adminAuthCache->getAllUri());
|
||||
|
||||
|
||||
@@ -2023,6 +2023,20 @@ class DiagnosisLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
/** @notes 本机完整录音补转写;不重新打开实时转写会话 */
|
||||
public static function saveLocalAudioTranscript(array $params)
|
||||
{
|
||||
try {
|
||||
return \app\adminapi\service\LocalAudioTranscriptRecovery::save($params);
|
||||
} catch (\DomainException $e) {
|
||||
self::setError($e->getMessage());
|
||||
return false;
|
||||
} catch (\Throwable $e) {
|
||||
self::setError('录音补转写保存失败,请稍后重试');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 完成实时转写并将最终文字固化在通话记录上
|
||||
* @return array|false
|
||||
@@ -2068,10 +2082,9 @@ class DiagnosisLogic extends BaseLogic
|
||||
$callRecordId = (int)$record['id'];
|
||||
$snapshot = self::buildCallTranscriptSnapshot($callRecordId, $sessionId);
|
||||
$actualCount = (int)$snapshot['segment_count'];
|
||||
$finalStatus = $requestedStatus;
|
||||
if ($requestedStatus === 'completed' && $actualCount < $expectedCount) {
|
||||
$finalStatus = 'partial';
|
||||
}
|
||||
$finalStatus = \app\adminapi\service\LocalAudioTranscriptRecovery::completionStatus(
|
||||
$requestedStatus, $actualCount, $expectedCount, $snapshot['transcript_text']
|
||||
);
|
||||
$now = time();
|
||||
$record->save([
|
||||
'transcription_status' => $finalStatus,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\logic\tcm;
|
||||
|
||||
use app\adminapi\logic\firstvisit\MyPatientLogic;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use DomainException;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
/** Metadata only. This service never accepts, reads or uploads a recording or transcript. */
|
||||
final class MobileRecordingLogic
|
||||
{
|
||||
public const PAGE_PERMISSION = 'firstvisit.myPatient/lists';
|
||||
|
||||
/** Re-read the actor and grants; token role/root snapshots are not authorization. */
|
||||
public static function actor(int $adminId, bool $lock = false): array
|
||||
{
|
||||
$row = Db::name('admin')->where('id', $adminId)->whereNull('delete_time')
|
||||
->where('disable', 0)->lock($lock)->find();
|
||||
if ($adminId <= 0 || !$row) {
|
||||
throw new DomainException('账号已停用或登录状态失效');
|
||||
}
|
||||
$roles = Db::name('admin_role')->where('admin_id', $adminId)->column('role_id');
|
||||
$info = ['admin_id' => $adminId, 'id' => $adminId, 'root' => (int) $row['root'], 'role_id' => $roles];
|
||||
if ($info['root'] !== 1) {
|
||||
$menuIds = $roles ? Db::name('system_role_menu')->whereIn('role_id', $roles)->column('menu_id') : [];
|
||||
$allowed = $menuIds && Db::name('system_menu')->whereIn('id', $menuIds)
|
||||
->where('is_disable', 0)->where('perms', self::PAGE_PERMISSION)->count() > 0;
|
||||
if (!$allowed) {
|
||||
throw new DomainException('权限不足,无法访问我的患者录音关联');
|
||||
}
|
||||
}
|
||||
return $info;
|
||||
}
|
||||
|
||||
public static function bind(array $params, int $adminId): array
|
||||
{
|
||||
$p = self::bindingInput($params);
|
||||
return Db::transaction(static function () use ($p, $adminId): 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();
|
||||
$ids = array_unique([$p['diagnosis_id'], (int) ($existing['diagnosis_id'] ?? $p['diagnosis_id'])]);
|
||||
sort($ids);
|
||||
$patients = [];
|
||||
foreach ($ids as $id) {
|
||||
$patients[$id] = self::diagnosis($id, $adminId, $info, true);
|
||||
}
|
||||
$now = time();
|
||||
if ($existing) {
|
||||
$id = (int) $existing['id'];
|
||||
self::ownedQuery($adminId, $p)->where('id', $id)->update($p + ['updated_at' => $now]);
|
||||
$row = array_merge($existing, $p, ['updated_at' => $now]);
|
||||
} else {
|
||||
$row = $p + ['admin_id' => $adminId, 'created_at' => $now, 'updated_at' => $now];
|
||||
$row['id'] = (int) Db::name('mobile_recording_binding')->insertGetId($row);
|
||||
}
|
||||
return ['binding' => self::deviceRow($row, $patients[$p['diagnosis_id']])];
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
$info = self::actor($adminId, true);
|
||||
$row = self::ownedQuery($adminId, $identity)->lock(true)->find();
|
||||
if ($row) {
|
||||
self::diagnosis((int) $row['diagnosis_id'], $adminId, $info, true);
|
||||
self::ownedQuery($adminId, $identity)->where('id', (int) $row['id'])->delete();
|
||||
}
|
||||
return ['unbound' => true];
|
||||
});
|
||||
}
|
||||
|
||||
public static function deviceLists(array $params, int $adminId): array
|
||||
{
|
||||
self::only($params, ['installation_id', 'page_no', 'page_size']);
|
||||
$installationId = self::installation($params['installation_id'] ?? null);
|
||||
[$page, $size] = self::pagination($params, 50);
|
||||
$info = self::actor($adminId);
|
||||
$query = Db::name('mobile_recording_binding')->alias('b')
|
||||
->join([(new Diagnosis())->getTable() => 'd'], 'd.id = b.diagnosis_id')
|
||||
->where('b.admin_id', $adminId)->where('b.installation_id', $installationId)
|
||||
->where('d.status', 1)->whereNull('d.delete_time');
|
||||
MyPatientLogic::applyScope($query, $adminId, $info);
|
||||
$count = (int) (clone $query)->count('b.id');
|
||||
$rows = $query->field('b.*,d.patient_id AS source_patient_id,d.patient_name,d.phone AS patient_phone')
|
||||
->order('b.id', 'desc')->page($page, $size)->select()->toArray();
|
||||
$items = array_map(static fn (array $row): array => self::deviceRow($row, [
|
||||
'id' => $row['diagnosis_id'], 'patient_id' => $row['source_patient_id'],
|
||||
'patient_name' => $row['patient_name'], 'phone' => $row['patient_phone'],
|
||||
]), $rows);
|
||||
return ['lists' => $items, 'count' => $count, 'page_no' => $page, 'page_size' => $size];
|
||||
}
|
||||
|
||||
public static function patientLists(array $params, int $adminId): array
|
||||
{
|
||||
self::only($params, ['diagnosis_id', 'page_no', 'page_size']);
|
||||
$diagnosisId = self::integer($params['diagnosis_id'] ?? null, 1, 4294967295, '诊单标识');
|
||||
[$page, $size] = self::pagination($params, 20);
|
||||
$info = self::actor($adminId);
|
||||
self::diagnosis($diagnosisId, $adminId, $info);
|
||||
$query = Db::name('mobile_recording_binding')->alias('b')->where('b.diagnosis_id', $diagnosisId);
|
||||
$count = (int) (clone $query)->count();
|
||||
$adminTable = Db::name('admin')->getTable();
|
||||
$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 {
|
||||
$dto = self::metadata($row);
|
||||
$dto['bound_by'] = trim((string) ($row['bound_by'] ?? '')) ?: '已停用账号';
|
||||
$dto['playback_location'] = 'original_device';
|
||||
return $dto;
|
||||
}, $rows);
|
||||
return ['lists' => $items, 'count' => $count, 'page_no' => $page, 'page_size' => $size];
|
||||
}
|
||||
|
||||
/** Public for the controller and executable input-contract tests. */
|
||||
public static function bindingInput(array $params): array
|
||||
{
|
||||
self::only($params, ['installation_id', 'recording_key', 'diagnosis_id', 'source_platform', 'recorded_at', 'duration_ms']);
|
||||
if (($params['source_platform'] ?? null) !== 'android_bcr') {
|
||||
throw new DomainException('录音来源无效');
|
||||
}
|
||||
return self::identity($params) + [
|
||||
'diagnosis_id' => self::integer($params['diagnosis_id'] ?? null, 1, 4294967295, '诊单标识'),
|
||||
'source_platform' => 'android_bcr',
|
||||
'recorded_at' => self::integer($params['recorded_at'] ?? null, 0, (time() + 300) * 1000, '录音时间'),
|
||||
'duration_ms' => self::integer($params['duration_ms'] ?? null, 0, 604800000, '录音时长'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function ownedQuery(int $adminId, array $identity): Query
|
||||
{
|
||||
return Db::name('mobile_recording_binding')->where('admin_id', $adminId)
|
||||
->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
|
||||
{
|
||||
$row = Db::name('tcm_diagnosis')->where('id', $id)->whereNull('delete_time')->where('status', 1)
|
||||
->lock($lock)->find();
|
||||
if (!$row || !MyPatientLogic::canAccessDiagnosis($id, $adminId, $info)) {
|
||||
throw new DomainException('患者不存在或无权操作');
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
private static function deviceRow(array $row, array $patient): array
|
||||
{
|
||||
return self::metadata($row) + [
|
||||
'installation_id' => (string) $row['installation_id'],
|
||||
'recording_key' => (string) $row['recording_key'],
|
||||
'patient' => [
|
||||
'diagnosis_id' => (int) $patient['id'],
|
||||
'source_patient_id' => (int) ($patient['patient_id'] ?? 0),
|
||||
'patient_name' => (string) ($patient['patient_name'] ?? ''),
|
||||
'phone_masked' => self::maskPhone((string) ($patient['phone'] ?? '')),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function metadata(array $row): array
|
||||
{
|
||||
$result = ['source_platform' => (string) $row['source_platform']];
|
||||
foreach (['id', 'diagnosis_id', 'recorded_at', 'duration_ms', 'created_at', 'updated_at'] as $field) {
|
||||
$result[$field] = (int) $row[$field];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function maskPhone(string $phone): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $phone) ?? '';
|
||||
$length = strlen($digits);
|
||||
if ($length <= 4) {
|
||||
return str_repeat('*', $length);
|
||||
}
|
||||
if ($length < 8) {
|
||||
return substr($digits, 0, 1) . str_repeat('*', $length - 2) . substr($digits, -1);
|
||||
}
|
||||
return substr($digits, 0, 3) . str_repeat('*', $length - 7) . substr($digits, -4);
|
||||
}
|
||||
|
||||
private static function identity(array $params): array
|
||||
{
|
||||
$key = $params['recording_key'] ?? null;
|
||||
if (!is_string($key) || !preg_match('/^[0-9a-f]{64}$/D', $key)) {
|
||||
throw new DomainException('录音标识无效');
|
||||
}
|
||||
return ['installation_id' => self::installation($params['installation_id'] ?? null), 'recording_key' => $key];
|
||||
}
|
||||
|
||||
private static function installation($value): string
|
||||
{
|
||||
if (!is_string($value) || !preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/D', $value)) {
|
||||
throw new DomainException('设备标识无效');
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function pagination(array $params, int $defaultSize): array
|
||||
{
|
||||
return [
|
||||
self::integer($params['page_no'] ?? 1, 1, 1000000, '页码'),
|
||||
self::integer($params['page_size'] ?? $defaultSize, 1, 100, '每页数量'),
|
||||
];
|
||||
}
|
||||
|
||||
private 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) {
|
||||
throw new DomainException($label . '无效');
|
||||
}
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private static function only(array $params, array $allowed): void
|
||||
{
|
||||
if (array_diff(array_keys($params), $allowed) !== []) {
|
||||
throw new DomainException('请求包含不支持的字段');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\service;
|
||||
|
||||
use app\common\model\tcm\CallRecord;
|
||||
use DomainException;
|
||||
use think\facade\Db;
|
||||
|
||||
/** Persist a locally recovered transcript without reopening the realtime session. */
|
||||
final class LocalAudioTranscriptRecovery
|
||||
{
|
||||
public static function save(array $params): array
|
||||
{
|
||||
foreach (['diagnosis_id', 'call_record_id', 'admin_id'] as $key) {
|
||||
$value = $params[$key] ?? null;
|
||||
if ((!is_int($value) && !is_string($value))
|
||||
|| filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]) === false) {
|
||||
throw new DomainException('录音补转写身份参数无效');
|
||||
}
|
||||
}
|
||||
foreach (['recovery_id', 'expected_session_id', 'expected_status', 'audio_url', 'transcript_text'] as $key) {
|
||||
if (!isset($params[$key]) || !is_string($params[$key])) {
|
||||
throw new DomainException('录音补转写参数无效');
|
||||
}
|
||||
}
|
||||
if (!preg_match('/\Aaudio-[a-f0-9]{32}\z/D', $params['recovery_id'])
|
||||
|| mb_strlen($params['expected_session_id']) > 128
|
||||
|| !in_array($params['expected_status'], ['', 'pending', 'running', 'partial', 'failed', 'completed'], true)
|
||||
|| trim($params['audio_url']) === '' || strlen($params['audio_url']) > 8192) {
|
||||
throw new DomainException('录音补转写会话参数无效');
|
||||
}
|
||||
$text = trim($params['transcript_text']);
|
||||
if ($text === '' || mb_strlen($params['transcript_text']) > 100000) {
|
||||
throw new DomainException('录音补转写文字必须为1到100000个字符');
|
||||
}
|
||||
|
||||
return Db::transaction(static function () use ($params, $text): array {
|
||||
// The realtime upsert/finish endpoints lock the same row, so a late
|
||||
// old-session write cannot run after this session switch succeeds.
|
||||
$record = CallRecord::where('id', (int) $params['call_record_id'])
|
||||
->where('diagnosis_id', (int) $params['diagnosis_id'])
|
||||
->where('caller_id', (int) $params['admin_id'])
|
||||
->where('caller_type', 'doctor')->lock(true)->find();
|
||||
if (!$record) { throw new DomainException('通话记录不存在或无权操作'); }
|
||||
if ((int) $record['status'] !== 2) { throw new DomainException('通话结束后才能补转写'); }
|
||||
$urls = json_decode((string) ($record['local_audio_urls'] ?? ''), true);
|
||||
if ((int) ($record['local_audio_status'] ?? 0) !== 2 || !is_array($urls)
|
||||
|| !in_array($params['audio_url'], $urls, true)) {
|
||||
throw new DomainException('录音尚未上传或不属于当前通话');
|
||||
}
|
||||
$result = ['call_record_id' => (int) $record['id'], 'transcription_status' => 'completed'];
|
||||
$session = (string) ($record['transcription_session_id'] ?? '');
|
||||
$status = (string) ($record['transcription_status'] ?? '');
|
||||
if ($status === 'completed' && trim((string) ($record['transcript_text'] ?? '')) !== '') {
|
||||
// Lost response retries succeed even with the original CAS values;
|
||||
// a different completed session always wins without being changed.
|
||||
return $session === $params['recovery_id']
|
||||
? $result + ['saved' => true]
|
||||
: $result + ['saved' => false, 'already_complete' => true];
|
||||
}
|
||||
if ($session !== $params['expected_session_id'] || $status !== $params['expected_status']) {
|
||||
throw new DomainException('通话转写状态已变化,请刷新后重试');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$segments = [];
|
||||
$lines = [];
|
||||
// Recognition supplied no diarization or alignment. Do not invent
|
||||
// doctor/patient attribution or timestamps; preserve source order by id.
|
||||
for ($offset = 0, $index = 0, $length = mb_strlen($text); $offset < $length; $offset += 4000, $index++) {
|
||||
$part = mb_substr($text, $offset, 4000);
|
||||
$segments[] = [
|
||||
'call_record_id' => (int) $record['id'],
|
||||
'transcription_session_id' => $params['recovery_id'],
|
||||
'segment_id' => $params['recovery_id'] . '-' . $index,
|
||||
'speaker_user_id' => '', 'speaker_role' => 'unknown', 'timestamp_ms' => 0,
|
||||
'text' => $part, 'create_time' => $now, 'update_time' => $now,
|
||||
];
|
||||
$lines[] = '未知说话人:' . $part;
|
||||
}
|
||||
// Keep all old-session segments for audit; the current session is the
|
||||
// only one consumed by transcript readers and clinical context builders.
|
||||
Db::name('tcm_call_transcript_segment')->insertAll($segments);
|
||||
$record->save([
|
||||
'transcription_session_id' => $params['recovery_id'],
|
||||
'transcription_status' => 'completed',
|
||||
'transcription_segment_count' => count($segments),
|
||||
'transcript_text' => implode("\n", $lines),
|
||||
'transcription_started_at' => $now, 'transcription_finished_at' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
return $result + ['saved' => true];
|
||||
});
|
||||
}
|
||||
|
||||
public static function completionStatus(string $requested, int $actual, int $expected, string $text): string
|
||||
{
|
||||
if ($requested !== 'completed') { return $requested; }
|
||||
if ($actual === 0 || trim($text) === '') { return 'failed'; }
|
||||
return $actual < $expected ? 'partial' : 'completed';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Metadata-only mobile recording links. Default prefix: zyt_.
|
||||
-- No audio, transcript, original filename, contact label or device filesystem path is stored.
|
||||
-- Permissions intentionally reuse firstvisit.myPatient/lists in BOTH middleware and controller.
|
||||
CREATE TABLE IF NOT EXISTS `zyt_mobile_recording_binding` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`admin_id` int unsigned NOT NULL,
|
||||
`installation_id` char(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'Random per-install UUID v4',
|
||||
`recording_key` char(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'SHA256 of opaque stable local identifier, not audio content',
|
||||
`diagnosis_id` int unsigned NOT NULL,
|
||||
`source_platform` varchar(24) NOT NULL DEFAULT 'android_bcr',
|
||||
`recorded_at` bigint unsigned NOT NULL COMMENT 'UTC Unix milliseconds; zero means unknown',
|
||||
`duration_ms` bigint unsigned NOT NULL DEFAULT 0,
|
||||
`created_at` int unsigned NOT NULL,
|
||||
`updated_at` int unsigned NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_actor_device_recording` (`admin_id`, `installation_id`, `recording_key`),
|
||||
KEY `idx_diagnosis_recorded` (`diagnosis_id`, `recorded_at`, `id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Phone-local recording metadata links';
|
||||
@@ -55,6 +55,11 @@ namespace app\adminapi\logic\tcm {
|
||||
self::$events[] = ['read', $params];
|
||||
return self::$records;
|
||||
}
|
||||
public static function saveLocalAudioTranscript(array $params): array
|
||||
{
|
||||
self::$events[] = ['recover', $params];
|
||||
return ['call_record_id' => 1102, 'transcription_status' => 'completed', 'saved' => true];
|
||||
}
|
||||
public static function getError(): string { return self::$error; }
|
||||
}
|
||||
}
|
||||
@@ -79,6 +84,7 @@ namespace {
|
||||
public object $controllerObject;
|
||||
public string $actionName = 'getCallRecords';
|
||||
public string $controllerName = 'tcm.Diagnosis';
|
||||
public bool $postRequest = false;
|
||||
public function __construct(public array $query = [], bool $root = false)
|
||||
{
|
||||
$this->adminInfo = ['admin_id' => 91, 'root' => $root ? 1 : 0,
|
||||
@@ -92,6 +98,8 @@ namespace {
|
||||
return $name === null ? $this->query : ($this->query[$name] ?? $default);
|
||||
}
|
||||
public function param(string $name, $default = null) { return $default; }
|
||||
public function post($name = '', $default = null, $filter = '') { return $this->query; }
|
||||
public function isPost(): bool { return $this->postRequest; }
|
||||
public function controller(): string { return $this->controllerName; }
|
||||
public function action(): string { return $this->actionName; }
|
||||
public function ip(): string { return '192.0.2.91'; }
|
||||
@@ -207,5 +215,45 @@ namespace {
|
||||
$empty = transcriptAuthorizationInvoke(['diagnosis_id' => 701], ['tcm.diagnosis/getCallRecords']);
|
||||
transcriptAuthorizationExpect($empty['code'] === 1 && $empty['data'] === [], 'authorized empty history retains the original array response');
|
||||
|
||||
$recoveryGrants = ['tcm.diagnosis/startCallTranscription', 'tcm.diagnosis/upsertCallTranscriptSegments',
|
||||
'tcm.diagnosis/finishCallTranscription'];
|
||||
foreach ([[], ['tcm.diagnosis/huifang'], ['tcm.diagnosis/edit'], ['tcm.diagnosis/video-call'],
|
||||
['tcm.diagnosis/saveLocalAudioTranscript'], [$recoveryGrants[0]], array_slice($recoveryGrants, 0, 2),
|
||||
[$recoveryGrants[0], $recoveryGrants[1], $recoveryGrants[2] . 'Other']] as $permissions) {
|
||||
$request = new ConsultationTranscriptRequest();
|
||||
$request->actionName = 'SaVeLoCaLaUdIoTrAnScRiPt';
|
||||
$GLOBALS['consultation_transcript_request'] = $request;
|
||||
AdminAuthCache::$permissions = $permissions;
|
||||
AdminAuthCache::$allUriReads = 0;
|
||||
$continued = false;
|
||||
$denied = (new AuthMiddleware())->handle($request, static function () use (&$continued): array {
|
||||
$continued = true; return ['code' => 1];
|
||||
});
|
||||
transcriptAuthorizationExpect($denied['code'] === 0 && !$continued, 'recovery requires all exact realtime grants');
|
||||
transcriptAuthorizationExpect(AdminAuthCache::$allUriReads === 0, 'recovery never uses unregistered route bypass');
|
||||
}
|
||||
foreach ([false, true] as $root) {
|
||||
$payload = ['diagnosis_id' => 701, 'call_record_id' => 1102, 'admin_id' => 9999,
|
||||
'recovery_id' => 'audio-' . str_repeat('a', 32), 'expected_session_id' => 'session',
|
||||
'expected_status' => 'partial', 'audio_url' => 'https://audio.invalid/test.webm',
|
||||
'transcript_text' => "原文 <test> &\n结尾"];
|
||||
$request = new ConsultationTranscriptRequest($payload, $root);
|
||||
$request->actionName = 'saveLocalAudioTranscript';
|
||||
$request->postRequest = true;
|
||||
$GLOBALS['consultation_transcript_request'] = $request;
|
||||
$controller = new ConsultationTranscriptController($request);
|
||||
$request->controllerObject = $controller;
|
||||
AdminAuthCache::$permissions = $root ? [] : array_map('strtoupper', $recoveryGrants);
|
||||
DiagnosisLogic::$events = [];
|
||||
$saved = (new AuthMiddleware())->handle($request, static fn () => $controller->saveLocalAudioTranscript());
|
||||
transcriptAuthorizationExpect($saved['code'] === 1 && $saved['data']['saved'] === true, 'granted actor can invoke recovery');
|
||||
$payload['admin_id'] = 91;
|
||||
transcriptAuthorizationExpect(DiagnosisLogic::$events === [['recover', $payload]], 'controller uses login actor and preserves transcript text');
|
||||
$request->postRequest = false;
|
||||
DiagnosisLogic::$events = [];
|
||||
transcriptAuthorizationExpect($controller->saveLocalAudioTranscript()['code'] === 0 && DiagnosisLogic::$events === [],
|
||||
'recovery rejects GET before persistence');
|
||||
}
|
||||
|
||||
echo "Consultation transcript authorization: {$assertions} assertions passed (no DB or network)\n";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Execute the production recovery and realtime finish logic against in-memory IO.
|
||||
namespace recovery_test {
|
||||
final class Record extends \ArrayObject
|
||||
{
|
||||
public function save(array $values): void
|
||||
{
|
||||
if (!\think\facade\Db::$transaction) { throw new \RuntimeException('Write outside transaction'); }
|
||||
if (\think\facade\Db::$failSave) { throw new \RuntimeException('Synthetic database failure'); }
|
||||
foreach ($values as $key => $value) { $this[$key] = $value; }
|
||||
}
|
||||
}
|
||||
|
||||
final class RecordQuery
|
||||
{
|
||||
private array $filters = [];
|
||||
private bool $locked = false;
|
||||
public function where(string $key, $value): self { $this->filters[$key] = $value; return $this; }
|
||||
public function lock(bool $locked): self { $this->locked = $locked; return $this; }
|
||||
public function find(): ?Record
|
||||
{
|
||||
if (!$this->locked || !\think\facade\Db::$transaction) { throw new \RuntimeException('Missing transaction row lock'); }
|
||||
foreach (\app\common\model\tcm\CallRecord::$rows as $row) {
|
||||
foreach ($this->filters as $key => $value) { if (($row[$key] ?? null) !== $value) { continue 2; } }
|
||||
return $row;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final class SegmentQuery
|
||||
{
|
||||
private array $filters = [];
|
||||
public function where(string $key, $value): self { $this->filters[$key] = $value; return $this; }
|
||||
public function order(string $order): self { return $this; }
|
||||
public function select(): self { return $this; }
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_values(array_filter(\think\facade\Db::$segments, function (array $row): bool {
|
||||
foreach ($this->filters as $key => $value) { if ($row[$key] !== $value) { return false; } }
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
public function insertAll(array $rows): void
|
||||
{
|
||||
if (!\think\facade\Db::$transaction) { throw new \RuntimeException('Segment write outside transaction'); }
|
||||
foreach ($rows as $row) {
|
||||
foreach (\think\facade\Db::$segments as $existing) {
|
||||
if ($row['call_record_id'] === $existing['call_record_id']
|
||||
&& $row['transcription_session_id'] === $existing['transcription_session_id']
|
||||
&& $row['segment_id'] === $existing['segment_id']) { throw new \RuntimeException('Duplicate segment'); }
|
||||
}
|
||||
\think\facade\Db::$segments[] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace app\common\model\tcm {
|
||||
final class CallRecord
|
||||
{
|
||||
public static array $rows = [];
|
||||
public static function where(string $key, $value): \recovery_test\RecordQuery
|
||||
{
|
||||
return (new \recovery_test\RecordQuery())->where($key, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace think\facade {
|
||||
final class Db
|
||||
{
|
||||
public static bool $transaction = false;
|
||||
public static bool $failSave = false;
|
||||
public static array $segments = [];
|
||||
public static function transaction(callable $callback)
|
||||
{
|
||||
$rows = array_map(static fn ($row) => clone $row, \app\common\model\tcm\CallRecord::$rows);
|
||||
$segments = self::$segments;
|
||||
self::$transaction = true;
|
||||
try { return $callback(); }
|
||||
catch (\Throwable $e) {
|
||||
\app\common\model\tcm\CallRecord::$rows = $rows;
|
||||
self::$segments = $segments;
|
||||
throw $e;
|
||||
} finally { self::$transaction = false; }
|
||||
}
|
||||
public static function name(string $name): \recovery_test\SegmentQuery
|
||||
{
|
||||
if ($name !== 'tcm_call_transcript_segment') { throw new \RuntimeException('Unexpected table'); }
|
||||
return new \recovery_test\SegmentQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic as Logic;
|
||||
use app\common\model\tcm\CallRecord;
|
||||
use think\facade\Db;
|
||||
|
||||
$assertions = 0;
|
||||
function check(bool $condition, string $message): void
|
||||
{
|
||||
global $assertions;
|
||||
if (!$condition) { throw new RuntimeException($message); }
|
||||
$assertions++;
|
||||
}
|
||||
function fixture(array $changes = []): array
|
||||
{
|
||||
CallRecord::$rows = [new recovery_test\Record(array_replace([
|
||||
'id' => 17, 'diagnosis_id' => 23, 'caller_id' => 91, 'caller_type' => 'doctor',
|
||||
'status' => 2, 'local_audio_status' => 2, 'local_audio_urls' => '["https://audio.invalid/synthetic.webm"]',
|
||||
'transcription_session_id' => 'original-session', 'transcription_status' => 'partial',
|
||||
'transcription_segment_count' => 1, 'transcript_text' => '未知说话人:片段',
|
||||
], $changes))];
|
||||
Db::$segments = [['call_record_id' => 17, 'transcription_session_id' => 'original-session',
|
||||
'segment_id' => 'old-1', 'text' => '片段', 'speaker_role' => 'unknown', 'timestamp_ms' => 10]];
|
||||
Db::$failSave = false;
|
||||
return ['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
|
||||
'recovery_id' => 'audio-' . str_repeat('a', 32), 'expected_session_id' => 'original-session',
|
||||
'expected_status' => 'partial', 'audio_url' => 'https://audio.invalid/synthetic.webm',
|
||||
'transcript_text' => "完整的合成录音。\n保留结尾及否定:没有服药。"];
|
||||
}
|
||||
function rejects(array $params, string $label): void
|
||||
{
|
||||
$before = serialize([CallRecord::$rows, Db::$segments]);
|
||||
check(Logic::saveLocalAudioTranscript($params) === false, $label . ' rejects');
|
||||
check(serialize([CallRecord::$rows, Db::$segments]) === $before, $label . ' does not mutate');
|
||||
}
|
||||
|
||||
$params = fixture();
|
||||
$result = Logic::saveLocalAudioTranscript($params);
|
||||
check($result === ['call_record_id' => 17, 'transcription_status' => 'completed', 'saved' => true], 'recovery saves');
|
||||
check(CallRecord::$rows[0]['transcription_session_id'] === $params['recovery_id'], 'new session is active');
|
||||
check(CallRecord::$rows[0]['transcript_text'] === '未知说话人:' . $params['transcript_text'], 'complete text retained without claiming a speaker');
|
||||
check(count(Db::$segments) === 2 && Db::$segments[0]['text'] === '片段', 'old session retained');
|
||||
check(Db::$segments[1]['speaker_role'] === 'unknown' && Db::$segments[1]['timestamp_ms'] === 0, 'no invented attribution or timing');
|
||||
$before = serialize([CallRecord::$rows, Db::$segments]);
|
||||
check(Logic::saveLocalAudioTranscript($params) === $result, 'lost response retry is idempotent despite stale CAS');
|
||||
check(serialize([CallRecord::$rows, Db::$segments]) === $before, 'retry writes no duplicate segments');
|
||||
$late = Logic::upsertCallTranscriptSegments(['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
|
||||
'transcription_session_id' => 'original-session', 'segments' => [['segment_id' => 'late', 'text' => '迟到分段']]]);
|
||||
check($late === false, 'old realtime writer cannot overwrite replacement session');
|
||||
$lateFinish = Logic::finishCallTranscription(['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
|
||||
'transcription_session_id' => 'original-session', 'status' => 'completed', 'expected_segment_count' => 1]);
|
||||
check($lateFinish === false && serialize([CallRecord::$rows, Db::$segments]) === $before, 'late finish cannot overwrite replacement');
|
||||
|
||||
$params = fixture(['transcription_status' => 'completed']);
|
||||
$before = serialize([CallRecord::$rows, Db::$segments]);
|
||||
check(Logic::saveLocalAudioTranscript($params) === ['call_record_id' => 17, 'transcription_status' => 'completed',
|
||||
'saved' => false, 'already_complete' => true], 'another complete session wins even with stale CAS');
|
||||
check(serialize([CallRecord::$rows, Db::$segments]) === $before, 'completed transcript never overwritten');
|
||||
foreach (['', 'pending', 'running', 'partial', 'failed', 'completed'] as $status) {
|
||||
$params = fixture(['transcription_status' => $status, 'transcript_text' => '']);
|
||||
$params['expected_status'] = $status;
|
||||
check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'recover empty/nonterminal session ' . $status);
|
||||
}
|
||||
$params = fixture(['transcription_session_id' => '', 'transcription_status' => '', 'transcript_text' => '']);
|
||||
$params['expected_session_id'] = $params['expected_status'] = '';
|
||||
check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'record without prior session can recover');
|
||||
|
||||
foreach (['expected_session_id' => 'changed-session', 'expected_status' => 'running',
|
||||
'audio_url' => 'https://audio.invalid/different.webm', 'admin_id' => 92, 'diagnosis_id' => 24,
|
||||
'call_record_id' => 18, 'recovery_id' => 'invalid', 'transcript_text' => ' '] as $key => $value) {
|
||||
$params = fixture(); $params[$key] = $value; rejects($params, 'invalid ' . $key);
|
||||
}
|
||||
foreach ([['status' => 1], ['status' => 3], ['caller_type' => 'patient'], ['local_audio_status' => 0],
|
||||
['local_audio_urls' => 'invalid'], ['local_audio_urls' => '[]']] as $changes) {
|
||||
rejects(fixture($changes), 'invalid record ' . json_encode($changes));
|
||||
}
|
||||
foreach ([null, [], false, 23] as $value) {
|
||||
$params = fixture(); $params['transcript_text'] = $value; rejects($params, 'non-string text');
|
||||
}
|
||||
$params = fixture(); $params['transcript_text'] = str_repeat('字', 100001); rejects($params, 'oversize text');
|
||||
$params = fixture(); $params['transcript_text'] = str_repeat('字', 100000);
|
||||
check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'Unicode limit counts characters');
|
||||
$newSegments = array_slice(Db::$segments, 1);
|
||||
check(count($newSegments) === 25 && implode('', array_column($newSegments, 'text')) === $params['transcript_text'],
|
||||
'bounded segments preserve all 100000 characters in order');
|
||||
check(max(array_map(static fn ($row) => mb_strlen($row['text']), $newSegments)) <= 4000, 'segment schema limit respected');
|
||||
|
||||
$params = fixture(); Db::$failSave = true;
|
||||
rejects($params, 'transaction rollback after segment insertion');
|
||||
check(!str_contains(Logic::getError(), 'Synthetic'), 'internal failure detail is not exposed');
|
||||
|
||||
fixture(['transcription_status' => 'running', 'transcript_text' => '', 'transcription_segment_count' => 0]);
|
||||
Db::$segments = [];
|
||||
$finish = ['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91,
|
||||
'transcription_session_id' => 'original-session', 'status' => 'completed', 'expected_segment_count' => 0];
|
||||
check(Logic::finishCallTranscription($finish)['status'] === 'failed', 'zero-segment completion is failed');
|
||||
fixture(['transcription_status' => 'running']);
|
||||
$finish['expected_segment_count'] = 2;
|
||||
check(Logic::finishCallTranscription($finish)['status'] === 'partial', 'missing requested segments stays partial');
|
||||
fixture(['transcription_status' => 'running']);
|
||||
$finish['expected_segment_count'] = 1;
|
||||
check(Logic::finishCallTranscription($finish)['status'] === 'completed', 'nonempty complete realtime session still completes');
|
||||
|
||||
echo "Local audio transcript recovery: {$assertions} assertions passed (no DB or network)\n";
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Disposable in-memory synthetic database only. No .env, services or production connection. */
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
|
||||
use app\adminapi\logic\tcm\MobileRecordingLogic as Logic;
|
||||
use think\Container;
|
||||
use think\facade\Db;
|
||||
|
||||
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
|
||||
fwrite(STDERR, "SQLite PDO is required; no production fallback is allowed.\n");
|
||||
exit(1);
|
||||
}
|
||||
new think\App(); // Do not initialize application services or load configuration.
|
||||
$manager = new think\DbManager();
|
||||
$manager->setConfig(['default' => 'sqlite', 'auto_timestamp' => false, 'datetime_format' => false,
|
||||
'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'zyt_']]]);
|
||||
Container::getInstance()->instance('think\DbManager', $manager);
|
||||
$config = new think\Config();
|
||||
$config->set(['data_scope' => ['enabled' => false]], 'project');
|
||||
Container::getInstance()->instance('config', $config);
|
||||
|
||||
foreach ([
|
||||
'CREATE TABLE zyt_admin (id INTEGER PRIMARY KEY, root INT, name TEXT, disable INT, delete_time INT)',
|
||||
'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 INTEGER PRIMARY KEY, perms TEXT, is_disable INT)',
|
||||
'CREATE TABLE zyt_tcm_diagnosis (id INTEGER PRIMARY KEY, patient_id INT, patient_name TEXT, phone TEXT, assistant_id INT, status INT, delete_time INT)',
|
||||
'CREATE TABLE zyt_doctor_appointment (id INTEGER PRIMARY KEY, patient_id INT, doctor_id INT, status INT)',
|
||||
'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))',
|
||||
] as $ddl) {
|
||||
Db::execute($ddl);
|
||||
}
|
||||
Db::name('admin')->insertAll([
|
||||
['id' => 1, 'root' => 1, 'name' => '合成管理员', 'disable' => 0],
|
||||
['id' => 11, 'root' => 0, 'name' => '合成助理甲', 'disable' => 0],
|
||||
['id' => 12, 'root' => 0, 'name' => '合成助理乙', 'disable' => 0],
|
||||
['id' => 13, 'root' => 0, 'name' => '合成医生', 'disable' => 0],
|
||||
['id' => 14, 'root' => 0, 'name' => '合成停用账号', 'disable' => 1],
|
||||
['id' => 15, 'root' => 0, 'name' => '合成无权限账号', '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], ['admin_id' => 14, 'role_id' => 2],
|
||||
]);
|
||||
Db::name('system_menu')->insert(['id' => 1, 'perms' => Logic::PAGE_PERMISSION, 'is_disable' => 0]);
|
||||
Db::name('system_role_menu')->insertAll([['role_id' => 1, 'menu_id' => 1], ['role_id' => 2, 'menu_id' => 1]]);
|
||||
Db::name('tcm_diagnosis')->insertAll([
|
||||
['id' => 101, 'patient_id' => 501, 'patient_name' => '合成患者甲', 'phone' => '13800000001', 'assistant_id' => 11, 'status' => 1, 'delete_time' => null],
|
||||
['id' => 102, 'patient_id' => 502, 'patient_name' => '合成患者乙', 'phone' => '13800000002', 'assistant_id' => 11, 'status' => 1, 'delete_time' => null],
|
||||
['id' => 103, 'patient_id' => 503, 'patient_name' => '合成患者丙', 'phone' => '13800000003', 'assistant_id' => 12, 'status' => 1, 'delete_time' => null],
|
||||
['id' => 104, 'patient_id' => 504, 'patient_name' => '合成停用患者', 'phone' => '', 'assistant_id' => 11, 'status' => 0, 'delete_time' => null],
|
||||
['id' => 105, 'patient_id' => 505, 'patient_name' => '合成已删患者', 'phone' => '', 'assistant_id' => 11, 'status' => 1, 'delete_time' => 1],
|
||||
['id' => 106, 'patient_id' => 506, 'patient_name' => '合成接诊患者', 'phone' => '', 'assistant_id' => 12, 'status' => 1, 'delete_time' => null],
|
||||
]);
|
||||
Db::name('doctor_appointment')->insert(['id' => 1, 'patient_id' => 106, 'doctor_id' => 13, 'status' => 3]);
|
||||
|
||||
$checks = 0;
|
||||
$expect = static function (bool $ok, string $why) use (&$checks): void {
|
||||
if (!$ok) { throw new RuntimeException($why); }
|
||||
$checks++;
|
||||
};
|
||||
$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);
|
||||
};
|
||||
$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']));
|
||||
foreach ([
|
||||
['admin_id' => 1], ['phone' => '13800000000'], ['file_name' => 'secret.wav'],
|
||||
['audio_path' => '/private/audio'], ['transcript_text' => 'content'],
|
||||
] as $extra) {
|
||||
$denied(fn () => Logic::bindingInput($input + $extra), 'unexpected private or actor field rejected');
|
||||
}
|
||||
foreach ([
|
||||
['installation_id' => 'not-a-uuid'], ['recording_key' => '../path'], ['diagnosis_id' => true],
|
||||
['duration_ms' => 1.5], ['duration_ms' => -1], ['duration_ms' => 604800001],
|
||||
['recorded_at' => (time() + 3600) * 1000], ['recorded_at' => '1e10'], ['source_platform' => 'other'],
|
||||
] as $invalid) {
|
||||
$denied(fn () => Logic::bindingInput(array_replace($input, $invalid)), 'invalid typed field rejected');
|
||||
}
|
||||
$denied(fn () => Logic::bind($input, 15), 'missing page permission');
|
||||
$denied(fn () => Logic::bind($input, 14), 'disabled actor');
|
||||
$denied(fn () => Logic::bind($input, 0), 'missing actor');
|
||||
$denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 11), 'other assistant patient');
|
||||
$denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 104]), 11), 'disabled patient');
|
||||
$denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 105]), 11), 'deleted patient');
|
||||
$created = Logic::bind($input, 11)['binding'];
|
||||
$again = Logic::bind($input, 11)['binding'];
|
||||
$expect($created['id'] === $again['id'] && Db::name('mobile_recording_binding')->count() === 1, 'repeat bind is idempotent');
|
||||
$expect($created['patient']['diagnosis_id'] === 101 && $created['patient']['source_patient_id'] === 501, 'separate diagnosis/source patient identities');
|
||||
$expect($created['patient']['phone_masked'] === '138****0001', 'masked phone only');
|
||||
$expect(!str_contains(json_encode($created), '13800000001') && !isset($created['admin_id']), 'private data and actor ID omitted');
|
||||
|
||||
$other = Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 12)['binding'];
|
||||
$expect($other['id'] !== $created['id'], 'unique key includes current actor');
|
||||
Logic::unbind($identity, 12);
|
||||
Logic::unbind($identity, 12);
|
||||
$expect(Db::name('mobile_recording_binding')->where('id', $created['id'])->count() === 1, 'another actor unbind cannot touch original row');
|
||||
$denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 11), 'replacement target is checked');
|
||||
$replaced = Logic::bind(array_replace($input, ['diagnosis_id' => 102]), 11)['binding'];
|
||||
$expect($replaced['id'] === $created['id'] && $replaced['diagnosis_id'] === 102, 'replacement updates existing identity');
|
||||
|
||||
$second = array_replace($input, ['recording_key' => str_repeat('b', 64)]);
|
||||
Logic::bind($second, 11);
|
||||
$device = ['installation_id' => $input['installation_id']];
|
||||
$page = Logic::deviceLists($device + ['page_no' => 1, 'page_size' => 1], 11);
|
||||
$expect($page['count'] === 2 && count($page['lists']) === 1 && $page['page_size'] === 1, 'bounded device pagination');
|
||||
$expect(Logic::deviceLists(['installation_id' => '22345678-1234-4123-8123-123456789abc'], 11)['count'] === 0, 'device isolation');
|
||||
$expect(Logic::deviceLists($device, 12)['count'] === 0, 'account isolation');
|
||||
$denied(fn () => Logic::patientLists(['diagnosis_id' => 102], 12), 'patient listing scope');
|
||||
$records = Logic::patientLists(['diagnosis_id' => 102], 11);
|
||||
$record = $records['lists'][0];
|
||||
$expect($record['bound_by'] === '合成助理甲' && $record['playback_location'] === 'original_device', 'archive playback and actor metadata');
|
||||
$expect(!isset($record['installation_id'], $record['recording_key']) && !isset($record['patient_name']), 'archive omits device keys and unnecessary patient names');
|
||||
$denied(fn () => Logic::deviceLists($device + ['page_size' => 101], 11), 'page limit');
|
||||
|
||||
$doctor = Logic::bind(array_replace($input, ['diagnosis_id' => 106]), 13);
|
||||
$expect($doctor['binding']['diagnosis_id'] === 106, 'doctor uses own effective appointment');
|
||||
$denied(fn () => Logic::bind($input, 13), 'doctor cannot use unrelated patient');
|
||||
Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 1);
|
||||
$expect(Logic::patientLists(['diagnosis_id' => 103], 1)['count'] === 1, 'root uses same metadata contract');
|
||||
|
||||
Db::name('tcm_diagnosis')->where('id', 102)->update(['assistant_id' => 12]);
|
||||
$denied(fn () => Logic::bind($input, 11), 'old patient checked on reassignment after scope loss');
|
||||
$denied(fn () => Logic::unbind($identity, 11), 'old patient checked on unlink after scope loss');
|
||||
$afterTransfer = Logic::deviceLists($device, 11);
|
||||
$expect($afterTransfer['count'] === 1 && !str_contains(json_encode($afterTransfer, JSON_UNESCAPED_UNICODE), '合成患者乙'), 'lost-scope row and patient name hidden');
|
||||
Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => 2]);
|
||||
$expect(Logic::deviceLists($device, 11)['count'] === 0, 'deleted diagnosis hidden on restore');
|
||||
$denied(fn () => Logic::patientLists(['diagnosis_id' => 101], 11), 'deleted patient archive denied');
|
||||
Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => null]);
|
||||
Logic::unbind(array_replace($identity, ['recording_key' => str_repeat('b', 64)]), 11);
|
||||
$expect(Logic::deviceLists($device, 11)['count'] === 0, 'authorized unbind removes association');
|
||||
Db::name('system_menu')->where('id', 1)->update(['is_disable' => 1]);
|
||||
$denied(fn () => Logic::deviceLists($device, 11), 'revoked page permission is re-read');
|
||||
Db::name('admin')->where('id', 1)->update(['root' => 0]);
|
||||
$denied(fn () => Logic::deviceLists($device, 1), 'revoked root is re-read');
|
||||
|
||||
// Guard the integration boundary which runtime service tests cannot exercise without an HTTP app.
|
||||
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/MobileRecordingController.php');
|
||||
$middleware = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
|
||||
$expect(str_contains($controller, 'MobileRecordingLogic::actor((int) $this->adminId)'), 'controller explicitly checks page grant');
|
||||
$expect(str_contains($controller, '$this->request->file()'), 'controller refuses binary upload');
|
||||
$expect(str_contains($middleware, "'tcm.mobilerecording/'")
|
||||
&& strpos($middleware, "'tcm.mobilerecording/'") < strpos($middleware, '$allUri ='), 'middleware guards before legacy unregistered-route bypass');
|
||||
echo "Mobile recording binding: {$checks} checks passed (synthetic in-memory SQLite).\n";
|
||||
Reference in New Issue
Block a user