Files
zyt/app/src/doctor_workstation/video/window.py
T
2026-08-27 14:23:23 +08:00

1362 lines
54 KiB
Python

"""Hardened QtWebEngine host for the video companion.
Browser launch is intentionally disabled until the backend provides a
single-use handoff ticket. PySide6 remains optional at import time, while an
actual call requires an isolated QtWebEngine profile and an active QApplication.
"""
from __future__ import annotations
import base64
import binascii
import json
import logging
import os
import re
import sqlite3
import sys
from collections.abc import Callable, Mapping
from concurrent.futures import Future
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
from ..services.local_audio_queue import (
LocalAudioQueueStore,
LocalAudioUploadManager,
get_local_audio_upload_manager,
)
from .launcher import (
VideoCallRequest,
VideoTicketError,
require_supported_backend,
)
from .lifecycle import OrderedCallLifecycle
from .security import TrustedDocumentError, TrustedDocumentPolicy
try: # Optional by design: core-only builds must still import this module.
from PySide6.QtCore import QObject, Qt, QTimer, QUrl, Signal, Slot
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import (
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QApplication, QMainWindow
except (ImportError, OSError) as _qt_import_error: # pragma: no cover - no Qt runtime.
QObject = QTimer = Qt = QUrl = Signal = Slot = None # type: ignore[assignment]
QWebChannel = QWebEnginePage = QWebEngineProfile = None # type: ignore[assignment]
QWebEngineSettings = QWebEngineView = None # type: ignore[assignment]
QApplication = QMainWindow = None # type: ignore[assignment]
_WEBENGINE_IMPORT_ERROR: Exception | None = _qt_import_error
else: # pragma: no cover - requires a GUI runtime.
_WEBENGINE_IMPORT_ERROR = None
WEBENGINE_AVAILABLE = _WEBENGINE_IMPORT_ERROR is None
_LOGGER = logging.getLogger(__name__)
_SENSITIVE_QUERY_KEYS = {"usersig", "sdksecret", "sdksecretkey", "secretkey"}
class VideoWindowError(RuntimeError):
"""Raised when the trusted embedded companion cannot be opened."""
@dataclass(frozen=True, slots=True)
class CompanionLocation:
url: str
is_local: bool
@dataclass(slots=True)
class _LocalAudioCapture:
record_id: int
session_id: str
mime_type: str
path: Path
handle: Any
lifecycle: OrderedCallLifecycle
next_sequence: int = 0
bytes_written: int = 0
def _validate_remote_url(value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme.lower() != "https" or not parsed.hostname:
raise VideoWindowError("remote video companion URL must use HTTPS")
if parsed.username or parsed.password:
raise VideoWindowError("remote video companion URL must not contain credentials")
url_parameter_keys = {
"".join(character for character in key.lower() if character.isalnum())
for key, _ in (*parse_qsl(parsed.query), *parse_qsl(parsed.fragment))
}
if url_parameter_keys & _SENSITIVE_QUERY_KEYS:
raise VideoWindowError("remote video companion URL must not contain RTC credentials")
return value
def _candidate_index(local_dist: str | Path) -> Path:
candidate = Path(local_dist).expanduser().resolve()
return candidate if candidate.name.lower() == "index.html" else candidate / "index.html"
def _default_local_indexes() -> tuple[Path, ...]:
candidates: list[Path] = []
bundle_root = getattr(sys, "_MEIPASS", None)
if bundle_root:
candidates.append(Path(bundle_root) / "video_companion_dist" / "index.html")
project_root = Path(__file__).resolve().parents[3]
candidates.append(project_root / "video_companion" / "dist" / "index.html")
return tuple(candidates)
def resolve_companion_location(
*,
local_dist: str | Path | None = None,
remote_url: str | None = None,
) -> CompanionLocation:
"""Resolve the trusted document used inside QtWebEngine."""
indexes = (
(_candidate_index(local_dist),) if local_dist is not None else _default_local_indexes()
)
for index in indexes:
if index.is_file():
return CompanionLocation(index.as_uri(), is_local=True)
if remote_url:
return CompanionLocation(_validate_remote_url(remote_url), is_local=False)
raise VideoWindowError(
"video companion is unavailable: build video_companion/dist or configure an HTTPS URL"
)
def webengine_unavailable_reason() -> str | None:
"""Return a non-sensitive diagnostic reason without importing Qt again."""
if _WEBENGINE_IMPORT_ERROR is None:
return None
return f"{type(_WEBENGINE_IMPORT_ERROR).__name__}: QtWebEngine is not installed"
if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration test.
class _RestrictedWebEnginePage(QWebEnginePage): # type: ignore[misc, valid-type]
def __init__(
self,
profile: Any,
policy: TrustedDocumentPolicy,
logger: logging.Logger,
parent: Any,
) -> None:
super().__init__(profile, parent)
self._policy = policy
self._logger = logger
self._shutting_down = False
def begin_shutdown(self) -> None:
self._shutting_down = True
def acceptNavigationRequest(
self,
url: Any,
navigation_type: Any,
is_main_frame: bool,
) -> bool:
del navigation_type
if not is_main_frame:
return True
rendered = url.toString()
if self._shutting_down and rendered == "about:blank":
return True
if self._policy.allows_main_document(rendered):
return True
self._logger.warning(
"blocked video companion main-document navigation",
extra={
"target_scheme": url.scheme(),
"target_host": url.host(),
},
)
return False
def createWindow(self, window_type: Any) -> Any:
del window_type
self._logger.warning("blocked video companion popup window")
return None
class _QtVideoBridge(QObject): # type: ignore[misc, valid-type]
def __init__(self, callback: Callable[[Mapping[str, Any]], None]) -> None:
super().__init__()
self._callback = callback
@Slot(str) # type: ignore[misc]
def notify(self, payload: str) -> None:
if not isinstance(payload, str) or len(payload) > 16_384:
return
try:
message = json.loads(payload)
except (TypeError, ValueError):
return
if isinstance(message, Mapping) and message.get("source") == "doctor-call":
self._callback(message)
@Slot(str) # type: ignore[misc]
def saveScreenshot(self, data_url: str) -> None: # noqa: N802 - Qt bridge API
"""Forward one bounded JPEG data URL to the trusted desktop host."""
if not isinstance(data_url, str) or len(data_url) > 14 * 1024 * 1024:
self._callback(
{
"source": "doctor-call",
"event": "screenshot-invalid",
"message": "截屏图片过大,无法保存。",
}
)
return
self._callback(
{
"source": "doctor-call",
"event": "screenshot",
"dataUrl": data_url,
}
)
@Slot(str, str) # type: ignore[misc]
def startLocalAudioRecording( # noqa: N802 - Qt bridge API
self, session_id: str, mime_type: str
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-start",
"sessionId": session_id,
"mimeType": mime_type,
}
)
@Slot(str, int, str) # type: ignore[misc]
def appendLocalAudioChunk( # noqa: N802 - Qt bridge API
self, session_id: str, sequence: int, encoded: str
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-chunk",
"sessionId": session_id,
"sequence": sequence,
"data": encoded,
}
)
@Slot(str, int) # type: ignore[misc]
def finishLocalAudioRecording( # noqa: N802 - Qt bridge API
self, session_id: str, total_bytes: int
) -> None:
self._callback(
{
"source": "doctor-call",
"event": "local-audio-finish",
"sessionId": session_id,
"totalBytes": total_bytes,
}
)
@Slot(str) # type: ignore[misc]
def abortLocalAudioRecording(self, session_id: str) -> None: # noqa: N802
self._callback(
{
"source": "doctor-call",
"event": "local-audio-abort",
"sessionId": session_id,
}
)
class _EmbeddedVideoWindow(QMainWindow): # type: ignore[misc, valid-type]
status_changed = Signal(str) # type: ignore[misc]
call_ended = Signal(str) # type: ignore[misc]
call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc]
_room_completed = Signal(str, bool, str) # type: ignore[misc]
_screenshot_completed = Signal(bool, str) # type: ignore[misc]
_transcription_completed = Signal(str, str, str, bool, str) # type: ignore[misc]
_local_recording_completed = Signal( # type: ignore[misc]
str, str, int, bool, str
)
def __init__(
self,
request: VideoCallRequest,
location: CompanionLocation,
lifecycle: OrderedCallLifecycle,
*,
logger: logging.Logger,
lifecycle_factory: Callable[[], OrderedCallLifecycle],
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> None:
super().__init__()
self.request = request
self.location = location
self.lifecycle = lifecycle
self._lifecycle_factory = lifecycle_factory
self._lifecycles = [lifecycle]
self.logger = logger
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.patient_case = dict(patient_case or {})
self._on_open_diagnosis = on_open_diagnosis
try:
self._policy = TrustedDocumentPolicy.from_url(
location.url,
is_local=location.is_local,
)
except TrustedDocumentError as error:
raise VideoWindowError(str(error)) from error
self._injected = False
self._media_active = False
self._closing = False
self._companion_ended = False
self._released = False
self._close_reason = "window-closed"
self._call_cycle_closed = False
self._start_requested = False
self._shutdown_requested = False
self._local_audio_capture: _LocalAudioCapture | None = None
self._local_audio_store: LocalAudioQueueStore | None = None
self._local_audio_uploads: LocalAudioUploadManager | None = None
self._legacy_grants: list[tuple[Any, Any]] = []
self._permission_grants: list[Any] = []
self._shutdown_timer = QTimer(self)
self._shutdown_timer.setSingleShot(True)
self._shutdown_timer.timeout.connect(self._force_requested_shutdown)
self.setWindowTitle(
f"与 {self.patient_name} IM 问诊" if self.open_im else "视频面诊"
)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
self.resize(1120, 760)
self.setMinimumSize(760, 520)
self._profile = QWebEngineProfile(self)
if not self._profile.isOffTheRecord():
raise VideoWindowError("video WebEngine profile must be off-the-record")
profile_policy = QWebEngineProfile.PersistentCookiesPolicy
cache_type = QWebEngineProfile.HttpCacheType
self._profile.setPersistentCookiesPolicy(profile_policy.NoPersistentCookies)
self._profile.setHttpCacheType(cache_type.MemoryHttpCache)
self._profile.downloadRequested.connect(self._deny_download)
self.web_view = QWebEngineView(self)
self._page = _RestrictedWebEnginePage(
self._profile,
self._policy,
self.logger,
self.web_view,
)
self.web_view.setPage(self._page)
self.setCentralWidget(self.web_view)
self._configure_settings(self._page.settings())
self._bridge = _QtVideoBridge(self._handle_bridge_message)
self._channel = QWebChannel(self._page)
self._channel.registerObject("qtVideoBridge", self._bridge)
self._page.setWebChannel(self._channel)
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self._room_completed.connect(self._on_room_completed)
self._screenshot_completed.connect(self._on_screenshot_completed)
self._transcription_completed.connect(self._on_transcription_completed)
self._local_recording_completed.connect(self._on_local_recording_completed)
self.web_view.loadFinished.connect(self._on_load_finished)
self.web_view.setUrl(QUrl(self.location.url))
def _configure_settings(self, settings: Any) -> None:
attributes = getattr(QWebEngineSettings, "WebAttribute", QWebEngineSettings)
values = (
("LocalContentCanAccessRemoteUrls", self.location.is_local),
("PlaybackRequiresUserGesture", False),
("JavascriptCanOpenWindows", False),
("AllowRunningInsecureContent", False),
)
for name, enabled in values:
attribute = getattr(attributes, name, None)
if attribute is not None:
settings.setAttribute(attribute, enabled)
def _connect_permissions(self) -> None:
if hasattr(self._page, "featurePermissionRequested"):
self._page.featurePermissionRequested.connect(self._grant_legacy_media_permission)
if hasattr(self._page, "permissionRequested"):
self._page.permissionRequested.connect(self._grant_media_permission)
def _permission_context_is_trusted(self, origin: Any) -> bool:
if self._closing or self._released or not self._media_active:
return False
if not self._policy.allows_main_document(self._page.url().toString()):
return False
return self._policy.allows_origin(origin.toString())
def _grant_legacy_media_permission(self, origin: Any, feature: Any) -> None:
features = QWebEnginePage.Feature
allowed = {
features.MediaAudioCapture,
features.MediaVideoCapture,
features.MediaAudioVideoCapture,
}
policies = QWebEnginePage.PermissionPolicy
trusted = self._permission_context_is_trusted(origin) and feature in allowed
policy = (
policies.PermissionGrantedByUser if trusted else policies.PermissionDeniedByUser
)
self._page.setFeaturePermission(origin, feature, policy)
if trusted:
self._legacy_grants.append((origin, feature))
def _grant_media_permission(self, permission: Any) -> None:
permission_type = permission.permissionType()
allowed_names = {
"MediaAudioCapture",
"MediaVideoCapture",
"MediaAudioVideoCapture",
}
trusted = (
permission.isValid()
and permission_type.name in allowed_names
and self._permission_context_is_trusted(permission.origin())
)
if trusted:
permission.grant()
self._permission_grants.append(permission)
else:
permission.deny()
def _deny_download(self, download: Any) -> None:
download.cancel()
def _on_load_finished(self, succeeded: bool) -> None:
if self._closing:
return
if not succeeded:
self.logger.error(
"embedded video companion failed to load",
extra={"video_call": self.request.safe_log_context()},
)
self._close_reason = "page-load-failed"
self.close()
return
self._media_active = True
config = {
**self.request.to_web_config(),
"patientName": self.patient_name,
"patientCase": self.patient_case,
"mode": "chat" if self.open_im else "video",
}
config_json = json.dumps(config, ensure_ascii=True, separators=(",", ":"))
script = f"""
(() => {{
if (!window.doctorConsultation
|| typeof window.doctorConsultation.open !== 'function') {{
return false;
}}
void window.doctorConsultation.open({config_json}).catch(() => undefined);
return true;
}})()
"""
self._page.runJavaScript(script, self._after_injection)
def _notify_start_completed(self, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
except Exception:
succeeded = False
with suppress(RuntimeError):
self._start_completed.emit(succeeded)
def _on_lifecycle_started(self, succeeded: bool) -> None:
if self._closing:
return
if not succeeded:
self._start_requested = False
self._call_cycle_closed = True
self._page.runJavaScript(
"window.doctorConsultation?.hostCallReady?.(false, "
'"通话记录创建失败,请稍后重试。");'
)
return
self._page.runJavaScript(
"window.doctorConsultation?.hostCallReady?.(true, '');"
)
def _after_injection(self, result: Any) -> None:
self._injected = result is not False
if not self._injected:
self._close_reason = "bridge-api-missing"
self.close()
def _handle_bridge_message(self, message: Mapping[str, Any]) -> None:
if self._closing:
return
event = str(message.get("event", ""))
if event == "open-diagnosis-request":
if self._on_open_diagnosis is not None:
QTimer.singleShot(0, self._open_diagnosis_safely)
return
if event == "call-start-request":
self._start_call_cycle()
return
if event == "screenshot":
self._save_screenshot(str(message.get("dataUrl") or ""))
return
if event == "screenshot-invalid":
self._on_screenshot_completed(
False,
str(message.get("message") or "截屏图片无效。")[:200],
)
return
if event == "local-audio-start":
self._start_local_audio_recording(
str(message.get("sessionId") or ""),
str(message.get("mimeType") or "audio/webm"),
)
return
if event == "local-audio-chunk":
self._append_local_audio_chunk(
str(message.get("sessionId") or ""),
message.get("sequence"),
str(message.get("data") or ""),
)
return
if event == "local-audio-finish":
self._finish_local_audio_recording(
str(message.get("sessionId") or ""),
message.get("totalBytes"),
)
return
if event == "local-audio-abort":
self._abort_local_audio_recording(str(message.get("sessionId") or ""))
return
if event == "transcription-start-request":
self._start_transcription(
str(message.get("sessionId") or ""),
str(message.get("language") or "zh-CN"),
)
return
if event == "transcription-segment":
self._save_transcript_segment(message)
return
if event == "transcription-stop":
self._finish_transcription(
str(message.get("sessionId") or ""),
str(message.get("status") or "completed"),
)
return
room_id = message.get("roomId", message.get("room_id"))
if room_id not in (None, ""):
clean_room_id = str(room_id).strip()
future = self.lifecycle.bind_room(clean_room_id)
future.add_done_callback(
lambda completed, current_room_id=clean_room_id: (
self._notify_room_completed(current_room_id, completed)
)
)
if event == "room":
return
if event == "status":
status = str(message.get("status", "unknown"))[:80]
self.status_changed.emit(status)
elif event == "hangup":
status = str(message.get("status", "ended"))[:80]
self.call_ended.emit(status)
self.lifecycle.end(f"companion-{status}")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-hangup")
elif event == "error":
message_text = str(message.get("message", "视频通话错误"))[:400]
self.call_error.emit(message_text)
if self._start_requested:
self.lifecycle.end("companion-error")
self._call_cycle_closed = True
self._start_requested = False
if not self.open_im or self._shutdown_requested:
self._close_from_companion("companion-error")
def _open_diagnosis_safely(self) -> None:
if self._closing or self._on_open_diagnosis is None:
return
try:
self._on_open_diagnosis()
except Exception:
self.logger.exception(
"diagnosis drawer could not be opened from video companion",
extra={"video_call": self.request.safe_log_context()},
)
def _notify_room_completed(self, room_id: str, future: Future[bool]) -> None:
try:
succeeded = bool(future.result())
except Exception as error:
succeeded = False
message = str(error)[:200] or "腾讯云混流视频录制未启动。"
else:
message = (
"腾讯云混流视频已启动;本机录音将在结束后另行上传 COS。"
if succeeded
else "通话房间尚未绑定,云端视频和本机录音无法关联通话记录。"
)
with suppress(RuntimeError):
self._room_completed.emit(room_id, succeeded, message)
def _on_room_completed(
self,
room_id: str,
succeeded: bool,
message: str,
) -> None:
if self._closing:
return
room_payload = json.dumps(str(room_id)[:160], ensure_ascii=True)
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
"window.doctorConsultation?.roomBindingResult?.("
f"{room_payload}, {state}, {payload});"
)
def _emit_local_recording_result(
self,
operation: str,
session_id: str,
sequence: int,
succeeded: bool,
message: str,
) -> None:
with suppress(RuntimeError):
self._local_recording_completed.emit(
operation,
session_id,
sequence,
succeeded,
str(message)[:200],
)
def _on_local_recording_completed(
self,
operation: str,
session_id: str,
sequence: int,
succeeded: bool,
message: str,
) -> None:
if self._closing or self._released:
return
self._page.runJavaScript(
"window.doctorConsultation?.localRecordingResult?.("
f"{json.dumps(operation)}, {json.dumps(session_id)}, {sequence}, "
f"{'true' if succeeded else 'false'}, "
f"{json.dumps(str(message)[:200], ensure_ascii=True)});"
)
def _start_local_audio_recording(self, session_id: str, mime_type: str) -> None:
cleaned = str(session_id or "").strip()
clean_mime = str(mime_type or "audio/webm").strip().lower()[:120]
if not re.fullmatch(r"[A-Za-z0-9_-]{12,64}", cleaned):
self._emit_local_recording_result(
"start", cleaned, -1, False, "本地录音会话标识无效。"
)
return
if not clean_mime.startswith(("audio/webm", "audio/ogg")):
self._emit_local_recording_result(
"start", cleaned, -1, False, "当前浏览器录音格式不受支持。"
)
return
if self._local_audio_capture is not None:
existing = self._local_audio_capture.session_id == cleaned
self._emit_local_recording_result(
"start",
cleaned,
-1,
existing,
"本地录音已启动。" if existing else "已有另一条本地录音正在进行。",
)
return
try:
lifecycle = self.lifecycle
raw_call_record_id = lifecycle.call_record_id
call_record_id = (
int(raw_call_record_id) if raw_call_record_id not in (None, "") else None
)
store, uploads = get_local_audio_upload_manager(
lifecycle.repository,
self._local_audio_store,
)
self._local_audio_store = store
self._local_audio_uploads = uploads
record = store.begin_recording(
session_id=cleaned,
diagnosis_id=self.request.diagnosis_id,
mime_type=clean_mime,
call_record_id=call_record_id,
room_id=lifecycle.current_room_id or "",
)
# The handle intentionally remains open across WebChannel chunks.
handle = record.file_path.open("w+b")
except (OSError, RuntimeError, sqlite3.Error) as error:
self._emit_local_recording_result(
"start", cleaned, -1, False, str(error)[:200]
)
return
self._local_audio_capture = _LocalAudioCapture(
record_id=record.id,
session_id=cleaned,
mime_type=clean_mime,
path=record.file_path,
handle=handle,
lifecycle=lifecycle,
)
self._emit_local_recording_result(
"start", cleaned, -1, True, "本机语音录音已启动。"
)
def _append_local_audio_chunk(
self,
session_id: str,
sequence_value: Any,
encoded: str,
) -> None:
capture = self._local_audio_capture
try:
sequence = int(sequence_value)
except (TypeError, ValueError):
sequence = -1
if capture is None or session_id != capture.session_id:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音会话标识不匹配。"
)
return
if sequence != capture.next_sequence:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片顺序不连续。"
)
return
if not encoded or len(encoded) > 16_384:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片过大或为空。"
)
return
try:
content = base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error):
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片解析失败。"
)
return
if not content or len(content) > 12 * 1024:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音分片大小无效。"
)
return
if capture.bytes_written + len(content) > 512 * 1024 * 1024:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, "本地录音超过 512 MB 限制。"
)
self._abort_local_audio_recording(session_id)
return
try:
capture.handle.write(content)
except OSError as error:
self._emit_local_recording_result(
"chunk", session_id, sequence, False, str(error)[:200]
)
self._abort_local_audio_recording(session_id)
return
capture.bytes_written += len(content)
capture.next_sequence += 1
def _finish_local_audio_recording(
self, session_id: str, total_bytes_value: Any
) -> None:
capture = self._local_audio_capture
try:
total_bytes = int(total_bytes_value)
except (TypeError, ValueError):
total_bytes = -1
if capture is None or session_id != capture.session_id:
self._emit_local_recording_result(
"finish", session_id, -1, False, "本地录音会话标识不匹配。"
)
return
self._local_audio_capture = None
try:
capture.handle.flush()
os.fsync(capture.handle.fileno())
capture.handle.close()
except OSError as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
if total_bytes != capture.bytes_written or total_bytes <= 0:
self._mark_local_audio_invalid(
capture.record_id, "本地录音文件不完整。"
)
self._emit_local_recording_result(
"finish", session_id, -1, False, "本地录音文件不完整。"
)
return
if capture.bytes_written < 1024:
self._mark_local_audio_invalid(
capture.record_id, "本地录音文件为空或只有容器信息。"
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
False,
"本地录音文件为空或只有容器信息,已阻止上传。",
)
return
try:
with capture.path.open("rb") as recording:
signature = recording.read(4)
except OSError as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
valid_signature = (
capture.mime_type.startswith("audio/webm")
and signature == b"\x1aE\xdf\xa3"
) or (
capture.mime_type.startswith("audio/ogg") and signature == b"OggS"
)
if not valid_signature:
self._mark_local_audio_invalid(
capture.record_id, "本地录音格式校验失败。"
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
False,
"本地录音格式校验失败,已阻止上传无效文件。",
)
return
store = self._local_audio_store
uploads = self._local_audio_uploads
if store is None or uploads is None:
self._emit_local_recording_result(
"finish", session_id, -1, False, "本机录音队列尚未初始化。"
)
return
try:
store.finalize_recording(
capture.record_id,
size_bytes=capture.bytes_written,
)
except (OSError, RuntimeError, sqlite3.Error) as error:
self._mark_local_audio_invalid(capture.record_id, str(error))
self._emit_local_recording_result(
"finish", session_id, -1, False, str(error)[:200]
)
return
lifecycle = capture.lifecycle
def enqueue_upload(start_result: Future[bool] | None = None) -> None:
try:
if start_result is not None and not bool(start_result.result()):
raise RuntimeError("通话记录创建失败,录音已保存在本机,可稍后重试。")
raw_call_record_id = lifecycle.call_record_id
call_record_id = int(raw_call_record_id or 0)
if call_record_id <= 0:
raise RuntimeError("未取得通话记录编号,录音已保存在本机,可稍后重试。")
store.bind_identity(
capture.record_id,
call_record_id=call_record_id,
room_id=lifecycle.current_room_id or "",
)
uploads.submit(capture.record_id)
except Exception as error:
with suppress(Exception):
store.update_status(
capture.record_id,
"failed",
str(error)[:1000],
)
if lifecycle.call_record_id:
enqueue_upload()
else:
try:
lifecycle.start().add_done_callback(enqueue_upload)
except Exception as error:
store.update_status(
capture.record_id,
"failed",
str(error)[:1000] or "通话记录创建失败。",
)
self._emit_local_recording_result(
"finish",
session_id,
-1,
True,
"本地录音已保存,正在后台上传 COS。",
)
def _mark_local_audio_invalid(self, record_id: int, message: str) -> None:
store = self._local_audio_store
if store is None:
return
with suppress(Exception):
store.mark_invalid(record_id, message)
def _abort_local_audio_recording(self, session_id: str) -> None:
capture = self._local_audio_capture
if capture is None or (session_id and capture.session_id != session_id):
return
self._local_audio_capture = None
with suppress(OSError):
capture.handle.flush()
capture.handle.close()
self._mark_local_audio_invalid(
capture.record_id,
"本次本地录音未正常结束,文件已保留以便排查。",
)
def _start_call_cycle(self) -> None:
if self._closing or self._start_requested:
return
if self._call_cycle_closed:
self.lifecycle = self._lifecycle_factory()
self._lifecycles.append(self.lifecycle)
self._call_cycle_closed = False
self._start_requested = True
try:
future = self.lifecycle.start()
except Exception:
self._start_requested = False
self._on_lifecycle_started(False)
return
future.add_done_callback(self._notify_start_completed)
def _save_screenshot(self, data_url: str) -> None:
prefix = "data:image/jpeg;base64,"
if not data_url.startswith(prefix):
self._on_screenshot_completed(False, "截屏图片格式不正确。")
return
try:
content = base64.b64decode(data_url[len(prefix) :], validate=True)
except (ValueError, binascii.Error):
self._on_screenshot_completed(False, "截屏图片解析失败。")
return
try:
future = self.lifecycle.save_screenshot(
content,
f"callshot-{self.request.diagnosis_id}.jpg",
)
except Exception as error:
self._on_screenshot_completed(False, str(error)[:200])
return
future.add_done_callback(self._notify_screenshot_completed)
def _notify_screenshot_completed(self, future: Future[str]) -> None:
try:
future.result()
except Exception as error:
succeeded = False
message = str(error)[:200] or "截屏保存失败。"
else:
succeeded = True
message = "截屏已保存到患者信息。"
with suppress(RuntimeError):
self._screenshot_completed.emit(succeeded, message)
def _on_screenshot_completed(self, succeeded: bool, message: str) -> None:
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
f"window.doctorConsultation?.screenshotResult?.({state}, {payload});"
)
def _notify_transcription_completed(
self,
operation: str,
session_id: str,
segment_id: str,
future: Future[bool],
) -> None:
try:
succeeded = bool(future.result())
except Exception as error:
succeeded = False
message = str(error)[:200] or "录音文字保存失败。"
else:
message = {
"start": "录音文字存储已准备。",
"segment": "",
"stop": "本次面诊对话文字已保存。",
}.get(operation, "")
with suppress(RuntimeError):
self._transcription_completed.emit(
operation, session_id, segment_id, succeeded, message
)
def _on_transcription_completed(
self,
operation: str,
session_id: str,
segment_id: str,
succeeded: bool,
message: str,
) -> None:
payload = json.dumps(str(message)[:200], ensure_ascii=True)
state = "true" if succeeded else "false"
self._page.runJavaScript(
"window.doctorConsultation?.transcriptionResult?.("
f"{json.dumps(operation)}, {json.dumps(session_id)}, "
f"{json.dumps(segment_id)}, {state}, {payload});"
)
def _start_transcription(self, session_id: str, language: str) -> None:
try:
future = self.lifecycle.start_transcription(session_id, language=language)
except Exception as error:
self._on_transcription_completed(
"start", session_id, "", False, str(error)[:200]
)
return
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"start", session_id, "", completed
)
)
def _save_transcript_segment(self, message: Mapping[str, Any]) -> None:
session_id = str(message.get("sessionId") or "").strip()
segment = message.get("segment")
segment_id = (
str(segment.get("segment_id") or segment.get("segmentId") or "").strip()
if isinstance(segment, Mapping)
else ""
)
if not isinstance(segment, Mapping):
self._on_transcription_completed(
"segment", session_id, segment_id, False, "录音文字片段无效。"
)
return
if session_id != str(self.lifecycle.transcription_session_id or ""):
self._on_transcription_completed(
"segment", session_id, segment_id, False, "录音会话标识不匹配。"
)
return
try:
future = self.lifecycle.save_transcript_segment(segment)
except Exception as error:
self._on_transcription_completed(
"segment", session_id, segment_id, False, str(error)[:200]
)
return
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"segment", session_id, segment_id, completed
)
)
def _finish_transcription(self, session_id: str, status: str) -> None:
if session_id.strip() != str(self.lifecycle.transcription_session_id or ""):
self._on_transcription_completed(
"stop", session_id, "", False, "录音会话标识不匹配。"
)
return
try:
future = self.lifecycle.finish_transcription(status=status)
except Exception as error:
self._on_transcription_completed(
"stop", session_id, "", False, str(error)[:200]
)
return
future.add_done_callback(
lambda completed: self._notify_transcription_completed(
"stop", session_id, "", completed
)
)
def _close_from_companion(self, reason: str) -> None:
self._companion_ended = True
self._close_reason = reason
self.close()
def hangup(self) -> None:
self._request_companion_shutdown("desktop-hangup")
def _request_companion_shutdown(self, reason: str) -> None:
"""Let MediaRecorder finish and upload before WebEngine is destroyed."""
self._close_reason = reason
if self._closing or self._released:
return
should_wait_for_companion = (
self._injected
and self._start_requested
and not self._call_cycle_closed
and not self._companion_ended
)
if not should_wait_for_companion:
self.close()
return
if self._shutdown_requested:
return
self._shutdown_requested = True
# doctorConsultation.close() stops MediaRecorder, drains every queued
# WebChannel chunk, waits for the Qt/COS finish acknowledgement, and
# only then emits hangup. Keeping _closing false here is essential:
# bridge callbacks are deliberately rejected once final destruction
# begins.
self._page.runJavaScript(
"void window.doctorConsultation?.close?.().catch(() => undefined)"
)
self._shutdown_timer.start(190_000)
def _force_requested_shutdown(self) -> None:
"""Bound a failed companion shutdown without racing queued uploads."""
if not self._shutdown_requested or self._closing or self._released:
return
if self._start_requested and not self._call_cycle_closed:
# OrderedCallLifecycle places end after any upload that already
# reached the Qt bridge.
self.lifecycle.end(f"{self._close_reason}-timeout")
self._call_cycle_closed = True
self._start_requested = False
self._abort_local_audio_recording("")
self._companion_ended = True
self.close()
def _begin_shutdown(self) -> None:
if self._closing:
return
self._closing = True
self._media_active = False
self._shutdown_timer.stop()
if self._start_requested and not self._call_cycle_closed:
self.lifecycle.end(self._close_reason)
self._abort_local_audio_recording("")
self._release_webengine()
def wait_for_lifecycles(self, timeout: float) -> bool:
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
def _release_webengine(self) -> None:
if self._released:
return
self._released = True
policies = QWebEnginePage.PermissionPolicy
for origin, feature in self._legacy_grants:
with suppress(RuntimeError):
self._page.setFeaturePermission(
origin,
feature,
policies.PermissionDeniedByUser,
)
self._legacy_grants.clear()
for permission in self._permission_grants:
try:
if permission.isValid():
permission.reset()
except RuntimeError:
pass
self._permission_grants.clear()
try:
self._channel.deregisterObject(self._bridge)
self._page.setWebChannel(None)
except RuntimeError:
pass
try:
self._profile.cookieStore().deleteAllCookies()
self._profile.clearHttpCache()
self._profile.clearAllVisitedLinks()
except RuntimeError:
pass
try:
self._page.begin_shutdown()
self._page.setUrl(QUrl("about:blank"))
except RuntimeError:
pass
view = self.takeCentralWidget()
if view is not None:
view.close()
view.deleteLater()
self._page.deleteLater()
self._profile.deleteLater()
def closeEvent(self, event: Any) -> None:
if (
self._injected
and self._start_requested
and not self._call_cycle_closed
and not self._companion_ended
):
event.ignore()
self._request_companion_shutdown(self._close_reason)
return
self._begin_shutdown()
event.accept()
else:
_EmbeddedVideoWindow = None # type: ignore[assignment, misc]
class VideoCallWindow:
"""Facade for the only currently supported backend: embedded QtWebEngine."""
def __init__(
self,
request: VideoCallRequest,
*,
repository: Any,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> None:
del browser_opener # Reserved for a future authenticated handoff implementation.
try:
self.backend_mode = require_supported_backend(request.backend_mode)
except VideoTicketError as error:
raise VideoWindowError(str(error)) from error
if not WEBENGINE_AVAILABLE:
raise VideoWindowError(
"embedded video is unavailable and automatic browser fallback is disabled"
)
if QApplication is None or QApplication.instance() is None:
raise VideoWindowError("embedded video requires an active QApplication")
self.request = request
self.repository = repository
self.open_im = bool(open_im)
self.patient_name = str(patient_name or "患者").strip() or "患者"
self.patient_case = dict(patient_case or {})
self.on_open_diagnosis = on_open_diagnosis
self.logger = logger or _LOGGER
self.location = resolve_companion_location(
local_dist=local_dist,
remote_url=remote_url,
)
self.lifecycle = OrderedCallLifecycle(request, repository, self.logger)
self._lifecycles = [self.lifecycle]
self._session: Any = None
def _new_lifecycle(self) -> OrderedCallLifecycle:
lifecycle = OrderedCallLifecycle(self.request, self.repository, self.logger)
self._lifecycles.append(lifecycle)
return lifecycle
@property
def qt_window(self) -> Any:
return self._session
def open(self) -> VideoCallWindow:
try:
self._session = _EmbeddedVideoWindow(
self.request,
self.location,
self.lifecycle,
logger=self.logger,
lifecycle_factory=self._new_lifecycle,
open_im=self.open_im,
patient_name=self.patient_name,
patient_case=self.patient_case,
on_open_diagnosis=self.on_open_diagnosis,
)
except Exception:
self.lifecycle.end("window-open-failed")
raise
self._session.show()
self._session.raise_()
self._session.activateWindow()
return self
show = open
def hangup(self) -> None:
if self._session is not None:
self._session.hangup()
else:
self.lifecycle.end("unopened-session")
def close(self) -> None:
if self._session is not None:
self._session.close()
else:
self.lifecycle.end("unopened-session")
def wait_for_lifecycle(self, timeout: float = 0.25) -> bool:
"""Wait briefly for ordered backend writes; timeout is capped at five seconds."""
if self._session is not None:
return self._session.wait_for_lifecycles(timeout)
return all(lifecycle.wait(timeout) for lifecycle in self._lifecycles)
wait = wait_for_lifecycle
def open_video_call(
request: VideoCallRequest,
*,
repository: Any,
local_dist: str | Path | None = None,
remote_url: str | None = None,
logger: logging.Logger | None = None,
browser_opener: Callable[[str], bool] | None = None,
open_im: bool = False,
patient_name: str = "患者",
patient_case: Mapping[str, Any] | None = None,
on_open_diagnosis: Callable[[], None] | None = None,
) -> VideoCallWindow:
"""Create and immediately open a trusted embedded video window."""
if not isinstance(request, VideoCallRequest):
raise VideoTicketError("request must be a VideoCallRequest")
return VideoCallWindow(
request,
repository=repository,
local_dist=local_dist,
remote_url=remote_url,
logger=logger,
browser_opener=browser_opener,
open_im=open_im,
patient_name=patient_name,
patient_case=patient_case,
on_open_diagnosis=on_open_diagnosis,
).open()
__all__ = [
"CompanionLocation",
"TrustedDocumentPolicy",
"VideoCallWindow",
"VideoWindowError",
"WEBENGINE_AVAILABLE",
"open_video_call",
"resolve_companion_location",
"webengine_unavailable_reason",
]