This commit is contained in:
Your Name
2026-09-09 15:47:48 +08:00
parent bd5d5c5f08
commit cb10e75ead
98 changed files with 7031 additions and 804 deletions
+34 -1
View File
@@ -85,6 +85,18 @@ def _normalized_key(key: Any) -> str:
return "".join(character for character in str(key).lower() if character.isalnum())
def _appointment_id(value: Any) -> int:
if value is None:
return 0
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise VideoTicketError("appointment_id must be a nonnegative integer") from exc
if isinstance(value, bool) or parsed < 0 or str(value) != str(parsed):
raise VideoTicketError("appointment_id must be a nonnegative integer")
return parsed
_FORBIDDEN_SECRET_KEYS = {"sdksecret", "sdksecretkey", "secretkey"}
@@ -138,7 +150,8 @@ def _ticket_mapping(ticket: Any) -> Mapping[str, Any]:
if hasattr(ticket, attribute_name)
}
if adapted:
return adapted
# Preserve server policy and identities; model attributes alone omit them.
return {**(dict(raw) if isinstance(raw, Mapping) else {}), **adapted}
raise VideoTicketError("backend ticket must be a mapping or call-ticket object")
@@ -199,8 +212,15 @@ class VideoCallRequest:
patient_id: Identifier | None = None
call_record_id: Identifier | None = None
backend_mode: BackendMode = BackendMode.EMBEDDED
appointment_id: int = 0
appointment_type: Any = None
appointment_type_desc: str = ""
can_video_call: bool = False
can_audio_call: bool = False
call_disabled_reason: str = ""
def __post_init__(self) -> None:
object.__setattr__(self, "appointment_id", _appointment_id(self.appointment_id))
object.__setattr__(self, "sdk_app_id", _sdk_app_id(self.sdk_app_id))
object.__setattr__(self, "user_id", _non_empty_string(self.user_id, "userID"))
object.__setattr__(self, "user_sig", _non_empty_string(self.user_sig, "userSig"))
@@ -257,6 +277,13 @@ class VideoCallRequest:
"userSig": self.user_sig,
"targetUserId": self.target_user_id,
"diagnosisId": self.diagnosis_id,
"patientId": self.patient_id,
"appointmentId": self.appointment_id,
"appointment_type": self.appointment_type,
"appointment_type_desc": self.appointment_type_desc,
"can_video_call": self.can_video_call is True,
"can_audio_call": self.can_audio_call is True,
"call_disabled_reason": self.call_disabled_reason,
}
def safe_log_context(self) -> dict[str, Any]:
@@ -340,6 +367,12 @@ def normalize_backend_ticket(
diagnosis_id=normalized_diagnosis,
patient_id=normalized_patient,
call_record_id=payload_call_record,
appointment_id=_appointment_id(payload.get("appointment_id", 0)),
appointment_type=payload.get("appointment_type"),
appointment_type_desc=str(payload.get("appointment_type_desc") or ""),
can_video_call=payload.get("can_video_call") is True,
can_audio_call=payload.get("can_audio_call") is True,
call_disabled_reason=str(payload.get("call_disabled_reason") or ""),
backend_mode=BackendMode.parse(backend_mode),
)
+26 -3
View File
@@ -18,7 +18,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar
from .launcher import VideoCallRequest
from .launcher import VideoCallRequest, normalize_backend_ticket
ResultT = TypeVar("ResultT")
@@ -288,7 +288,29 @@ class OrderedCallLifecycle:
with self._lock:
return self.bound_room_id or self._claimed_room_id
def start(self) -> Future[bool]:
def refresh_call_policy(self) -> Future[dict[str, Any]]:
"""Fetch policy for this exact immutable consultation on the worker."""
def operation() -> dict[str, Any]:
ticket = self.repository.get_call_ticket(
patient_id=self.request.patient_id,
diagnosis_id=self.request.diagnosis_id,
appointment_id=self.request.appointment_id,
)
refreshed = normalize_backend_ticket(
ticket, patient_id=self.request.patient_id,
diagnosis_id=self.request.diagnosis_id,
)
if (refreshed.appointment_id != self.request.appointment_id
or refreshed.target_user_id != self.request.target_user_id
or refreshed.user_id != self.request.user_id
or refreshed.sdk_app_id != self.request.sdk_app_id):
raise ValueError("当前问诊已变更,请重新打开聊天窗口")
return refreshed.to_web_config()
return self._worker.submit("refresh_policy", operation)
def start(self, *, call_type: int = 2) -> Future[bool]:
if type(call_type) is not int or call_type not in (1, 2):
raise ValueError("call_type must be 1 or 2")
with self._lock:
if self._start_future is not None:
return self._start_future
@@ -297,7 +319,8 @@ class OrderedCallLifecycle:
raise ValueError("video repository does not implement start_call")
payload: dict[str, Any] = {
"diagnosis_id": self.request.diagnosis_id,
"call_type": 2,
"call_type": call_type,
"appointment_id": self.request.appointment_id,
}
if self.request.patient_id is not None:
payload["patient_id"] = self.request.patient_id
+44 -7
View File
@@ -279,6 +279,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
call_ended = Signal(str) # type: ignore[misc]
call_error = Signal(str) # type: ignore[misc]
_start_completed = Signal(bool) # type: ignore[misc]
_policy_completed = Signal(str, object) # 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]
@@ -369,6 +370,7 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
self._connect_permissions()
self._start_completed.connect(self._on_lifecycle_started)
self._policy_completed.connect(self._on_policy_completed)
self._room_completed.connect(self._on_room_completed)
self._screenshot_completed.connect(self._on_screenshot_completed)
self._transcription_completed.connect(self._on_transcription_completed)
@@ -509,7 +511,10 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
QTimer.singleShot(0, self._open_diagnosis_safely)
return
if event == "call-start-request":
self._start_call_cycle()
self._start_call_cycle(call_type=message.get("callType", 2))
return
if event == "call-policy-request":
self._refresh_call_policy(str(message.get("requestId") or ""))
return
if event == "screenshot":
self._save_screenshot(str(message.get("dataUrl") or ""))
@@ -934,16 +939,48 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
"本次本地录音未正常结束,文件已保留以便排查。",
)
def _start_call_cycle(self) -> None:
if self._closing or self._start_requested:
return
def _prepare_call_cycle(self) -> None:
if self._call_cycle_closed:
self.lifecycle = self._lifecycle_factory()
self._lifecycles.append(self.lifecycle)
self._call_cycle_closed = False
def _refresh_call_policy(self, request_id: str) -> None:
if not request_id or self._closing or self._shutdown_requested:
return
self._prepare_call_cycle()
def completed(future: Future[dict[str, Any]]) -> None:
try:
policy = future.result()
except Exception:
policy = {"call_disabled_reason": "无法确认本次挂号通话权限,请重新打开聊天窗口"}
with suppress(RuntimeError):
self._policy_completed.emit(request_id, policy)
try:
self.lifecycle.refresh_call_policy().add_done_callback(completed)
except Exception:
self._on_policy_completed(request_id, {})
def _on_policy_completed(self, request_id: str, policy: dict[str, Any]) -> None:
if self._closing or self._shutdown_requested or self._released:
return
# Send policy only; refreshed credentials never enter logs or notices.
payload = {key: policy.get(key) for key in (
"appointmentId", "appointment_type", "can_video_call", "can_audio_call",
"call_disabled_reason", "appointment_type_desc",
)}
self._page.runJavaScript(
"window.doctorConsultation?.callPolicyResult?.("
+ json.dumps(request_id) + "," + json.dumps(payload, ensure_ascii=True) + ");"
)
def _start_call_cycle(self, *, call_type: int = 2) -> None:
if self._closing or self._shutdown_requested or self._start_requested:
return
self._prepare_call_cycle()
self._start_requested = True
try:
future = self.lifecycle.start()
future = self.lifecycle.start(call_type=call_type)
except Exception:
self._start_requested = False
self._on_lifecycle_started(False)
@@ -1149,8 +1186,8 @@ if WEBENGINE_AVAILABLE: # pragma: no cover - GUI behavior needs an integration
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)
# Also retire policy-only workers for IM sessions without a live call.
self.lifecycle.end(self._close_reason)
self._abort_local_audio_recording("")
self._release_webengine()