This commit is contained in:
Your Name
2026-11-06 12:01:31 +08:00
parent 5926083221
commit b46fb901ca
9 changed files with 1433 additions and 11 deletions
@@ -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)
@@ -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)
@@ -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(
@@ -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() == "—"
+352
View File
@@ -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"]
+144
View File
@@ -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"<ListMultipartUploadsResult><IsTruncated>false</IsTruncated></ListMultipartUploadsResult>"
elif request.method == "POST" and "uploads" in query:
body = b"<InitiateMultipartUploadResult><UploadId>synthetic-upload</UploadId></InitiateMultipartUploadResult>"
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'<CompleteMultipartUploadResult><ETag>"synthetic-etag"</ETag></CompleteMultipartUploadResult>'
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)
@@ -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')));
+261
View File
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
// Exercise the real lists/count methods with synthetic in-memory rows only.
require dirname(__DIR__) . '/vendor/autoload.php';
final class AssistantNameQuery
{
public static array $tables = [];
public static array $adminReads = [];
private array $conditions = [];
private ?array $fields = null;
private int $offset = 0;
private ?int $length = null;
public function __construct(private string $table) {}
public function where($field, $operator = null, $value = null): self
{
if (is_array($field)) {
foreach ($field as $condition) {
$this->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";
@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
// Execute the real middleware, department controller and tree filtering logic.
// Only authentication storage, department rows and scope lookup are in memory.
namespace app\adminapi\logic {
final class LoginLogic
{
public static function adminMustBindWorkWechat(array $adminInfo): bool { return false; }
}
}
namespace app\common\cache {
final class AdminAuthCache
{
public static array $permissions = [];
public static array $allUris = [];
public static array $actors = [];
public static int $allUriReads = 0;
public function __construct(int $adminId) { self::$actors[] = $adminId; }
public function getAdminUri(): array { return self::$permissions; }
public function getAllUri(): array { self::$allUriReads++; return self::$allUris; }
}
}
namespace app\common\service {
final class JsonService
{
public static function fail(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): array
{
return compact('code', 'show', 'msg', 'data');
}
public static function data(array $data): array
{
return ['code' => 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";
}