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"; +}