diff --git a/.gitignore b/.gitignore index 3d2553e..3b55f9e 100755 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ clients/voice-confirmation/dist/ logs/ Ultralytics/ scripts/ +!backend/scripts/ diff --git a/backend/.env b/backend/.env index 98e1080..9cca824 100755 --- a/backend/.env +++ b/backend/.env @@ -18,6 +18,12 @@ OR_SITE_CONFIG_JSON_FILE=app/resources/or_site_config.sample.json RTSP_PREWARM_ENABLED=true RTSP_PREWARM_RECONNECT_MAX_SEC=30 +# --- Docker 挂载目录权限(baitian@GPU 服务器,id -u = 1000)--- +APP_UID=1000 +APP_GID=1000 +# 若 HLS ensure 报 docker.sock 权限错误,在服务器执行 getent group docker | cut -d: -f3 后改此值 +DOCKER_GID=999 + # --- 百度 --- BAIDU_APP_ID=31461922 BAIDU_API_KEY=8NjVDPUl64fg7InpxOka69TE diff --git a/backend/.env.example b/backend/.env.example index db3a763..025b9f4 100755 --- a/backend/.env.example +++ b/backend/.env.example @@ -28,6 +28,14 @@ POSTGRES_PORT=45432 # http://:38080 作为服务端 Base URL。 # API_PORT=38080 +# --- Docker 挂载目录权限(Linux 宿主机)--- +# 使 logs/rtsp_segments 等 bind mount 文件可在宿主机用 VLC/文件管理器直接打开。 +# 填部署账号 UID/GID:id -u / id -g ;设为 0 则容器内以 root 写 logs(宿主机可能无法读取)。 +APP_UID=1000 +APP_GID=1000 +# HLS 预览 ensure 需访问 docker.sock;填宿主机 docker 组 GID:getent group docker | cut -d: -f3 +DOCKER_GID=999 + # --- 应用日志(loguru 持久化;目录已在 .gitignore)--- # APP_LOG_DIR=logs/app # APP_LOG_FILE_NAME=server.log diff --git a/backend/Dockerfile b/backend/Dockerfile index 37aaffa..f316088 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -42,11 +42,12 @@ WORKDIR /app ENV PYTHONUNBUFFERED=1 \ UV_HTTP_TIMEOUT=600 \ UV_LINK_MODE=copy \ - TORCH_HOME=/root/.cache/torch + TORCH_HOME=/app/.cache/torch COPY pyproject.toml uv.lock main.py alembic.ini ./ COPY app ./app/ COPY alembic ./alembic/ +COPY scripts ./scripts/ # 离线批处理 / demo 直调 algorithm_subprocesses/5.15/main.py(含 weights/) COPY algorithm_subprocesses ./algorithm_subprocesses/ @@ -69,6 +70,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \ ENV PATH="/app/.venv/bin:$PATH" +RUN chmod +x /app/scripts/docker-entrypoint.sh + EXPOSE 8000 +ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/api.py b/backend/app/api.py index 91da3bc..009c410 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -59,8 +59,13 @@ def _pipeline_error_detail(exc: SurgeryPipelineError, surgery_id: str) -> dict: def _raise_surgery_pipeline_http(exc: SurgeryPipelineError, surgery_id: str) -> None: + status_map = { + "SURGERY_ALREADY_RECORDING": status.HTTP_409_CONFLICT, + "SURGERY_IN_PROGRESS": status.HTTP_409_CONFLICT, + } + st = status_map.get(exc.code, status.HTTP_503_SERVICE_UNAVAILABLE) raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + status_code=st, detail=_pipeline_error_detail(exc, surgery_id), ) from exc @@ -88,6 +93,14 @@ def _raise_confirmation_http(exc: SurgeryPipelineError, surgery_id: str) -> None ) from exc +_RECORDING_NON_RETRYABLE_CODES = frozenset( + { + "SURGERY_ALREADY_RECORDING", + "SURGERY_IN_PROGRESS", + } +) + + async def _call_recording_with_retries( factory: Callable[[], Awaitable[None]], *, @@ -103,6 +116,8 @@ async def _call_recording_with_retries( return except SurgeryPipelineError as exc: last_exc = exc + if exc.code in _RECORDING_NON_RETRYABLE_CODES: + raise if attempt < max_attempts: logger.warning( "{} attempt {}/{} failed ({}), retrying in {}s", @@ -118,6 +133,7 @@ async def _call_recording_with_retries( raise SurgeryPipelineError( last_exc.code, f"{last_exc.message}(已重试 {max_attempts} 次仍失败)", + extra=last_exc.extra, ) from last_exc @@ -438,6 +454,10 @@ async def rtsp_preview_frame( "/client/surgeries/start", response_model=SurgeryApiResponse, responses={ + status.HTTP_409_CONFLICT: { + "description": "该手术已在录制中,或另有手术正在进行(见 detail.active_surgery_id)。", + "model": SurgeryClientErrorResponse, + }, status.HTTP_503_SERVICE_UNAVAILABLE: { "description": ("未能在确认摄像头已开始录制后完成请求;录制子系统未就绪、开录未确认或发生故障。"), "model": SurgeryClientErrorResponse, diff --git a/backend/app/baked/pipeline.py b/backend/app/baked/pipeline.py index 0bca912..55ce7ad 100644 --- a/backend/app/baked/pipeline.py +++ b/backend/app/baked/pipeline.py @@ -4,6 +4,10 @@ SURGERY_RECORDING_MAX_ATTEMPTS: int = 3 SURGERY_RECORDING_RETRY_DELAY_SECONDS: float = 1.0 +# --- 孤儿手术(开录后未正常 end):超过此时长自动停录 --- +ORPHAN_SURGERY_AUTO_END_HOURS: float = 12.0 +ORPHAN_SURGERY_SWEEP_INTERVAL_SEC: float = 300.0 + # --- RTSP 连接与抽帧、推理门控(不含 URL,URL 在 Settings)--- # 运行时以 Settings.video_open_timeout_sec(环境变量 VIDEO_OPEN_TIMEOUT_SEC)为准;此处为未注入 Settings 时的回退默认。 VIDEO_OPEN_TIMEOUT_SEC: float = 90.0 diff --git a/backend/app/host_log_permissions.py b/backend/app/host_log_permissions.py new file mode 100644 index 0000000..f58a0d3 --- /dev/null +++ b/backend/app/host_log_permissions.py @@ -0,0 +1,46 @@ +"""Keep bind-mounted log files readable on the Docker host.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def _runtime_owner() -> tuple[int, int] | None: + uid_raw = (os.environ.get("APP_UID") or "").strip() + gid_raw = (os.environ.get("APP_GID") or "").strip() + if not uid_raw or not gid_raw: + return None + try: + uid = int(uid_raw) + gid = int(gid_raw) + except ValueError: + return None + if uid < 0 or gid < 0: + return None + return uid, gid + + +def ensure_bind_mount_readable(path: Path) -> None: + """Best-effort: world-readable modes and optional chown to APP_UID/APP_GID.""" + try: + if path.is_dir(): + path.chmod(0o775) + elif path.is_file(): + path.chmod(0o664) + except OSError: + pass + + owner = _runtime_owner() + if owner is None: + return + uid, gid = owner + try: + os.chown(path, uid, gid) + except OSError: + return + if path.is_file(): + try: + os.chown(path.parent, uid, gid) + except OSError: + pass diff --git a/backend/app/logging_setup.py b/backend/app/logging_setup.py index b37d29d..d81dcab 100644 --- a/backend/app/logging_setup.py +++ b/backend/app/logging_setup.py @@ -8,6 +8,7 @@ from pathlib import Path from loguru import logger +from app.access_log_filters import should_log_uvicorn_access_line from app.config import Settings _LOG_FORMAT = ( @@ -24,6 +25,12 @@ class InterceptHandler(logging.Handler): """将 stdlib logging(uvicorn 等)转发到 loguru。""" def emit(self, record: logging.LogRecord) -> None: + if record.name == "uvicorn.access": + try: + if not should_log_uvicorn_access_line(record.getMessage()): + return + except Exception: + pass try: level = logger.level(record.levelname).name except ValueError: @@ -76,4 +83,8 @@ def configure_application_logging(settings: Settings) -> Path | None: lib_logger.handlers = [InterceptHandler()] lib_logger.propagate = False + from app.access_log_filters import install_uvicorn_access_log_filters + + install_uvicorn_access_log_filters() + return log_path diff --git a/backend/app/schemas.py b/backend/app/schemas.py index be9108a..25b462c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -89,7 +89,7 @@ class SurgeryClientErrorDetail(BaseModel): code: str = Field( description=( "业务错误码,如 RESULT_NOT_READY、RECORDING_CANNOT_START、" - "NO_PENDING_CONFIRMATION、VOICE_ASR_FAILED。" + "SURGERY_IN_PROGRESS、NO_PENDING_CONFIRMATION、VOICE_ASR_FAILED。" ) ) message: str = Field(description="人类可读说明。") diff --git a/backend/app/services/video/rtsp_segment_recorder.py b/backend/app/services/video/rtsp_segment_recorder.py index 8911611..d1b71a7 100644 --- a/backend/app/services/video/rtsp_segment_recorder.py +++ b/backend/app/services/video/rtsp_segment_recorder.py @@ -11,6 +11,7 @@ from pathlib import Path from loguru import logger +from app.host_log_permissions import ensure_bind_mount_readable from app.services.video.rtsp_ffmpeg_opts import ffmpeg_bin, rtsp_ffmpeg_input_opts SegmentCallback = Callable[ @@ -100,6 +101,7 @@ class RtspSegmentRecorder: async def run(self, stop_event: asyncio.Event) -> None: self._output_dir.mkdir(parents=True, exist_ok=True) + ensure_bind_mount_readable(self._output_dir) consecutive_failures = 0 while not stop_event.is_set(): output_path = self._output_dir / f"slice_{self._slice_index:04d}.mp4" @@ -265,6 +267,7 @@ class RtspSegmentRecorder: path, duration_sec, ) + ensure_bind_mount_readable(path) await self._on_segment_complete(event) self._offset_sec += duration_sec self._slice_index += 1 diff --git a/backend/app/services/video/session_manager.py b/backend/app/services/video/session_manager.py index 75e9f25..a694e20 100644 --- a/backend/app/services/video/session_manager.py +++ b/backend/app/services/video/session_manager.py @@ -97,6 +97,8 @@ class CameraSessionManager: self._rtsp_prewarm: RtspPrewarmService | None = None self._rtsp_ttl_stop = asyncio.Event() self._rtsp_ttl_task: asyncio.Task[None] | None = None + self._orphan_sweep_stop = asyncio.Event() + self._orphan_sweep_task: asyncio.Task[None] | None = None async def _purge_expired_rtsp_segments(self) -> None: ttl = float(self._s.rtsp_segment_ttl_hours or bp.RTSP_SEGMENT_TTL_HOURS) @@ -116,6 +118,42 @@ class CameraSessionManager: except Exception as exc: logger.warning("RTSP segment TTL sweep failed: {}", exc) + async def _sweep_orphan_surgeries_once(self) -> None: + max_h = float(bp.ORPHAN_SURGERY_AUTO_END_HOURS) + for sid in list(self._registry.surgeries_exceeding_duration_hours(max_h)): + logger.warning( + "孤儿手术 {} 开录已超过 {:.0f} 小时,自动停录", + sid, + max_h, + ) + try: + voice_tid = await self.stop_surgery(sid, require_active=False) + except Exception as exc: + logger.error("孤儿手术 {} 自动停录失败: {}", sid, exc) + continue + if voice_tid and self._voice_hub is not None: + try: + await self._voice_hub.notify_end(voice_tid, sid) + except Exception as exc: + logger.warning( + "孤儿手术 {} 自动停录后语音终端 end 推送失败: {}", + sid, + exc, + ) + + async def _orphan_surgery_sweep_loop(self) -> None: + interval = max(60.0, float(bp.ORPHAN_SURGERY_SWEEP_INTERVAL_SEC)) + while not self._orphan_sweep_stop.is_set(): + try: + await asyncio.wait_for(self._orphan_sweep_stop.wait(), timeout=interval) + break + except TimeoutError: + pass + try: + await self._sweep_orphan_surgeries_once() + except Exception as exc: + logger.warning("孤儿手术巡检失败: {}", exc) + def set_voice_terminal_hub(self, hub: VoiceTerminalHub | None) -> None: self._voice_hub = hub self._slice_batch.set_voice_terminal_hub(hub) @@ -140,6 +178,12 @@ class CameraSessionManager: self._rtsp_segment_ttl_loop(), name="rtsp_segment_ttl", ) + if self._orphan_sweep_task is None or self._orphan_sweep_task.done(): + self._orphan_sweep_stop.clear() + self._orphan_sweep_task = asyncio.create_task( + self._orphan_surgery_sweep_loop(), + name="orphan_surgery_sweep", + ) async def shutdown(self) -> None: self._rtsp_ttl_stop.set() @@ -150,6 +194,14 @@ class CameraSessionManager: except asyncio.CancelledError: pass self._rtsp_ttl_task = None + self._orphan_sweep_stop.set() + if self._orphan_sweep_task is not None: + self._orphan_sweep_task.cancel() + try: + await self._orphan_sweep_task + except asyncio.CancelledError: + pass + self._orphan_sweep_task = None await self._archive.shutdown() ids = self._registry.active_ids() for sid in ids: @@ -172,6 +224,13 @@ class CameraSessionManager: "SURGERY_ALREADY_RECORDING", "该手术已在录制中,请勿重复开始。", ) + other_active = self._registry.other_active_surgery_id(surgery_id) + if other_active is not None: + raise SurgeryPipelineError( + "SURGERY_IN_PROGRESS", + f"手术ID {other_active} 正在进行中,请先结束后再开始新手术。", + extra={"active_surgery_id": other_active}, + ) stale = await self._archive.take_archived_details(surgery_id) if stale is not None: logger.warning( diff --git a/backend/app/services/video/session_registry.py b/backend/app/services/video/session_registry.py index b761912..551dedb 100644 --- a/backend/app/services/video/session_registry.py +++ b/backend/app/services/video/session_registry.py @@ -145,6 +145,28 @@ class SurgerySessionRegistry: def active_ids(self) -> list[str]: return list(self._active.keys()) + def other_active_surgery_id(self, exclude_surgery_id: str) -> str | None: + """若存在与 ``exclude_surgery_id`` 不同的活跃手术,返回其 surgery_id(单术间仅允许一台)。""" + for sid in self._active: + if sid != exclude_surgery_id: + return sid + return None + + def surgeries_exceeding_duration_hours(self, max_hours: float) -> list[str]: + """开录时刻距今超过 ``max_hours`` 的活跃手术号列表(用于孤儿自动停录)。""" + if max_hours <= 0: + return [] + threshold_sec = max_hours * 3600.0 + now = time.time() + out: list[str] = [] + for sid, run in self._active.items(): + started = run.state.surgery_started_wall + if started is None: + continue + if now - started >= threshold_sec: + out.append(sid) + return out + async def register(self, surgery_id: str, running: RunningSurgery) -> None: async with self._manager_lock: self._active[surgery_id] = running diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 20ed898..dab3372 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -73,6 +73,8 @@ services: dockerfile: Dockerfile network: host gpus: all + group_add: + - "${DOCKER_GID:-999}" extra_hosts: - "host.docker.internal:host-gateway" environment: @@ -116,6 +118,8 @@ services: RTSP_PRIMARY_CAMERA_ID: ${RTSP_PRIMARY_CAMERA_ID:-or-cam-03} RTSP_RECORD_ALL_CAMERAS: ${RTSP_RECORD_ALL_CAMERAS:-false} RTSP_SEGMENT_DURATION_SEC: ${RTSP_SEGMENT_DURATION_SEC:-120} + RTSP_PREWARM_ENABLED: ${RTSP_PREWARM_ENABLED:-false} + RTSP_PREWARM_RECONNECT_MAX_SEC: ${RTSP_PREWARM_RECONNECT_MAX_SEC:-30} VIDEO_OPEN_TIMEOUT_SEC: ${VIDEO_OPEN_TIMEOUT_SEC:-90} APP_LOG_DIR: ${APP_LOG_DIR:-logs/app} APP_LOG_FILE_ENABLED: ${APP_LOG_FILE_ENABLED:-true} @@ -126,6 +130,10 @@ services: DEMO_HLS_PREVIEW_CONFIG_DIR: ${DEMO_HLS_PREVIEW_CONFIG_DIR:-/hls-preview-config} DEMO_HLS_PREVIEW_CONTAINER_NAME: ${DEMO_HLS_PREVIEW_CONTAINER_NAME:-orm-mediamtx-hls} MEDIAMTX_DOCKER_IMAGE: ${MEDIAMTX_DOCKER_IMAGE:-m.daocloud.io/docker.io/bluenviron/mediamtx:latest} + # Bind-mounted logs/cache ownership on Linux host (id -u / id -g). 0 = keep root. + APP_UID: ${APP_UID:-1000} + APP_GID: ${APP_GID:-1000} + TORCH_HOME: /app/.cache/torch command: > sh -c "uv run --no-sync alembic upgrade head && uv run --no-sync uvicorn main:app --host 0.0.0.0 --port 8000" diff --git a/backend/main.py b/backend/main.py index 682baa7..c813ec6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,7 +6,6 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from loguru import logger -from app.access_log_filters import install_uvicorn_access_log_filters from app.api import router as api_router from app.config import settings from app.database import check_database, engine @@ -61,7 +60,6 @@ async def lifespan(app: FastAPI): def create_app() -> FastAPI: configure_application_logging(settings) - install_uvicorn_access_log_filters() application = FastAPI( title="Operation Room Monitor", lifespan=lifespan, diff --git a/backend/scripts/docker-entrypoint.sh b/backend/scripts/docker-entrypoint.sh new file mode 100755 index 0000000..cebecc7 --- /dev/null +++ b/backend/scripts/docker-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Normalize bind-mounted logs/cache ownership for the host user (VLC, editors, etc.). +set -e + +APP_UID="${APP_UID:-1000}" +APP_GID="${APP_GID:-1000}" + +mkdir -p \ + /app/logs/rtsp_segments \ + /app/logs/video_batch \ + /app/logs/app \ + /app/.cache/torch + +if [ "$(id -u)" = "0" ] && [ "${APP_UID}" != "0" ]; then + chown -R "${APP_UID}:${APP_GID}" /app/logs /app/.cache 2>/dev/null || true + chmod -R u+rwX,g+rwX,o+rX /app/logs 2>/dev/null || true + exec runuser -u "#${APP_UID}" -g "#${APP_GID}" -- "$@" +fi + +exec "$@" diff --git a/backend/tests/test_access_log_filters.py b/backend/tests/test_access_log_filters.py index fcf0a82..3a99ffb 100644 --- a/backend/tests/test_access_log_filters.py +++ b/backend/tests/test_access_log_filters.py @@ -1,6 +1,31 @@ from app.access_log_filters import should_log_uvicorn_access_line +def test_intercept_handler_suppresses_health_200() -> None: + import logging + + from loguru import logger + + from app.logging_setup import InterceptHandler + + handler = InterceptHandler() + buf: list[str] = [] + logger.remove() + logger.add(lambda m: buf.append(str(m)), format="{message}") + + record = logging.LogRecord( + name="uvicorn.access", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg='127.0.0.1:52600 - "GET /health HTTP/1.1" 200', + args=(), + exc_info=None, + ) + handler.emit(record) + assert buf == [] + + def test_suppress_health_200() -> None: line = '127.0.0.1:52854 - "GET /health HTTP/1.1" 200 OK' assert should_log_uvicorn_access_line(line) is False diff --git a/backend/tests/test_api_contract.py b/backend/tests/test_api_contract.py index 878927b..7cbd856 100644 --- a/backend/tests/test_api_contract.py +++ b/backend/tests/test_api_contract.py @@ -244,6 +244,30 @@ def test_start_surgery_503_on_pipeline_error(api_app: FastAPI, instant_sleep: No assert d["surgery_id"] == "123456" +def test_start_surgery_409_when_another_surgery_in_progress( + api_app: FastAPI, instant_sleep: None +) -> None: + pipeline = MagicMock() + pipeline.start_recording = AsyncMock( + side_effect=SurgeryPipelineError( + "SURGERY_IN_PROGRESS", + "手术ID 111111 正在进行中,请先结束后再开始新手术。", + extra={"active_surgery_id": "111111"}, + ) + ) + api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline + client = TestClient(api_app) + r = client.post( + "/client/surgeries/start", + json={"surgery_id": "222222", "camera_ids": ["c1"], "candidate_consumables": ["纱布"]}, + ) + assert r.status_code == 409 + d = r.json()["detail"] + assert d["code"] == "SURGERY_IN_PROGRESS" + assert d["active_surgery_id"] == "111111" + assert d["surgery_id"] == "222222" + + def test_start_surgery_422_invalid_surgery_id(api_app: FastAPI) -> None: pipeline = MagicMock() pipeline.start_recording = AsyncMock() diff --git a/backend/tests/test_app_integration.py b/backend/tests/test_app_integration.py index cbf83bc..330f293 100644 --- a/backend/tests/test_app_integration.py +++ b/backend/tests/test_app_integration.py @@ -61,6 +61,15 @@ class _StubCameraSessionManager: "SURGERY_ALREADY_RECORDING", "该手术已在录制中,请勿重复开始。", ) + other = self._registry.other_active_surgery_id(surgery_id) + if other is not None: + from app.surgery_errors import SurgeryPipelineError + + raise SurgeryPipelineError( + "SURGERY_IN_PROGRESS", + f"手术ID {other} 正在进行中,请先结束后再开始新手术。", + extra={"active_surgery_id": other}, + ) state = SurgerySessionState( candidate_consumables=list(candidate_consumables), name_to_code={}, diff --git a/backend/tests/test_host_log_permissions.py b/backend/tests/test_host_log_permissions.py new file mode 100644 index 0000000..663c38f --- /dev/null +++ b/backend/tests/test_host_log_permissions.py @@ -0,0 +1,27 @@ +import os +from pathlib import Path + +from app.host_log_permissions import ensure_bind_mount_readable + + +def test_chown_when_app_uid_set(tmp_path: Path, monkeypatch) -> None: + target = tmp_path / "slice_0000.mp4" + target.write_bytes(b"mp4") + monkeypatch.setenv("APP_UID", str(os.getuid())) + monkeypatch.setenv("APP_GID", str(os.getgid())) + + ensure_bind_mount_readable(target) + + assert target.stat().st_mode & 0o777 == 0o664 + assert target.stat().st_uid == os.getuid() + + +def test_mode_only_without_app_uid(tmp_path: Path, monkeypatch) -> None: + target = tmp_path / "slice_0001.mp4" + target.write_bytes(b"mp4") + monkeypatch.delenv("APP_UID", raising=False) + monkeypatch.delenv("APP_GID", raising=False) + + ensure_bind_mount_readable(target) + + assert target.stat().st_mode & 0o777 == 0o664 diff --git a/backend/tests/test_session_manager_unit.py b/backend/tests/test_session_manager_unit.py index 18e7152..8a494ae 100644 --- a/backend/tests/test_session_manager_unit.py +++ b/backend/tests/test_session_manager_unit.py @@ -333,3 +333,52 @@ async def test_stop_surgery_resumes_prewarm_for_recorded_cameras() -> None: prewarm.resume.assert_awaited_once_with("or-cam-03") + +@pytest.mark.asyncio +async def test_start_surgery_rejects_when_another_surgery_active() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + hikvision_runtime=None, + result_repository=None, + ) + other = SurgerySessionState(candidate_consumables=["纱布"], surgery_started_wall=0.0) + other.ready.set() + mgr._registry._active["111111"] = RunningSurgery( + stop_event=asyncio.Event(), + state=other, + tasks=[], + ) + + with pytest.raises(SurgeryPipelineError) as ei: + await mgr.start_surgery("222222", ["or-cam-03"], ["纱布"]) + + assert ei.value.code == "SURGERY_IN_PROGRESS" + assert ei.value.extra == {"active_surgery_id": "111111"} + assert "111111" in ei.value.message + + +@pytest.mark.asyncio +async def test_orphan_sweep_auto_ends_stale_active_surgery() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + hikvision_runtime=None, + result_repository=None, + ) + mgr._slice_batch.drain = AsyncMock(return_value=None) + st = SurgerySessionState( + candidate_consumables=["纱布"], + surgery_started_wall=0.0, + ) + st.ready.set() + mgr._registry._active["123456"] = RunningSurgery( + stop_event=asyncio.Event(), + state=st, + tasks=[], + ) + + await mgr._sweep_orphan_surgeries_once() + + assert "123456" not in mgr._registry._active + diff --git a/clients/demo-client/app.js b/clients/demo-client/app.js index 6b35110..73919be 100644 --- a/clients/demo-client/app.js +++ b/clients/demo-client/app.js @@ -150,6 +150,7 @@ const RESULT_UNAVAILABLE_LABELS = { RESULT_NOT_READY: "结果尚不可查询", SURGERY_ALREADY_RECORDING: "该手术已在录制中", + SURGERY_IN_PROGRESS: "另有手术正在进行", }; function formatResultUnavailable(body) { @@ -1205,11 +1206,14 @@ candidate_consumables: candidateConsumables, }); if (!res.ok) { - const dup = body?.detail?.code === "SURGERY_ALREADY_RECORDING"; - showBanner( - dup ? "请勿重复开始:" + formatResultUnavailable(body) : "开录失败:" + formatDetail(body), - "err", - ); + const code = body?.detail?.code; + const dup = code === "SURGERY_ALREADY_RECORDING"; + const busy = code === "SURGERY_IN_PROGRESS"; + const activeId = body?.detail?.active_surgery_id; + let msg = "开录失败:" + formatDetail(body); + if (dup) msg = "请勿重复开始:" + formatResultUnavailable(body); + else if (busy && activeId) msg = `手术 ID ${activeId} 正在进行中,请先结束后再开始`; + showBanner(msg, "err"); return; } showBanner("开录已接受,请打开语音终端", "ok"); diff --git a/docs/Docker部署.md b/docs/Docker部署.md index 71ed660..c23407d 100644 --- a/docs/Docker部署.md +++ b/docs/Docker部署.md @@ -34,6 +34,21 @@ operation-room-monitor/ ## 二、启动后端 +复制 `backend/.env.example` 为 `.env` 后,在 **Linux GPU 服务器**上建议设置挂载目录归属(否则 `logs/rtsp_segments/` 下 MP4 可能只有 root 可读,宿主机 VLC 打不开): + +```bash +# 写入 backend/.env(将 baitian 换成实际部署用户名对应的 id) +APP_UID=$(id -u) +APP_GID=$(id -g) +DOCKER_GID=$(getent group docker | cut -d: -f3) +``` + +若已有 root 拥有的旧切片,重建 api 容器后 entrypoint 会 `chown` 整个 `logs/`;或一次性执行: + +```bash +sudo chown -R "$(id -u):$(id -g)" logs/ +``` + ```bash cd backend docker compose up -d --build