diff --git a/app/src/doctor_workstation/services/cos_upload.py b/app/src/doctor_workstation/services/cos_upload.py
new file mode 100644
index 000000000..ed4850569
--- /dev/null
+++ b/app/src/doctor_workstation/services/cos_upload.py
@@ -0,0 +1,223 @@
+"""COS video uploads using short-lived server credentials and the official SDK."""
+
+from __future__ import annotations
+
+import logging
+import re
+import time
+from collections.abc import Callable, Mapping
+from contextlib import suppress
+from datetime import datetime
+from pathlib import Path
+from threading import RLock
+from typing import Any
+from urllib.parse import unquote, urlsplit
+from uuid import uuid4
+
+from qcloud_cos import CosConfig, CosS3Client
+
+from doctor_workstation.core.errors import ApiProtocolError, ApiTransportError
+from doctor_workstation.services.api_client import ApiClient
+
+_VIDEO_EXTENSIONS = {
+ "wmv", "avi", "mpg", "mpeg", "3gp", "mov", "mp4", "m4v", "flv", "f4v", "rmvb", "mkv", "webm"
+}
+_MAX_VIDEO_SIZE = 2 * 1024 * 1024 * 1024
+
+
+def _positive_int(value: Any, field: str) -> int:
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
+ raise ApiProtocolError(f"COS 响应的 {field} 无效")
+ try:
+ result = int(value)
+ except ValueError:
+ raise ApiProtocolError(f"COS 响应的 {field} 无效") from None
+ if result <= 0:
+ raise ApiProtocolError(f"COS 响应的 {field} 无效")
+ return result
+
+
+def _string(data: Mapping[str, Any], field: str) -> str:
+ value = data.get(field)
+ if not isinstance(value, str) or not value or value != value.strip():
+ raise ApiProtocolError(f"COS 响应缺少有效的 {field}")
+ if any(ord(character) < 32 or ord(character) == 127 for character in value):
+ raise ApiProtocolError(f"COS 响应的 {field} 无效")
+ return value
+
+
+def _upload_key(credentials: Mapping[str, Any], extension: str) -> str:
+ prefix = _string(credentials, "key_prefix")
+ if not re.fullmatch(r"uploads/video/[0-9]{8}/", prefix):
+ raise ApiProtocolError("COS 视频上传目录无效")
+ try:
+ datetime.strptime(prefix.split("/")[2], "%Y%m%d")
+ except ValueError:
+ raise ApiProtocolError("COS 视频上传目录日期无效") from None
+
+ fixed_key = credentials.get("object_key", "")
+ if not isinstance(fixed_key, str):
+ raise ApiProtocolError("COS 视频对象 Key 无效")
+ if not fixed_key:
+ return f"{prefix}{int(time.time() * 1000)}-{uuid4().hex}.{extension}"
+ suffix = fixed_key.removeprefix(prefix)
+ if (
+ not fixed_key.startswith(prefix)
+ or len(fixed_key) > 1024
+ or not all(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", part) for part in suffix.split("/"))
+ or Path(suffix).suffix.lower() != f".{extension}"
+ ):
+ raise ApiProtocolError("COS 视频对象 Key 不在授权范围内")
+ return fixed_key
+
+
+def _confirmed_result(result: Any, key: str) -> dict[str, Any]:
+ if not isinstance(result, Mapping):
+ raise ApiProtocolError("COS 上传确认响应无效")
+ _positive_int(result.get("id"), "id")
+ address = result.get("uri") or result.get("url")
+ if not isinstance(address, str) or not address or address != address.strip():
+ raise ApiProtocolError("COS 上传确认未返回有效的视频地址")
+ try:
+ parsed = urlsplit(address)
+ # Accessing port also validates malformed/non-numeric authority ports.
+ _ = parsed.port
+ valid = (
+ parsed.scheme in {"https", "http"}
+ and bool(parsed.hostname)
+ and parsed.username is None
+ and parsed.password is None
+ and not parsed.fragment
+ and not any(
+ character.isspace() or ord(character) < 32 or ord(character) == 127
+ for character in address
+ )
+ and unquote(parsed.path).endswith("/" + key)
+ )
+ except ValueError:
+ valid = False
+ if not valid:
+ raise ApiProtocolError("COS 上传确认返回的视频地址无效")
+ return dict(result)
+
+
+def upload_video_to_cos(
+ client: ApiClient,
+ path: Path,
+ *,
+ mime_type: str,
+ on_progress: Callable[[int, int], None] | None = None,
+ on_processing: Callable[[], None] | None = None,
+) -> dict[str, Any]:
+ """Upload and confirm one video; the caller still owns call-record binding.
+
+ Progress stays below the full file size, including after confirmation. Only
+ the caller can report completion once the recording is attached. SDK worker
+ threads may invoke progress callbacks; callback failures are ignored.
+ """
+
+ source = path.expanduser().resolve()
+ if not source.is_file():
+ raise ValueError("视频文件不存在")
+ size = source.stat().st_size
+ extension = source.suffix.lower().removeprefix(".")
+ if size <= 0 or size > _MAX_VIDEO_SIZE:
+ raise ValueError("视频文件大小须大于 0 且不超过 2 GB")
+ if extension not in _VIDEO_EXTENSIONS:
+ raise ValueError("不支持的视频文件格式")
+ if (
+ not isinstance(mime_type, str)
+ or not re.fullmatch(r"video/[A-Za-z0-9!#$&^_.+-]+(?:;[^\r\n]*)?", mime_type)
+ or len(mime_type) > 120
+ or any(ord(character) < 32 or ord(character) == 127 for character in mime_type)
+ ):
+ raise ValueError("视频 MIME 类型无效")
+
+ last_progress = -1
+ progress_lock = RLock()
+
+ def report(sent: int, _total: int = size) -> None:
+ nonlocal last_progress
+ with progress_lock, suppress(Exception):
+ confirmed = max(0, min(int(sent), size - 1))
+ if confirmed > last_progress:
+ last_progress = confirmed
+ if on_progress is not None:
+ on_progress(confirmed, size)
+
+ report(0)
+ response = client.post("upload/ossCredentials", {"type": "video", "name": source.name})
+ if not isinstance(response, Mapping):
+ raise ApiProtocolError("COS 上传凭证响应无效")
+ if response.get("provider") != "qcloud" or response.get("fallback") is not False:
+ raise ApiProtocolError("服务器未启用 COS 直传,无法上传视频回放")
+ bucket = _string(response, "bucket")
+ region = _string(response, "region")
+ if len(bucket) > 63 or not re.fullmatch(r"[a-z0-9][a-z0-9-]{1,49}-[0-9]{5,20}", bucket):
+ raise ApiProtocolError("COS 存储桶无效")
+ if not re.fullmatch(r"[a-z]{2}-[a-z][a-z0-9]*(?:-[a-z0-9]+)*", region):
+ raise ApiProtocolError("COS 存储区域无效")
+ if size > _positive_int(response.get("max_size"), "max_size"):
+ raise ValueError("视频大小超过服务器允许的 COS 上传上限")
+ start = _positive_int(response.get("start_time"), "start_time")
+ expires = _positive_int(response.get("expired_time"), "expired_time")
+ now = time.time()
+ if start >= expires or expires <= now or start > now + 60:
+ raise ApiProtocolError("COS 上传凭证已过期或尚未生效,请重试")
+ temporary = response.get("credentials")
+ if not isinstance(temporary, Mapping):
+ raise ApiProtocolError("COS 响应缺少临时凭证")
+ secret_id = _string(temporary, "tmpSecretId")
+ secret_key = _string(temporary, "tmpSecretKey")
+ token = _string(temporary, "sessionToken")
+ key = _upload_key(response, extension)
+
+ # SDK debug logs include signing keys and request headers; even error logs
+ # may echo provider payloads. Keep these out of the application's debug log.
+ # The SDK installs a NullHandler on this logger; report only our safe error.
+ logging.getLogger("qcloud_cos").propagate = False
+ try:
+ config = CosConfig(
+ Region=region,
+ SecretId=secret_id,
+ SecretKey=secret_key,
+ Token=token,
+ Scheme="https",
+ Timeout=120,
+ VerifySSL=True,
+ )
+ # Never use returned host/CDN values as an SDK endpoint, and never send
+ # the application's admin token to COS. The SDK derives the COS host.
+ CosS3Client(config).upload_file(
+ Bucket=bucket,
+ Key=key,
+ LocalFilePath=str(source),
+ PartSize=1,
+ MAXThread=3,
+ ContentType=mime_type,
+ progress_callback=report,
+ )
+ except Exception:
+ raise ApiTransportError(
+ "COS 视频上传失败,请检查网络后重试。",
+ data={"provider": "cos", "operation": "upload"},
+ ) from None
+
+ # Small-file put_object does not call the SDK's progress callback.
+ report(size - 1)
+ if on_processing is not None:
+ with suppress(Exception):
+ on_processing()
+ result = client.post(
+ "upload/ossConfirm",
+ {
+ "type": "video",
+ "key": key,
+ "name": source.name,
+ "size": size,
+ "content_type": mime_type,
+ "cid": 0,
+ },
+ timeout=120.0,
+ )
+ return _confirmed_result(result, key)
diff --git a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py
index a5a5e39f9..d97b109b5 100644
--- a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py
+++ b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py
@@ -612,6 +612,7 @@ class DiagnosisTableModel(QAbstractTableModel):
self._checked_ids: set[int] = set()
self._hover_row = -1
self._sort_direction = ""
+ self._assistant_names: dict[int, str] = {}
def rowCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802
return 0 if parent.isValid() else len(self.rows)
@@ -645,6 +646,9 @@ class DiagnosisTableModel(QAbstractTableModel):
if self.record_id(row) in self._checked_ids
else Qt.CheckState.Unchecked
)
+ if column == 7 and role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole):
+ name = self.assistant_name(row)
+ return name or ("—" if role == Qt.ItemDataRole.DisplayRole else "")
if role == Qt.ItemDataRole.ToolTipRole:
return self._tooltip(row, column)
if role == Qt.ItemDataRole.TextAlignmentRole:
@@ -711,6 +715,30 @@ class DiagnosisTableModel(QAbstractTableModel):
def checked_records(self) -> list[Any]:
return [row for row in self.rows if self.record_id(row) in self._checked_ids]
+ def assistant_name(self, row: Any) -> str:
+ name = str(first_value(row, "assistant_name", default="")).strip()
+ if name:
+ return name
+ assistant_id = _as_int(first_value(row, "assistant_id", "assistant", default=0))
+ return self._assistant_names.get(assistant_id, "") if assistant_id > 0 else ""
+
+ def set_assistant_names(self, names: Mapping[int, str]) -> bool:
+ """Refresh names from the authorized picker without changing diagnosis records."""
+ normalized = {
+ _as_int(key): str(value).strip()
+ for key, value in names.items()
+ if _as_int(key) > 0 and value and str(value).strip()
+ }
+ if normalized == self._assistant_names:
+ return False
+ self._assistant_names = normalized
+ if self.rows:
+ self.dataChanged.emit(
+ self.index(0, 7), self.index(len(self.rows) - 1, 7),
+ [Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.ToolTipRole],
+ )
+ return True
+
def clear_checks(self) -> None:
if not self._checked_ids:
return
@@ -1269,11 +1297,15 @@ class _TechBlueDiagnosisDelegate(DiagnosisItemDelegate):
"""Approved consultation presentation, isolated from the patients table."""
@staticmethod
- def row_height(record: Any, widths: Sequence[int]) -> int:
+ def row_height(
+ record: Any, widths: Sequence[int], *, assistant_name: str | None = None,
+ ) -> int:
_entries, appointment_height = _blue_appointment_layout(record, widths[4])
heights = [72, 16 + appointment_height]
for column in (1, 2, 3, 6, 7):
text = DiagnosisTableModel._display(record, column)
+ if column == 7 and assistant_name is not None:
+ text = assistant_name or "—"
heights.append(18 + _blue_text_height(
text, widths[column] - 16, 13 if column == 6 else 14, medium=column == 2,
))
@@ -1287,7 +1319,10 @@ class _TechBlueDiagnosisDelegate(DiagnosisItemDelegate):
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize: # noqa: N802
model = index.model()
record = model.record(index.row()) if isinstance(model, DiagnosisTableModel) else None
- return QSize(option.rect.width(), self.row_height(record, _BLUE_COLUMN_WIDTHS))
+ name = model.assistant_name(record) if isinstance(model, DiagnosisTableModel) else None
+ return QSize(option.rect.width(), self.row_height(
+ record, _BLUE_COLUMN_WIDTHS, assistant_name=name,
+ ))
@staticmethod
def _text(
@@ -1850,6 +1885,11 @@ class DiagnosisTableHost(QFrame):
def selected_records(self) -> list[Any]:
return self.model.checked_records()
+ def set_assistant_names(self, names: Mapping[int, str]) -> None:
+ if self.model.set_assistant_names(names):
+ self._sync_row_heights()
+ self._schedule_fixed_height_sync()
+
def clear_checks(self) -> None:
self.model.clear_checks()
@@ -1898,7 +1938,8 @@ class DiagnosisTableHost(QFrame):
)
height = (
_TechBlueDiagnosisDelegate.row_height(
- record, [self.main.columnWidth(column) for column in range(10)]
+ record, [self.main.columnWidth(column) for column in range(10)],
+ assistant_name=self.model.assistant_name(record),
) if self.tech_blue else max(52, 12 + count * 42 + (18 if channel else 0))
)
self.main.setRowHeight(row, height)
diff --git a/app/src/doctor_workstation/ui/pages/consultations.py b/app/src/doctor_workstation/ui/pages/consultations.py
index e6b3d832f..e63621d7c 100644
--- a/app/src/doctor_workstation/ui/pages/consultations.py
+++ b/app/src/doctor_workstation/ui/pages/consultations.py
@@ -1644,13 +1644,6 @@ class ConsultationsPage(QWidget):
return combo.currentText().strip()
return "" if data is None else data
- def _assistant_cell(self, _value: Any, row: Any) -> str:
- name = first_value(row, "assistant_name", default="")
- if name:
- return display_text(name)
- assistant_id = _as_int(first_value(row, "assistant_id", "assistant", default=0))
- return self._assistant_names.get(assistant_id, "—")
-
def _custom_date_changed(self, value: QDate) -> None:
if value == _OPTIONAL_DATE_MINIMUM:
return
@@ -2327,11 +2320,12 @@ class ConsultationsPage(QWidget):
combo.setEditText(str(current))
self._assistant_names = {
_as_int(first_value(row, "id", "value")): display_text(
- first_value(row, "name", "label")
+ first_value(row, "name", "label"), ""
)
for row in options.get("assistants", [])
if _as_int(first_value(row, "id", "value")) > 0
}
+ self.table_host.set_assistant_names(self._assistant_names)
def _checked_changed(self, count: int) -> None:
self.selected_label.setText(f"已选 {count} 条")
@@ -2425,6 +2419,7 @@ class ConsultationsPage(QWidget):
}
options = {key: value for key, value in options.items() if value}
self._assistant_names.update(options)
+ self.table_host.set_assistant_names(self._assistant_names)
self._show_assistant_picker(records, options)
def _show_assistant_picker(
diff --git a/app/tests/test_consultation_assistant_names.py b/app/tests/test_consultation_assistant_names.py
new file mode 100644
index 000000000..95292e2fd
--- /dev/null
+++ b/app/tests/test_consultation_assistant_names.py
@@ -0,0 +1,139 @@
+"""Medical-assistant names must survive independent list/options loading."""
+
+from __future__ import annotations
+
+import os
+from copy import deepcopy
+from typing import Any
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+import pytest
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import QApplication, QWidget
+
+from doctor_workstation.core.errors import ApiBusinessError
+from doctor_workstation.services.repository import RemoteDoctorRepository
+from doctor_workstation.ui.pages import consultations as module
+
+
+class Client:
+ def __init__(self) -> None:
+ self.assistants: Any = [{"id": 66, "name": "周医助"}]
+ self.rows = [
+ {"id": 1, "assistant_id": "66", "patient_name": "测试患者"},
+ {"id": 2, "assistant_id": 66, "assistant_name": "诊单医助"},
+ {"id": 3, "assistant_id": 99},
+ {"id": 4, "assistant_id": 0},
+ {"id": 5, "assistant_id": None},
+ ]
+
+ def get(self, endpoint: str, params: Any = None) -> Any:
+ if endpoint == "tcm.diagnosis/lists":
+ return {"lists": deepcopy(self.rows), "count": len(self.rows)}
+ if endpoint == "tcm.diagnosis/getAssistants":
+ if isinstance(self.assistants, Exception):
+ raise self.assistants
+ return deepcopy(self.assistants)
+ return []
+
+
+class DetailStub(QWidget):
+ saved = Signal()
+
+ def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
+ super().__init__(parent)
+
+
+@pytest.fixture(scope="module")
+def application() -> QApplication:
+ return QApplication.instance() or QApplication([])
+
+
+@pytest.fixture
+def page(application: QApplication, monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr(module, "DiagnosisDialog", DetailStub)
+ jobs = []
+
+ def defer(function: Any, **callbacks: Any) -> None:
+ jobs.append(lambda: callbacks["on_success"](function()))
+
+ monkeypatch.setattr(module, "run_async", defer)
+ client = Client()
+ result = module.ConsultationsPage(RemoteDoctorRepository(client))
+ yield result, client, jobs
+ result.poll_timer.stop()
+ result.close()
+ result.deleteLater()
+ application.processEvents()
+
+
+@pytest.mark.parametrize("options_first", [False, True])
+def test_names_render_with_either_response_order_without_changing_source(page, options_first):
+ view, client, jobs = page
+ result = view.repository.list_consultations()
+ before = deepcopy(result)
+ view._load_filter_options()
+ if options_first:
+ jobs.pop()()
+ view._apply_result(result, view._generation)
+ model = view.table_host.model
+ model.setData(model.index(0, 0), Qt.CheckState.Checked, Qt.ItemDataRole.CheckStateRole)
+ view.table.selectRow(0)
+ if not options_first:
+ assert model.index(0, 7).data() == "—"
+ changes = []
+ model.dataChanged.connect(lambda first, last, roles: changes.append((first, last, roles)))
+ jobs.pop()()
+ assert any(first.column() == last.column() == 7 for first, last, _ in changes)
+ assert [model.index(row, 7).data() for row in range(5)] == [
+ "周医助", "诊单医助", "—", "—", "—",
+ ]
+ assert model.index(0, 7).data(Qt.ItemDataRole.ToolTipRole) == "周医助"
+ assert [row.id for row in model.checked_records()] == [1]
+ assert view.table.current_data().id == 1
+ assert result == before
+ assert "assistant_name" not in client.rows[0]
+ # A later page uses the same lookup; reassignment uses the new ID, not a cached row name.
+ client.rows = [{"id": 1, "assistant_id": 99}, {"id": 6, "assistant_id": 66}]
+ view._apply_result(view.repository.list_consultations(), view._generation)
+ assert [model.index(row, 7).data() for row in range(2)] == ["—", "周医助"]
+
+
+@pytest.mark.parametrize("options", [[], ApiBusinessError("权限不足,无法访问或操作")])
+def test_server_name_still_displays_when_picker_empty_or_denied(page, options):
+ view, client, jobs = page
+ client.assistants = options
+ view._apply_result(view.repository.list_consultations(), view._generation)
+ view._load_filter_options()
+ jobs.pop()()
+ assert view.table_host.model.index(1, 7).data() == "诊单医助"
+ assert view.table_host.model.index(0, 7).data() == "—"
+
+
+def test_updated_and_stale_options_do_not_leave_old_assistant_names(page):
+ view, _client, jobs = page
+ view._apply_result(view.repository.list_consultations(), view._generation)
+ view._load_filter_options()
+ jobs.pop()()
+ generation = view._options_generation
+ view._apply_filter_options({"assistants": [{"id": 66, "name": "新医助"}]}, generation)
+ assert view.table_host.model.index(0, 7).data() == "新医助"
+ view._apply_filter_options({"assistants": []}, generation - 1)
+ assert view.table_host.model.index(0, 7).data() == "新医助"
+ view._apply_filter_options({"assistants": []}, generation)
+ assert view.table_host.model.index(0, 7).data() == "—"
+ assert view.table_host.model.index(1, 7).data() == "诊单医助"
+
+
+def test_long_assistant_name_resizes_both_table_sections(page):
+ view, _client, _jobs = page
+ host = view.table_host
+ view._apply_result(view.repository.list_consultations(), view._generation)
+ original_height = host.main.rowHeight(0)
+ name = "完整医助姓名用于显示换行" * 5
+ host.set_assistant_names({66: name, 0: "不能用于未分配患者", -1: "无效医助"})
+ assert host.model.index(0, 7).data() == name
+ assert host.main.rowHeight(0) > original_height
+ assert host.main.rowHeight(0) == host.fixed.rowHeight(0)
+ assert host.model.index(3, 7).data() == "—"
diff --git a/app/tests/test_cos_upload.py b/app/tests/test_cos_upload.py
new file mode 100644
index 000000000..d35a69a76
--- /dev/null
+++ b/app/tests/test_cos_upload.py
@@ -0,0 +1,352 @@
+"""COS upload contract tests: real SDK configuration, no external requests."""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+import time
+import traceback
+from collections.abc import Iterator
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import httpx
+import pytest
+from qcloud_cos import CosClientError, CosConfig, CosS3Client
+
+from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError, ApiTransportError
+from doctor_workstation.services.api_client import ApiClient
+from doctor_workstation.services.cos_upload import upload_video_to_cos
+
+BUCKET = "synthetic-video-1234567890"
+REGION = "ap-guangzhou"
+PREFIX = "uploads/video/20261105/"
+_AUTOMATIC = object()
+
+
+def _credentials() -> dict[str, Any]:
+ now = int(time.time())
+ return {
+ "provider": "qcloud",
+ "fallback": False,
+ "bucket": BUCKET,
+ "region": REGION,
+ "key_prefix": PREFIX,
+ "object_key": "",
+ "host": "https://untrusted-host.example.test",
+ "cdn_domain": "https://cdn.example.test",
+ "max_size": 2 * 1024 * 1024 * 1024,
+ "start_time": now - 10,
+ "expired_time": now + 1800,
+ "credentials": {
+ "tmpSecretId": "synthetic-temporary-id",
+ "tmpSecretKey": "synthetic-temporary-secret",
+ "sessionToken": "synthetic-temporary-token",
+ },
+ }
+
+
+@dataclass
+class _Harness:
+ source: Path
+ credentials: Any = field(default_factory=_credentials)
+ confirmation: Any = _AUTOMATIC
+ sdk_error: Exception | None = None
+ sdk_reports_progress: bool = True
+ api_failure: str = ""
+ log_sensitive_sdk_data: bool = False
+ api_requests: list[httpx.Request] = field(default_factory=list)
+ sdk_calls: list[dict[str, Any]] = field(default_factory=list)
+ configs: list[CosConfig] = field(default_factory=list)
+ progress: list[tuple[int, int]] = field(default_factory=list)
+ events: list[str] = field(default_factory=list)
+
+ def handle(self, request: httpx.Request) -> httpx.Response:
+ self.api_requests.append(request)
+ assert request.method == "POST"
+ assert request.headers["token"] == "synthetic-admin-token"
+ endpoint = request.url.path.rsplit("/", 1)[-1]
+ assert endpoint in {"ossCredentials", "ossConfirm"}
+ self.events.append(endpoint)
+ if self.api_failure == endpoint:
+ return httpx.Response(200, json={"code": 0, "msg": "synthetic API rejection"})
+ if endpoint == "ossCredentials":
+ return httpx.Response(200, json={"code": 1, "data": self.credentials})
+ assert request.extensions["timeout"]["read"] == 120.0
+ body = json.loads(request.content)
+ result = self.confirmation
+ if result is _AUTOMATIC:
+ address = "https://cdn.example.test/" + body["key"]
+ result = {"id": 19, "uri": address, "url": address, "type": 20, "cid": 0}
+ return httpx.Response(200, json={"code": 1, "data": result})
+
+ def client(self) -> ApiClient:
+ return ApiClient(
+ "https://api.example.test",
+ token="synthetic-admin-token",
+ max_retries=5,
+ transport=httpx.MockTransport(self.handle),
+ )
+
+ def processing(self) -> None:
+ self.events.append("processing")
+
+ def report(self, sent: int, total: int) -> None:
+ self.progress.append((sent, total))
+
+
+@pytest.fixture
+def harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Harness]:
+ source = tmp_path / "synthetic-replay.mp4"
+ source.write_bytes(b"synthetic-video-bytes")
+ state = _Harness(source)
+
+ def sdk_upload(sdk: CosS3Client, **kwargs: Any) -> dict[str, str]:
+ state.configs.append(sdk.get_conf())
+ state.sdk_calls.append(kwargs)
+ state.events.append("sdk-start")
+ if state.log_sensitive_sdk_data:
+ logging.getLogger("qcloud_cos.cos_auth").debug("synthetic-temporary-secret")
+ logging.getLogger("qcloud_cos.cos_client").error("synthetic-temporary-token")
+ if state.sdk_error is not None:
+ raise state.sdk_error
+ if state.sdk_reports_progress:
+ size = source.stat().st_size
+ callback = kwargs["progress_callback"]
+ callback(size // 2, size)
+ callback(size, size)
+ callback(size * 2, size) # Defensive clamping must keep binding pending.
+ state.events.append("sdk-done")
+ return {"ETag": '"synthetic-etag"'}
+
+ def no_external_request(*_args: Any, **_kwargs: Any) -> None:
+ pytest.fail("SDK tests must never make an external request")
+
+ monkeypatch.setattr(CosS3Client, "upload_file", sdk_upload)
+ monkeypatch.setattr("requests.Session.request", no_external_request)
+ # Restore the logger after the test while exercising the helper's choice.
+ monkeypatch.setattr(logging.getLogger("qcloud_cos"), "propagate", True)
+ yield state
+
+
+@pytest.mark.parametrize("size", [1, 1024, 5 * 1024 * 1024])
+def test_official_sdk_config_and_confirm_order_keep_completion_pending(
+ harness: _Harness, size: int
+) -> None:
+ harness.source.write_bytes(b"v" * size)
+ harness.sdk_reports_progress = size > 1024 * 1024
+ with harness.client() as client:
+ result = upload_video_to_cos(
+ client,
+ harness.source,
+ mime_type="video/mp4",
+ on_progress=harness.report,
+ on_processing=harness.processing,
+ )
+
+ assert harness.events == ["ossCredentials", "sdk-start", "sdk-done", "processing", "ossConfirm"]
+ assert json.loads(harness.api_requests[0].content) == {
+ "type": "video", "name": harness.source.name
+ }
+ assert len(harness.sdk_calls) == 1
+ config = harness.configs[0]
+ call = harness.sdk_calls[0]
+ assert config._secret_id == "synthetic-temporary-id"
+ assert config._secret_key == "synthetic-temporary-secret"
+ assert config._token == "synthetic-temporary-token"
+ assert config._region == REGION
+ assert config._scheme == "https"
+ assert config._timeout == 120
+ assert config._verify_ssl is True
+ assert config._allow_redirects is False
+ assert config._domain is None
+ assert config._ip is None
+ assert config._port is None
+ assert config.uri(bucket=BUCKET, path=call["Key"]).startswith(
+ f"https://{BUCKET}.cos.{REGION}.myqcloud.com/"
+ )
+ assert call["Bucket"] == BUCKET
+ assert call["LocalFilePath"] == str(harness.source.resolve())
+ assert call["PartSize"] == 1
+ assert call["MAXThread"] == 3
+ assert call["ContentType"] == "video/mp4"
+ assert set(call) == {
+ "Bucket", "Key", "LocalFilePath", "PartSize", "MAXThread", "ContentType", "progress_callback"
+ }
+ assert re.fullmatch(re.escape(PREFIX) + r"[0-9]+-[0-9a-f]{32}\.mp4", call["Key"])
+ assert json.loads(harness.api_requests[1].content) == {
+ "type": "video", "key": call["Key"], "name": harness.source.name,
+ "size": size, "content_type": "video/mp4", "cid": 0,
+ }
+ assert result["id"] == 19
+ assert result["uri"].endswith("/" + call["Key"])
+ assert harness.progress[0] == (0, size)
+ assert harness.progress[-1] == (size - 1, size)
+ assert all(0 <= sent < total == size for sent, total in harness.progress)
+
+
+def test_fixed_object_key_is_used_without_expanding_its_scope(harness: _Harness) -> None:
+ harness.credentials["object_key"] = PREFIX + "session/fixed.mp4"
+ with harness.client() as client:
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4")
+ assert harness.sdk_calls[0]["Key"] == PREFIX + "session/fixed.mp4"
+
+
+@pytest.mark.parametrize(
+ ("field_name", "invalid"),
+ [
+ ("provider", "local"),
+ ("fallback", True),
+ ("fallback", "false"),
+ ("bucket", "bucket.example.test/escape-1234567890"),
+ ("bucket", "bucket@attacker-1234567890"),
+ ("bucket", ""),
+ ("region", "ap-guangzhou.attacker.test"),
+ ("region", "https://attacker.test"),
+ ("key_prefix", "uploads/voice/20261105/"),
+ ("key_prefix", "uploads/video/20261105/../"),
+ ("key_prefix", "uploads/video/20261399/"),
+ ("key_prefix", "uploads/video/20261105"),
+ ("object_key", "uploads/video/20261106/other.mp4"),
+ ("object_key", PREFIX + "../other.mp4"),
+ ("object_key", PREFIX + "%2e%2e/other.mp4"),
+ ("object_key", PREFIX + "nested\\other.mp4"),
+ ("object_key", PREFIX + "other.exe"),
+ ("object_key", []),
+ ("max_size", 0),
+ ("max_size", True),
+ ("start_time", 9999999999),
+ ("expired_time", 1),
+ ("expired_time", False),
+ ("credentials", None),
+ ("credentials", {"tmpSecretId": "id", "tmpSecretKey": "secret"}),
+ ("credentials", {"tmpSecretId": "id", "tmpSecretKey": "", "sessionToken": "token"}),
+ ("credentials", {"tmpSecretId": "id", "tmpSecretKey": "secret", "sessionToken": "bad\r\n"}),
+ ],
+)
+def test_invalid_credentials_fail_before_any_cos_upload(
+ harness: _Harness, field_name: str, invalid: Any
+) -> None:
+ harness.credentials[field_name] = invalid
+ with harness.client() as client, pytest.raises(ApiProtocolError):
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4")
+ assert harness.sdk_calls == []
+ assert harness.events == ["ossCredentials"]
+
+
+@pytest.mark.parametrize("response", [None, [], "invalid"])
+def test_non_mapping_credentials_are_rejected(harness: _Harness, response: Any) -> None:
+ harness.credentials = response
+ with harness.client() as client, pytest.raises(ApiProtocolError):
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4")
+ assert harness.sdk_calls == []
+
+
+def test_server_size_limit_is_enforced_before_cos_upload(harness: _Harness) -> None:
+ harness.credentials["max_size"] = harness.source.stat().st_size - 1
+ with harness.client() as client, pytest.raises(ValueError, match="上限"):
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4")
+ assert harness.sdk_calls == []
+ assert harness.events == ["ossCredentials"]
+
+
+@pytest.mark.parametrize("condition", ["missing", "empty", "oversized", "wrong-extension", "bad-mime"])
+def test_invalid_local_video_fails_before_request(harness: _Harness, condition: str) -> None:
+ mime = "video/mp4"
+ if condition == "missing":
+ harness.source.unlink()
+ elif condition == "empty":
+ harness.source.write_bytes(b"")
+ elif condition == "oversized":
+ with harness.source.open("wb") as stream:
+ stream.truncate(2 * 1024 * 1024 * 1024 + 1)
+ elif condition == "wrong-extension":
+ renamed = harness.source.with_suffix(".exe")
+ harness.source.rename(renamed)
+ harness.source = renamed
+ else:
+ mime = "video/mp4\r\nx-injected: value"
+ with harness.client() as client, pytest.raises(ValueError):
+ upload_video_to_cos(client, harness.source, mime_type=mime)
+ assert harness.api_requests == []
+ assert harness.sdk_calls == []
+
+
+def test_cos_error_is_sanitized_and_never_confirmed(
+ harness: _Harness, caplog: pytest.LogCaptureFixture
+) -> None:
+ harness.sdk_error = CosClientError("synthetic-temporary-secret synthetic-temporary-token")
+ harness.log_sensitive_sdk_data = True
+ caplog.set_level(logging.DEBUG)
+ with harness.client() as client, pytest.raises(ApiTransportError) as caught:
+ upload_video_to_cos(
+ client, harness.source, mime_type="video/mp4",
+ on_progress=harness.report, on_processing=harness.processing,
+ )
+ rendered = "".join(traceback.format_exception(caught.value))
+ assert "synthetic-temporary-secret" not in rendered + caplog.text
+ assert "synthetic-temporary-token" not in rendered + caplog.text
+ assert caught.value.data == {"provider": "cos", "operation": "upload"}
+ assert caught.value.__suppress_context__ is True
+ assert harness.events == ["ossCredentials", "sdk-start"]
+ assert harness.progress == [(0, harness.source.stat().st_size)]
+
+
+@pytest.mark.parametrize("endpoint", ["ossCredentials", "ossConfirm"])
+def test_api_business_errors_keep_their_type_and_are_not_retried(
+ harness: _Harness, endpoint: str
+) -> None:
+ harness.api_failure = endpoint
+ with harness.client() as client, pytest.raises(ApiBusinessError, match="synthetic API rejection"):
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4", on_progress=harness.report)
+ assert sum(request.url.path.endswith(endpoint) for request in harness.api_requests) == 1
+ assert len(harness.sdk_calls) == (0 if endpoint == "ossCredentials" else 1)
+ assert all(sent < total for sent, total in harness.progress)
+
+
+@pytest.mark.parametrize(
+ "invalid",
+ [
+ None,
+ [],
+ {},
+ {"id": 0},
+ {"id": True},
+ {"id": 19},
+ {"id": 19, "uri": "/uploads/video/local.mp4"},
+ {"id": 19, "uri": "file:///tmp/local.mp4"},
+ {"id": 19, "uri": "https://cdn.example.test/different.mp4"},
+ {"id": 19, "uri": "https://cdn.example.test:bad/" + PREFIX + "fixed.mp4"},
+ {"id": 19, "uri": "https://user:pass@cdn.example.test/" + PREFIX + "fixed.mp4"},
+ ],
+)
+def test_invalid_confirmation_never_reports_completion(harness: _Harness, invalid: Any) -> None:
+ harness.credentials["object_key"] = PREFIX + "fixed.mp4"
+ harness.confirmation = invalid
+ with harness.client() as client, pytest.raises(ApiProtocolError):
+ upload_video_to_cos(client, harness.source, mime_type="video/mp4", on_progress=harness.report)
+ assert len(harness.sdk_calls) == 1
+ assert harness.events[-1] == "ossConfirm"
+ assert all(sent < total for sent, total in harness.progress)
+
+
+def test_disposed_ui_callbacks_do_not_interrupt_upload_or_confirmation(harness: _Harness) -> None:
+ processing = []
+
+ def broken_progress(_sent: int, _total: int) -> None:
+ raise RuntimeError("synthetic disposed progress receiver")
+
+ def broken_processing() -> None:
+ processing.append(True)
+ raise RuntimeError("synthetic disposed processing receiver")
+
+ with harness.client() as client:
+ result = upload_video_to_cos(
+ client, harness.source, mime_type="video/mp4",
+ on_progress=broken_progress, on_processing=broken_processing,
+ )
+ assert result["id"] == 19
+ assert processing == [True]
+ assert harness.events == ["ossCredentials", "sdk-start", "sdk-done", "ossConfirm"]
diff --git a/app/tests/test_recording_cos_repository.py b/app/tests/test_recording_cos_repository.py
new file mode 100644
index 000000000..18762296a
--- /dev/null
+++ b/app/tests/test_recording_cos_repository.py
@@ -0,0 +1,144 @@
+"""Exercise the real COS SDK and repository with synthetic HTTP responses."""
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+from urllib.parse import parse_qs, urlsplit
+
+import httpx
+import pytest
+import requests
+
+from doctor_workstation.core.errors import ApiBusinessError, ApiProtocolError
+from doctor_workstation.services.api_client import ApiClient
+from doctor_workstation.services.repository import RemoteDoctorRepository
+
+
+@pytest.mark.parametrize("size", [1024, 5 * 1024 * 1024])
+@pytest.mark.parametrize("record_id", [None, 901])
+def test_real_sdk_sends_video_only_to_cos_before_confirming_and_binding(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, size: int, record_id: int | None,
+) -> None:
+ source = tmp_path / "synthetic-replay.mp4"
+ source.write_bytes(b"v" * size)
+ api_calls: list[str] = []
+ cos_requests: list[requests.PreparedRequest] = []
+ uploaded_parts: dict[int, bytes] = {}
+ progress: list[int] = []
+ stages: list[str] = []
+ confirmed_urls: list[str] = []
+
+ def cos_send(_session, request, **kwargs):
+ parsed = urlsplit(request.url)
+ assert parsed.scheme == "https"
+ assert parsed.hostname == "test-bucket-1250000000.cos.ap-guangzhou.myqcloud.com"
+ assert request.headers["x-cos-security-token"] == b"synthetic-sts-token"
+ assert "q-sign-algorithm=sha1" in request.headers["Authorization"]
+ assert "token" not in request.headers
+ cos_requests.append(request)
+ query = parse_qs(parsed.query, keep_blank_values=True)
+ if request.method == "GET":
+ body = b"false"
+ elif request.method == "POST" and "uploads" in query:
+ body = b"synthetic-upload"
+ elif request.method == "PUT":
+ part = int(query.get("partNumber", ["1"])[0])
+ uploaded_parts[part] = request.body.read() if hasattr(request.body, "read") else request.body
+ body = b""
+ else:
+ assert request.method == "POST" and query.get("uploadId") == ["synthetic-upload"]
+ body = b'"synthetic-etag"'
+ response = requests.Response()
+ response.status_code = 200
+ response.url = request.url
+ response.request = request
+ response._content = body
+ response.headers.update({"ETag": '"synthetic-etag"', "Content-Type": "application/xml"})
+ return response
+
+ monkeypatch.setattr(requests.Session, "send", cos_send)
+
+ def api_handler(request: httpx.Request) -> httpx.Response:
+ endpoint = request.url.path.removeprefix("/adminapi/")
+ api_calls.append(endpoint)
+ assert request.method == "POST"
+ assert request.headers["token"] == "synthetic-admin-token"
+ payload = json.loads(request.content)
+ if endpoint == "upload/ossCredentials":
+ assert payload == {"type": "video", "name": source.name}
+ data = {
+ "provider": "qcloud", "fallback": False,
+ "bucket": "test-bucket-1250000000", "region": "ap-guangzhou",
+ "host": "https://test-bucket-1250000000.cos.ap-guangzhou.myqcloud.com",
+ "key_prefix": "uploads/video/20261105/", "max_size": 2 * 1024**3,
+ "expired_time": int(time.time()) + 1800, "start_time": int(time.time()) - 10,
+ "credentials": {"tmpSecretId": "synthetic-id", "tmpSecretKey": "synthetic-key", "sessionToken": "synthetic-sts-token"},
+ }
+ elif endpoint == "upload/ossConfirm":
+ assert b"".join(uploaded_parts[i] for i in sorted(uploaded_parts)) == source.read_bytes()
+ assert stages == ["processing"]
+ assert size not in progress
+ assert payload == {
+ "type": "video", "key": payload["key"], "name": source.name,
+ "size": size, "content_type": "video/mp4", "cid": 0,
+ }
+ assert payload["key"].startswith("uploads/video/20261105/")
+ assert payload["key"].endswith(".mp4")
+ confirmed_urls.append("https://cdn.example.test/" + payload["key"])
+ data = {"id": 101, "uri": confirmed_urls[-1]}
+ elif endpoint == "tcm.diagnosis/createManualCallRecord":
+ assert payload == {"diagnosis_id": 501}
+ assert size not in progress
+ data = {"id": 901}
+ else:
+ assert endpoint == "tcm.diagnosis/attachLocalCallRecording"
+ assert payload == {"diagnosis_id": 501, "call_record_id": 901, "file_url": confirmed_urls[-1]}
+ assert size not in progress
+ data = []
+ return httpx.Response(200, json={"code": 1, "data": data})
+
+ with ApiClient("https://api.example.test", token="synthetic-admin-token", transport=httpx.MockTransport(api_handler)) as client:
+ result = RemoteDoctorRepository(client).upload_call_recording(
+ source, 501, call_record_id=record_id, mime_type="video/mp4",
+ on_progress=lambda sent, total: progress.append(sent),
+ on_processing=lambda: stages.append("processing"),
+ )
+ assert result["completed"] is True and result["call_record_id"] == 901
+ assert result["media_kind"] == "video"
+ assert progress[0] == 0 and progress[-1] == size
+ assert cos_requests
+ assert api_calls == [
+ "upload/ossCredentials", "upload/ossConfirm",
+ *(["tcm.diagnosis/createManualCallRecord"] if record_id is None else []),
+ "tcm.diagnosis/attachLocalCallRecording",
+ ]
+
+
+@pytest.mark.parametrize("failure", ["missing-record", "attach-failed"])
+def test_video_is_not_complete_until_exact_record_is_bound(tmp_path: Path, monkeypatch, failure):
+ from doctor_workstation.services import repository as repository_module
+
+ source = tmp_path / "synthetic.mp4"
+ source.write_bytes(b"video")
+ monkeypatch.setattr(repository_module, "upload_video_to_cos", lambda *args, **kwargs: {
+ "id": 101, "uri": "https://cdn.example.test/replay.mp4",
+ })
+ calls = []
+ progress = []
+
+ def handler(request):
+ calls.append(request.url.path)
+ if request.url.path.endswith("createManualCallRecord"):
+ return httpx.Response(200, json={"code": 1, "data": {"id": 0 if failure == "missing-record" else 901}})
+ return httpx.Response(200, json={"code": 0, "msg": "绑定失败"})
+
+ with (
+ ApiClient("https://api.example.test", transport=httpx.MockTransport(handler)) as client,
+ pytest.raises(ApiProtocolError if failure == "missing-record" else ApiBusinessError),
+ ):
+ RemoteDoctorRepository(client).upload_call_recording(
+ source, 501, on_progress=lambda sent, total: progress.append(sent),
+ )
+ assert source.stat().st_size not in progress
+ assert len(calls) == (1 if failure == "missing-record" else 2)
diff --git a/server/app/adminapi/lists/tcm/DiagnosisLists.php b/server/app/adminapi/lists/tcm/DiagnosisLists.php
index ebf6bada7..a4c332e98 100755
--- a/server/app/adminapi/lists/tcm/DiagnosisLists.php
+++ b/server/app/adminapi/lists/tcm/DiagnosisLists.php
@@ -184,6 +184,21 @@ class DiagnosisLists extends BaseAdminDataLists implements ListsSearchInterface
->limit($this->limitOffset, $this->limitLength)
->select()
->toArray();
+
+ // 仅补充已通过权限过滤且落在本页的医助姓名,不依赖调用者拥有后台人员列表权限。
+ $assistantIds = array_values(array_unique(array_filter(array_map(
+ 'intval',
+ array_column($lists, 'assistant_id')
+ ), static function (int $id): bool {
+ return $id > 0;
+ })));
+ $assistantNames = $assistantIds === []
+ ? []
+ : Admin::whereIn('id', $assistantIds)->whereNull('delete_time')->column('name', 'id');
+ foreach ($lists as &$item) {
+ $item['assistant_name'] = trim((string) ($assistantNames[(int) ($item['assistant_id'] ?? 0)] ?? ''));
+ }
+ unset($item);
// 关联挂号:doctor_appointment.patient_id 存诊单主键 id(与 Appointment 列表 join 一致)
$diagnosisIds = array_filter(array_unique(array_column($lists, 'id')));
diff --git a/server/tests/DiagnosisAssistantNameTest.php b/server/tests/DiagnosisAssistantNameTest.php
new file mode 100644
index 000000000..31063c4e1
--- /dev/null
+++ b/server/tests/DiagnosisAssistantNameTest.php
@@ -0,0 +1,261 @@
+where(...$condition);
+ }
+ return $this;
+ }
+ if (func_num_args() === 2) {
+ $value = $operator;
+ $operator = '=';
+ }
+ if ($operator === 'in') {
+ return $this->whereIn($field, $value);
+ }
+ if ($operator !== '=') {
+ throw new RuntimeException('Unexpected fixture operator: ' . $operator);
+ }
+ $this->conditions[] = [$field, '=', $value];
+ return $this;
+ }
+
+ public function whereIn(string $field, array $values): self
+ {
+ $this->conditions[] = [$field, 'in', $values];
+ return $this;
+ }
+
+ public function whereNull(string $field): self
+ {
+ $this->conditions[] = [$field, 'null', null];
+ return $this;
+ }
+
+ public function whereRaw(string $sql): self
+ {
+ if (!in_array($sql, ['0 = 1', '(assistant_id IS NULL OR assistant_id = 0)'], true)) {
+ throw new RuntimeException('Unexpected fixture SQL: ' . $sql);
+ }
+ $this->conditions[] = ['', 'raw', $sql];
+ return $this;
+ }
+
+ public function with(array $relations): self { return $this; }
+ public function append(array $fields): self { return $this; }
+ public function orderRaw(string $order): self { return $this; }
+ public function order(string $field, string $direction): self { return $this; }
+ public function group(string $field): self { return $this; }
+ public function getTableFields(): array { return []; }
+
+ public function field($fields): self
+ {
+ $this->fields = is_array($fields) ? $fields : array_map('trim', explode(',', $fields));
+ return $this;
+ }
+
+ public function limit(int $offset, int $length): self
+ {
+ $this->offset = $offset;
+ $this->length = $length;
+ return $this;
+ }
+
+ private function rows(): array
+ {
+ $rows = array_values(array_filter(self::$tables[$this->table] ?? [], function (array $row): bool {
+ foreach ($this->conditions as [$field, $operator, $value]) {
+ $actual = $row[$field] ?? null;
+ if (($operator === '=' && $actual != $value)
+ || ($operator === 'in' && !in_array($actual, $value))
+ || ($operator === 'null' && $actual !== null)
+ || ($operator === 'raw' && ($value === '0 = 1' || ($row['assistant_id'] ?? 0) != 0))) {
+ return false;
+ }
+ }
+ return true;
+ }));
+ return array_slice($rows, $this->offset, $this->length);
+ }
+
+ public function select(): think\Collection
+ {
+ if ($this->table === 'admin') {
+ throw new RuntimeException('Assistant enrichment must not read full admin records');
+ }
+ $rows = $this->rows();
+ if ($this->fields !== null) {
+ $rows = array_map(fn (array $row): array => array_intersect_key($row, array_flip($this->fields)), $rows);
+ }
+ return new think\Collection($rows);
+ }
+
+ public function column(string $field, string $key = ''): array
+ {
+ if ($this->table === 'admin') {
+ self::$adminReads[] = ['conditions' => $this->conditions, 'field' => $field, 'key' => $key];
+ }
+ return array_column($this->rows(), $field, $key !== '' ? $key : null);
+ }
+
+ public function count(): int { return count($this->rows()); }
+}
+
+class AssistantNameModel
+{
+ protected const TABLE = 'empty';
+ public function getTable(): string { return static::TABLE; }
+ public static function where(...$args): AssistantNameQuery
+ {
+ return (new AssistantNameQuery(static::TABLE))->where(...$args);
+ }
+ public static function whereIn(...$args): AssistantNameQuery
+ {
+ return (new AssistantNameQuery(static::TABLE))->whereIn(...$args);
+ }
+}
+final class AssistantNameDiagnosis extends AssistantNameModel { protected const TABLE = 'tcm_diagnosis'; }
+final class AssistantNameAdmin extends AssistantNameModel { protected const TABLE = 'admin'; }
+final class AssistantNameRoles extends AssistantNameModel { protected const TABLE = 'admin_role'; }
+final class AssistantNameAppointment extends AssistantNameModel { protected const TABLE = 'doctor_appointment'; }
+final class AssistantNameDb
+{
+ public static function name(string $table): AssistantNameQuery { return new AssistantNameQuery($table); }
+}
+final class AssistantNameRevisitDue
+{
+ public static function buildListPayload(...$args): array { return []; }
+ public static function emptyPayload(): array { return []; }
+}
+
+class_alias(AssistantNameDiagnosis::class, 'app\\common\\model\\tcm\\Diagnosis');
+class_alias(AssistantNameAdmin::class, 'app\\common\\model\\auth\\Admin');
+class_alias(AssistantNameRoles::class, 'app\\common\\model\\auth\\AdminRole');
+class_alias(AssistantNameAppointment::class, 'app\\common\\model\\doctor\\Appointment');
+foreach (['tcm\\Prescription', 'tcm\\CallRecord', 'tcm\\BloodRecord', 'tcm\\DietRecord',
+ 'tcm\\ExerciseRecord', 'dict\\DictData'] as $model) {
+ class_alias(AssistantNameModel::class, 'app\\common\\model\\' . $model);
+}
+class_alias(AssistantNameDb::class, 'think\\facade\\Db');
+class_alias(AssistantNameRevisitDue::class, 'app\\common\\service\\tcm\\RevisitDueService');
+
+final class AssistantNameLists extends app\adminapi\lists\tcm\DiagnosisLists
+{
+ public function __construct(private ?array $visibleIds = null, array $params = [], int $offset = 0,
+ int $length = 100, int $actor = 91)
+ {
+ $this->params = $params;
+ $this->adminId = $actor;
+ $this->adminInfo = ['admin_id' => $actor];
+ $this->searchWhere = [];
+ $this->limitOffset = $offset;
+ $this->limitLength = $length;
+ }
+ protected function dataScopeShouldApply(): bool { return true; }
+ protected function getDataScopeVisibleAdminIds(): ?array { return $this->visibleIds; }
+}
+
+$assertions = 0;
+function assistantNameExpect(bool $ok, string $message): void
+{
+ global $assertions;
+ if (!$ok) {
+ throw new RuntimeException('FAIL: ' . $message);
+ }
+ $assertions++;
+}
+
+// Input order is already the normal no-appointment descending-id order.
+AssistantNameQuery::$tables = [
+ 'tcm_diagnosis' => [
+ ['id' => 109, 'assistant_id' => 41],
+ ['id' => 108, 'assistant_id' => '42'],
+ ['id' => 107, 'assistant_id' => 41],
+ ['id' => 106, 'assistant_id' => null],
+ ['id' => 105, 'assistant_id' => 0],
+ ['id' => 104, 'assistant_id' => 404],
+ ['id' => 103, 'assistant_id' => 43],
+ ['id' => 102, 'assistant_id' => 44],
+ ['id' => 101, 'assistant_id' => -1],
+ ],
+ 'admin' => [
+ ['id' => 41, 'name' => ' 林医助 ', 'delete_time' => null, 'disable' => 0, 'account' => 'private-41'],
+ ['id' => 42, 'name' => '王医助', 'delete_time' => null, 'disable' => 1, 'account' => 'private-42'],
+ ['id' => 43, 'name' => '已删除医助', 'delete_time' => 123, 'disable' => 0],
+ ['id' => 44, 'name' => ' ', 'delete_time' => null, 'disable' => 0],
+ ['id' => 45, 'name' => '未关联人员', 'delete_time' => null, 'disable' => 0],
+ ],
+];
+$lists = new AssistantNameLists();
+$rows = $lists->lists();
+assistantNameExpect(array_column($rows, 'assistant_name', 'id') === [
+ 109 => '林医助', 108 => '王医助', 107 => '林医助', 106 => '', 105 => '',
+ 104 => '', 103 => '', 102 => '', 101 => '',
+], 'each row gets the assigned name; unassigned, missing, deleted, blank and invalid assignments remain empty');
+assistantNameExpect(AssistantNameQuery::$adminReads === [[
+ 'conditions' => [['id', 'in', [41, 42, 404, 43, 44]], ['delete_time', 'null', null]],
+ 'field' => 'name', 'key' => 'id',
+]], 'one batch reads only positive unique assigned ids and the name/id projection');
+assistantNameExpect($rows[1]['assistant_id'] === '42' && $rows[1]['assistant_name'] === '王医助',
+ 'disabled but still assigned staff retain their name without changing the original id');
+assistantNameExpect(!str_contains(json_encode($rows, JSON_UNESCAPED_UNICODE), 'private-')
+ && !str_contains(json_encode($rows, JSON_UNESCAPED_UNICODE), '未关联人员'),
+ 'enrichment exposes neither admin account fields nor unrelated names');
+assistantNameExpect($lists->count() === count($rows), 'enrichment does not change total count');
+
+AssistantNameQuery::$adminReads = [];
+$scoped = new AssistantNameLists([41]);
+$rows = $scoped->lists();
+assistantNameExpect(array_column($rows, 'id') === [109, 107] && $scoped->count() === 2,
+ 'real data-scope filtering remains active for lists and count');
+assistantNameExpect(AssistantNameQuery::$adminReads[0]['conditions'][0] === ['id', 'in', [41]],
+ 'names outside the authorized rows are never queried');
+
+AssistantNameQuery::$adminReads = [];
+$page = new AssistantNameLists(null, [], 1, 1);
+$rows = $page->lists();
+assistantNameExpect(array_column($rows, 'id') === [108] && $rows[0]['assistant_name'] === '王医助'
+ && $page->count() === 9, 'pagination and count are preserved');
+assistantNameExpect(AssistantNameQuery::$adminReads[0]['conditions'][0] === ['id', 'in', [42]],
+ 'only names on the requested page are queried');
+
+AssistantNameQuery::$tables['admin_role'] = [['admin_id' => 41, 'role_id' => 2]];
+AssistantNameQuery::$adminReads = [];
+$self = new AssistantNameLists(null, [], 0, 100, 41);
+assistantNameExpect(array_column($self->lists(), 'id') === [109, 107] && $self->count() === 2,
+ 'assistant-role self filtering is unchanged');
+assistantNameExpect(AssistantNameQuery::$adminReads[0]['conditions'][0] === ['id', 'in', [41]],
+ 'assistant-role name hydration uses the self-filtered result only');
+AssistantNameQuery::$tables['admin_role'] = [];
+
+foreach ([new AssistantNameLists([]), new AssistantNameLists(null, [], 50, 10)] as $empty) {
+ AssistantNameQuery::$adminReads = [];
+ assistantNameExpect($empty->lists() === [] && AssistantNameQuery::$adminReads === [],
+ 'empty authorization scope or an empty page does not query admin data');
+}
+AssistantNameQuery::$adminReads = [];
+$pending = new AssistantNameLists(null, ['pending_assign' => 1]);
+$rows = $pending->lists();
+assistantNameExpect(array_column($rows, 'id') === [106, 105] && $pending->count() === 2
+ && array_column($rows, 'assistant_name') === ['', ''] && AssistantNameQuery::$adminReads === [],
+ 'unassigned-only lists have an explicit empty name and need no admin query');
+
+echo "Diagnosis assistant names: {$assertions} assertions passed (no DB or network)\n";
diff --git a/server/tests/DiagnosisDepartmentFilterPermissionTest.php b/server/tests/DiagnosisDepartmentFilterPermissionTest.php
new file mode 100644
index 000000000..beb964f4a
--- /dev/null
+++ b/server/tests/DiagnosisDepartmentFilterPermissionTest.php
@@ -0,0 +1,252 @@
+ 1, 'show' => 0, 'msg' => '', 'data' => $data];
+ }
+ }
+}
+
+namespace app\common\model\dept {
+ final class Dept
+ {
+ public static array $rows = [];
+ public static int $reads = 0;
+ public static function order(array $order): self { self::$reads++; return new self(); }
+ public function select(): self { return $this; }
+ public function toArray(): array { return self::$rows; }
+ }
+}
+
+namespace app\common\service\DataScope {
+ final class DataScopeService
+ {
+ public static ?array $allowed = [12 => true];
+ public static array $actors = [];
+ public static function isEnabled(): bool { return true; }
+ public static function getAllowedDeptIdSet(int $adminId, array $adminInfo): ?array
+ {
+ self::$actors[] = [$adminId, $adminInfo];
+ return self::$allowed;
+ }
+ }
+}
+
+namespace app\adminapi\http\middleware {
+ function request(): object { return $GLOBALS['diagnosis_department_request']; }
+ function env(...$arguments): never { throw new \RuntimeException('This test must not read environment configuration'); }
+}
+
+namespace {
+ require dirname(__DIR__) . '/vendor/autoload.php';
+
+ use app\adminapi\controller\dept\DeptController;
+ use app\adminapi\http\middleware\AuthMiddleware;
+ use app\common\cache\AdminAuthCache;
+ use app\common\model\dept\Dept;
+ use app\common\service\DataScope\DataScopeService;
+ use app\common\service\JsonService;
+
+ final class DiagnosisDepartmentRequest
+ {
+ public array $adminInfo;
+ public object $controllerObject;
+ public string $controllerName = 'dept.Dept';
+ public string $actionName = 'all';
+
+ public function __construct(public array $query = [], public string $method = 'GET',
+ public array $body = [], bool $root = false)
+ {
+ $this->adminInfo = ['admin_id' => 91, 'root' => $root ? 1 : 0, 'role_id' => [7],
+ 'login_ip' => '192.0.2.91', 'work_wechat_userid' => 'synthetic-doctor-91'];
+ }
+ public function get(?string $name = null, $default = null)
+ {
+ return $name === null ? $this->query : ($this->query[$name] ?? $default);
+ }
+ public function param(string $name, $default = null)
+ {
+ return $this->body[$name] ?? $this->query[$name] ?? $default;
+ }
+ public function isGet(): bool { return $this->method === 'GET'; }
+ public function controller(): string { return $this->controllerName; }
+ public function action(): string { return $this->actionName; }
+ public function ip(): string { return '192.0.2.91'; }
+ }
+
+ final class DiagnosisDepartmentController extends DeptController
+ {
+ public function __construct(DiagnosisDepartmentRequest $request)
+ {
+ $this->request = $request;
+ $this->adminId = $request->adminInfo['admin_id'];
+ $this->adminInfo = $request->adminInfo;
+ }
+ protected function data($data) { return JsonService::data($data); }
+ }
+
+ $assertions = 0;
+ function departmentPermissionExpect(bool $ok, string $label): void
+ {
+ global $assertions;
+ if (!$ok) { throw new RuntimeException('FAIL: ' . $label); }
+ $assertions++;
+ }
+
+ function departmentPermissionInvoke(DiagnosisDepartmentRequest $request, array $permissions,
+ ?array $registered = null): array
+ {
+ AdminAuthCache::$permissions = $permissions;
+ AdminAuthCache::$allUris = $registered ?? [
+ 'dept.dept/all', 'dept.dept/lists', 'dept.dept/leaderDept', 'dept.dept/detail',
+ 'dept.dept/add', 'dept.dept/edit', 'dept.dept/delete', 'dept.jobs/all',
+ ];
+ AdminAuthCache::$actors = [];
+ AdminAuthCache::$allUriReads = 0;
+ DataScopeService::$actors = [];
+ Dept::$reads = 0;
+ $GLOBALS['diagnosis_department_request'] = $request;
+ $controller = new DiagnosisDepartmentController($request);
+ $request->controllerObject = $controller;
+ return (new AuthMiddleware())->handle($request, static function () use ($request, $controller): array {
+ if (strtolower($request->controller() . '/' . $request->action()) === 'dept.dept/all') {
+ return $controller->all();
+ }
+ // A sentinel verifies function-level grants without performing CRUD writes.
+ return ['code' => 1, 'continued' => true];
+ });
+ }
+
+ Dept::$rows = [
+ ['id' => 10, 'pid' => 0, 'name' => '共享祖先'],
+ ['id' => 11, 'pid' => 10, 'name' => '未授权同级'],
+ ['id' => 12, 'pid' => 10, 'name' => '授权部门'],
+ ['id' => 20, 'pid' => 0, 'name' => '其他部门'],
+ ];
+ $scopedTree = [[
+ 'id' => 10, 'pid' => 0, 'name' => '共享祖先', 'level' => 0,
+ 'children' => [['id' => 12, 'pid' => 10, 'name' => '授权部门', 'level' => 1, 'children' => []]],
+ ]];
+
+ foreach ([1, '1'] as $scope) {
+ foreach (['tcm.diagnosis/lists', 'TCM.DIAGNOSIS/LISTS'] as $permission) {
+ $request = new DiagnosisDepartmentRequest(['apply_data_scope' => $scope]);
+ $request->controllerName = 'DePt.DePt';
+ $request->actionName = 'AlL';
+ $result = departmentPermissionInvoke($request, [$permission]);
+ departmentPermissionExpect($result === ['code' => 1, 'show' => 0, 'msg' => '', 'data' => $scopedTree],
+ 'exact patient-list grant returns the real scoped tree and original envelope');
+ departmentPermissionExpect(DataScopeService::$actors === [[91, $request->adminInfo]],
+ 'controller passes the authenticated identity to data scope');
+ departmentPermissionExpect(Dept::$reads === 1 && AdminAuthCache::$allUriReads === 0,
+ 'read-only alias does not need a department management grant');
+ }
+ }
+
+ $invalidQueries = [[], ['apply_data_scope' => null]];
+ foreach ([0, '0', '', '01', '1x', '1.0', ' 1', '1 ', true, false, 1.0, 2, -1,
+ [], ['1'], new stdClass()] as $invalid) {
+ $invalidQueries[] = ['apply_data_scope' => $invalid];
+ }
+ foreach ($invalidQueries as $query) {
+ $result = departmentPermissionInvoke(new DiagnosisDepartmentRequest($query), ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($result['code'] === 0, 'missing or malformed scope cannot acquire the alias ' . json_encode($query));
+ departmentPermissionExpect(Dept::$reads === 0 && DataScopeService::$actors === [],
+ 'invalid scope is rejected before any department read');
+ }
+
+ foreach ([[], ['tcm.diagnosis/detail'], ['tcm.diagnosis/edit'], ['tcm.diagnosis/listsOther'],
+ ['dept.dept/lists']] as $permissions) {
+ $result = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => '1']), $permissions);
+ departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0,
+ 'unrelated permissions do not acquire the patient-list alias ' . json_encode($permissions));
+ }
+
+ foreach (['POST', 'PUT', 'DELETE', 'HEAD'] as $method) {
+ $result = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => '1'], $method),
+ ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0, 'alias denies HTTP method ' . $method);
+ }
+ foreach (['GET', 'POST'] as $method) {
+ $result = departmentPermissionInvoke(new DiagnosisDepartmentRequest([], $method, ['apply_data_scope' => '1']),
+ ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0,
+ 'request body cannot enable GET-only scope on ' . $method);
+ }
+
+ foreach (['lists', 'leaderDept', 'detail', 'add', 'edit', 'delete'] as $action) {
+ foreach (['GET', 'POST'] as $method) {
+ $request = new DiagnosisDepartmentRequest(['apply_data_scope' => '1'], $method);
+ $request->actionName = $action;
+ $result = departmentPermissionInvoke($request, ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($result['code'] === 0 && Dept::$reads === 0,
+ 'patient-list grant never authorizes department action ' . $method . ' ' . $action);
+ }
+ $request = new DiagnosisDepartmentRequest([], 'POST');
+ $request->actionName = $action;
+ $result = departmentPermissionInvoke($request, ['dept.dept/' . $action]);
+ departmentPermissionExpect(($result['continued'] ?? false) === true,
+ 'original exact department permission still authorizes ' . $action);
+ }
+ $otherController = new DiagnosisDepartmentRequest(['apply_data_scope' => '1']);
+ $otherController->controllerName = 'dept.Jobs';
+ $result = departmentPermissionInvoke($otherController, ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($result['code'] === 0, 'alias is limited to the exact department controller');
+
+ $originalGrant = departmentPermissionInvoke(new DiagnosisDepartmentRequest(), ['dept.dept/all']);
+ departmentPermissionExpect($originalGrant['code'] === 1 && count($originalGrant['data']) === 2
+ && count($originalGrant['data'][0]['children']) === 2 && DataScopeService::$actors === [],
+ 'original all-departments permission retains its unscoped response');
+ $originalScoped = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => 1]), ['dept.dept/all']);
+ departmentPermissionExpect($originalScoped['data'] === $scopedTree && count(DataScopeService::$actors) === 1,
+ 'original department grant can still request a scoped tree');
+
+ $root = departmentPermissionInvoke(new DiagnosisDepartmentRequest([], 'GET', [], true), []);
+ departmentPermissionExpect($root['code'] === 1 && count($root['data']) === 2 && AdminAuthCache::$actors === [],
+ 'root retains the existing middleware bypass and full tree');
+ DataScopeService::$allowed = null;
+ $rootScoped = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => 1], 'GET', [], true), []);
+ departmentPermissionExpect($rootScoped['data'] === $root['data'] && count(DataScopeService::$actors) === 1,
+ 'root scoped requests still execute the controller data-scope contract');
+ DataScopeService::$allowed = [];
+ $empty = departmentPermissionInvoke(new DiagnosisDepartmentRequest(['apply_data_scope' => '1']), ['tcm.diagnosis/lists']);
+ departmentPermissionExpect($empty === ['code' => 1, 'show' => 0, 'msg' => '', 'data' => []],
+ 'authorized empty scope remains a successful empty tree, never a full-data fallback');
+
+ $legacy = departmentPermissionInvoke(new DiagnosisDepartmentRequest(), [], []);
+ departmentPermissionExpect($legacy['code'] === 1 && count($legacy['data']) === 2 && AdminAuthCache::$allUriReads === 1,
+ 'unmatched requests continue through the existing unregistered-route policy unchanged');
+
+ echo "Diagnosis department filter permission: {$assertions} assertions passed (no DB or network)\n";
+}