This commit is contained in:
Your Name
2026-11-09 10:02:27 +08:00
parent 0c43d54c08
commit 8466acef9f
35 changed files with 3584 additions and 111 deletions
+28
View File
@@ -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>
+14
View File
@@ -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'
+19
View File
@@ -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。
## 验证范围
使用合成通话验证上传与文字状态分离、上传先于挂断、重复提交、保存失败后跨重启复用草稿、原文字保护、身份与音频绑定、服务端事务和迟到实时写入。使用仓库已有公开中文样本运行真实本地模型,验证期间禁网且未输出对话原文。未改写线上通话文字,线上修复效果仍需部署后验证。
+1 -1
View File
@@ -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":
+299
View File
@@ -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
+27 -8
View File
@@ -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)
+247
View File
@@ -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"]
+430
View File
@@ -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&amp;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)
+4 -3
View File
@@ -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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>
+19 -3
View File
@@ -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";
}
+157
View File
@@ -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";