Fix Docker log permissions and harden live surgery operations.

Map bind-mounted logs to host UID/GID via entrypoint, expose RTSP prewarm in compose, suppress health-check access noise, and return 409 when another surgery is active with orphan auto-end sweep.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-26 15:36:09 +08:00
parent 32c1c3ac75
commit 5bbc3903cb
22 changed files with 373 additions and 10 deletions

1
.gitignore vendored
View File

@@ -85,3 +85,4 @@ clients/voice-confirmation/dist/
logs/ logs/
Ultralytics/ Ultralytics/
scripts/ scripts/
!backend/scripts/

View File

@@ -18,6 +18,12 @@ OR_SITE_CONFIG_JSON_FILE=app/resources/or_site_config.sample.json
RTSP_PREWARM_ENABLED=true RTSP_PREWARM_ENABLED=true
RTSP_PREWARM_RECONNECT_MAX_SEC=30 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_APP_ID=31461922
BAIDU_API_KEY=8NjVDPUl64fg7InpxOka69TE BAIDU_API_KEY=8NjVDPUl64fg7InpxOka69TE

View File

@@ -28,6 +28,14 @@ POSTGRES_PORT=45432
# http://<GPU服务器局域网IP>:38080 作为服务端 Base URL。 # http://<GPU服务器局域网IP>:38080 作为服务端 Base URL。
# API_PORT=38080 # API_PORT=38080
# --- Docker 挂载目录权限Linux 宿主机)---
# 使 logs/rtsp_segments 等 bind mount 文件可在宿主机用 VLC/文件管理器直接打开。
# 填部署账号 UID/GIDid -u / id -g ;设为 0 则容器内以 root 写 logs宿主机可能无法读取
APP_UID=1000
APP_GID=1000
# HLS 预览 ensure 需访问 docker.sock填宿主机 docker 组 GIDgetent group docker | cut -d: -f3
DOCKER_GID=999
# --- 应用日志loguru 持久化;目录已在 .gitignore--- # --- 应用日志loguru 持久化;目录已在 .gitignore---
# APP_LOG_DIR=logs/app # APP_LOG_DIR=logs/app
# APP_LOG_FILE_NAME=server.log # APP_LOG_FILE_NAME=server.log

View File

@@ -42,11 +42,12 @@ WORKDIR /app
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
UV_HTTP_TIMEOUT=600 \ UV_HTTP_TIMEOUT=600 \
UV_LINK_MODE=copy \ UV_LINK_MODE=copy \
TORCH_HOME=/root/.cache/torch TORCH_HOME=/app/.cache/torch
COPY pyproject.toml uv.lock main.py alembic.ini ./ COPY pyproject.toml uv.lock main.py alembic.ini ./
COPY app ./app/ COPY app ./app/
COPY alembic ./alembic/ COPY alembic ./alembic/
COPY scripts ./scripts/
# 离线批处理 / demo 直调 algorithm_subprocesses/5.15/main.py含 weights/ # 离线批处理 / demo 直调 algorithm_subprocesses/5.15/main.py含 weights/
COPY algorithm_subprocesses ./algorithm_subprocesses/ COPY algorithm_subprocesses ./algorithm_subprocesses/
@@ -69,6 +70,9 @@ RUN --mount=type=cache,target=/root/.cache/uv \
ENV PATH="/app/.venv/bin:$PATH" ENV PATH="/app/.venv/bin:$PATH"
RUN chmod +x /app/scripts/docker-entrypoint.sh
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -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: 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( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=st,
detail=_pipeline_error_detail(exc, surgery_id), detail=_pipeline_error_detail(exc, surgery_id),
) from exc ) from exc
@@ -88,6 +93,14 @@ def _raise_confirmation_http(exc: SurgeryPipelineError, surgery_id: str) -> None
) from exc ) from exc
_RECORDING_NON_RETRYABLE_CODES = frozenset(
{
"SURGERY_ALREADY_RECORDING",
"SURGERY_IN_PROGRESS",
}
)
async def _call_recording_with_retries( async def _call_recording_with_retries(
factory: Callable[[], Awaitable[None]], factory: Callable[[], Awaitable[None]],
*, *,
@@ -103,6 +116,8 @@ async def _call_recording_with_retries(
return return
except SurgeryPipelineError as exc: except SurgeryPipelineError as exc:
last_exc = exc last_exc = exc
if exc.code in _RECORDING_NON_RETRYABLE_CODES:
raise
if attempt < max_attempts: if attempt < max_attempts:
logger.warning( logger.warning(
"{} attempt {}/{} failed ({}), retrying in {}s", "{} attempt {}/{} failed ({}), retrying in {}s",
@@ -118,6 +133,7 @@ async def _call_recording_with_retries(
raise SurgeryPipelineError( raise SurgeryPipelineError(
last_exc.code, last_exc.code,
f"{last_exc.message}(已重试 {max_attempts} 次仍失败)", f"{last_exc.message}(已重试 {max_attempts} 次仍失败)",
extra=last_exc.extra,
) from last_exc ) from last_exc
@@ -438,6 +454,10 @@ async def rtsp_preview_frame(
"/client/surgeries/start", "/client/surgeries/start",
response_model=SurgeryApiResponse, response_model=SurgeryApiResponse,
responses={ responses={
status.HTTP_409_CONFLICT: {
"description": "该手术已在录制中,或另有手术正在进行(见 detail.active_surgery_id",
"model": SurgeryClientErrorResponse,
},
status.HTTP_503_SERVICE_UNAVAILABLE: { status.HTTP_503_SERVICE_UNAVAILABLE: {
"description": ("未能在确认摄像头已开始录制后完成请求;录制子系统未就绪、开录未确认或发生故障。"), "description": ("未能在确认摄像头已开始录制后完成请求;录制子系统未就绪、开录未确认或发生故障。"),
"model": SurgeryClientErrorResponse, "model": SurgeryClientErrorResponse,

View File

@@ -4,6 +4,10 @@
SURGERY_RECORDING_MAX_ATTEMPTS: int = 3 SURGERY_RECORDING_MAX_ATTEMPTS: int = 3
SURGERY_RECORDING_RETRY_DELAY_SECONDS: float = 1.0 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 连接与抽帧、推理门控(不含 URLURL 在 Settings--- # --- RTSP 连接与抽帧、推理门控(不含 URLURL 在 Settings---
# 运行时以 Settings.video_open_timeout_sec环境变量 VIDEO_OPEN_TIMEOUT_SEC为准此处为未注入 Settings 时的回退默认。 # 运行时以 Settings.video_open_timeout_sec环境变量 VIDEO_OPEN_TIMEOUT_SEC为准此处为未注入 Settings 时的回退默认。
VIDEO_OPEN_TIMEOUT_SEC: float = 90.0 VIDEO_OPEN_TIMEOUT_SEC: float = 90.0

View File

@@ -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

View File

@@ -8,6 +8,7 @@ from pathlib import Path
from loguru import logger from loguru import logger
from app.access_log_filters import should_log_uvicorn_access_line
from app.config import Settings from app.config import Settings
_LOG_FORMAT = ( _LOG_FORMAT = (
@@ -24,6 +25,12 @@ class InterceptHandler(logging.Handler):
"""将 stdlib logginguvicorn 等)转发到 loguru。""" """将 stdlib logginguvicorn 等)转发到 loguru。"""
def emit(self, record: logging.LogRecord) -> None: 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: try:
level = logger.level(record.levelname).name level = logger.level(record.levelname).name
except ValueError: except ValueError:
@@ -76,4 +83,8 @@ def configure_application_logging(settings: Settings) -> Path | None:
lib_logger.handlers = [InterceptHandler()] lib_logger.handlers = [InterceptHandler()]
lib_logger.propagate = False lib_logger.propagate = False
from app.access_log_filters import install_uvicorn_access_log_filters
install_uvicorn_access_log_filters()
return log_path return log_path

View File

@@ -89,7 +89,7 @@ class SurgeryClientErrorDetail(BaseModel):
code: str = Field( code: str = Field(
description=( description=(
"业务错误码,如 RESULT_NOT_READY、RECORDING_CANNOT_START、" "业务错误码,如 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="人类可读说明。") message: str = Field(description="人类可读说明。")

View File

@@ -11,6 +11,7 @@ from pathlib import Path
from loguru import logger 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 from app.services.video.rtsp_ffmpeg_opts import ffmpeg_bin, rtsp_ffmpeg_input_opts
SegmentCallback = Callable[ SegmentCallback = Callable[
@@ -100,6 +101,7 @@ class RtspSegmentRecorder:
async def run(self, stop_event: asyncio.Event) -> None: async def run(self, stop_event: asyncio.Event) -> None:
self._output_dir.mkdir(parents=True, exist_ok=True) self._output_dir.mkdir(parents=True, exist_ok=True)
ensure_bind_mount_readable(self._output_dir)
consecutive_failures = 0 consecutive_failures = 0
while not stop_event.is_set(): while not stop_event.is_set():
output_path = self._output_dir / f"slice_{self._slice_index:04d}.mp4" output_path = self._output_dir / f"slice_{self._slice_index:04d}.mp4"
@@ -265,6 +267,7 @@ class RtspSegmentRecorder:
path, path,
duration_sec, duration_sec,
) )
ensure_bind_mount_readable(path)
await self._on_segment_complete(event) await self._on_segment_complete(event)
self._offset_sec += duration_sec self._offset_sec += duration_sec
self._slice_index += 1 self._slice_index += 1

View File

@@ -97,6 +97,8 @@ class CameraSessionManager:
self._rtsp_prewarm: RtspPrewarmService | None = None self._rtsp_prewarm: RtspPrewarmService | None = None
self._rtsp_ttl_stop = asyncio.Event() self._rtsp_ttl_stop = asyncio.Event()
self._rtsp_ttl_task: asyncio.Task[None] | None = None 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: async def _purge_expired_rtsp_segments(self) -> None:
ttl = float(self._s.rtsp_segment_ttl_hours or bp.RTSP_SEGMENT_TTL_HOURS) ttl = float(self._s.rtsp_segment_ttl_hours or bp.RTSP_SEGMENT_TTL_HOURS)
@@ -116,6 +118,42 @@ class CameraSessionManager:
except Exception as exc: except Exception as exc:
logger.warning("RTSP segment TTL sweep failed: {}", 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: def set_voice_terminal_hub(self, hub: VoiceTerminalHub | None) -> None:
self._voice_hub = hub self._voice_hub = hub
self._slice_batch.set_voice_terminal_hub(hub) self._slice_batch.set_voice_terminal_hub(hub)
@@ -140,6 +178,12 @@ class CameraSessionManager:
self._rtsp_segment_ttl_loop(), self._rtsp_segment_ttl_loop(),
name="rtsp_segment_ttl", 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: async def shutdown(self) -> None:
self._rtsp_ttl_stop.set() self._rtsp_ttl_stop.set()
@@ -150,6 +194,14 @@ class CameraSessionManager:
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
self._rtsp_ttl_task = None 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() await self._archive.shutdown()
ids = self._registry.active_ids() ids = self._registry.active_ids()
for sid in ids: for sid in ids:
@@ -172,6 +224,13 @@ class CameraSessionManager:
"SURGERY_ALREADY_RECORDING", "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) stale = await self._archive.take_archived_details(surgery_id)
if stale is not None: if stale is not None:
logger.warning( logger.warning(

View File

@@ -145,6 +145,28 @@ class SurgerySessionRegistry:
def active_ids(self) -> list[str]: def active_ids(self) -> list[str]:
return list(self._active.keys()) 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 def register(self, surgery_id: str, running: RunningSurgery) -> None:
async with self._manager_lock: async with self._manager_lock:
self._active[surgery_id] = running self._active[surgery_id] = running

View File

@@ -73,6 +73,8 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
network: host network: host
gpus: all gpus: all
group_add:
- "${DOCKER_GID:-999}"
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
environment: environment:
@@ -116,6 +118,8 @@ services:
RTSP_PRIMARY_CAMERA_ID: ${RTSP_PRIMARY_CAMERA_ID:-or-cam-03} RTSP_PRIMARY_CAMERA_ID: ${RTSP_PRIMARY_CAMERA_ID:-or-cam-03}
RTSP_RECORD_ALL_CAMERAS: ${RTSP_RECORD_ALL_CAMERAS:-false} RTSP_RECORD_ALL_CAMERAS: ${RTSP_RECORD_ALL_CAMERAS:-false}
RTSP_SEGMENT_DURATION_SEC: ${RTSP_SEGMENT_DURATION_SEC:-120} 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} VIDEO_OPEN_TIMEOUT_SEC: ${VIDEO_OPEN_TIMEOUT_SEC:-90}
APP_LOG_DIR: ${APP_LOG_DIR:-logs/app} APP_LOG_DIR: ${APP_LOG_DIR:-logs/app}
APP_LOG_FILE_ENABLED: ${APP_LOG_FILE_ENABLED:-true} 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_CONFIG_DIR: ${DEMO_HLS_PREVIEW_CONFIG_DIR:-/hls-preview-config}
DEMO_HLS_PREVIEW_CONTAINER_NAME: ${DEMO_HLS_PREVIEW_CONTAINER_NAME:-orm-mediamtx-hls} 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} 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: > command: >
sh -c "uv run --no-sync alembic upgrade head && sh -c "uv run --no-sync alembic upgrade head &&
uv run --no-sync uvicorn main:app --host 0.0.0.0 --port 8000" uv run --no-sync uvicorn main:app --host 0.0.0.0 --port 8000"

View File

@@ -6,7 +6,6 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from loguru import logger 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.api import router as api_router
from app.config import settings from app.config import settings
from app.database import check_database, engine from app.database import check_database, engine
@@ -61,7 +60,6 @@ async def lifespan(app: FastAPI):
def create_app() -> FastAPI: def create_app() -> FastAPI:
configure_application_logging(settings) configure_application_logging(settings)
install_uvicorn_access_log_filters()
application = FastAPI( application = FastAPI(
title="Operation Room Monitor", title="Operation Room Monitor",
lifespan=lifespan, lifespan=lifespan,

View File

@@ -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 "$@"

View File

@@ -1,6 +1,31 @@
from app.access_log_filters import should_log_uvicorn_access_line 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: def test_suppress_health_200() -> None:
line = '127.0.0.1:52854 - "GET /health HTTP/1.1" 200 OK' line = '127.0.0.1:52854 - "GET /health HTTP/1.1" 200 OK'
assert should_log_uvicorn_access_line(line) is False assert should_log_uvicorn_access_line(line) is False

View File

@@ -244,6 +244,30 @@ def test_start_surgery_503_on_pipeline_error(api_app: FastAPI, instant_sleep: No
assert d["surgery_id"] == "123456" 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: def test_start_surgery_422_invalid_surgery_id(api_app: FastAPI) -> None:
pipeline = MagicMock() pipeline = MagicMock()
pipeline.start_recording = AsyncMock() pipeline.start_recording = AsyncMock()

View File

@@ -61,6 +61,15 @@ class _StubCameraSessionManager:
"SURGERY_ALREADY_RECORDING", "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( state = SurgerySessionState(
candidate_consumables=list(candidate_consumables), candidate_consumables=list(candidate_consumables),
name_to_code={}, name_to_code={},

View File

@@ -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

View File

@@ -333,3 +333,52 @@ async def test_stop_surgery_resumes_prewarm_for_recorded_cameras() -> None:
prewarm.resume.assert_awaited_once_with("or-cam-03") 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

View File

@@ -150,6 +150,7 @@
const RESULT_UNAVAILABLE_LABELS = { const RESULT_UNAVAILABLE_LABELS = {
RESULT_NOT_READY: "结果尚不可查询", RESULT_NOT_READY: "结果尚不可查询",
SURGERY_ALREADY_RECORDING: "该手术已在录制中", SURGERY_ALREADY_RECORDING: "该手术已在录制中",
SURGERY_IN_PROGRESS: "另有手术正在进行",
}; };
function formatResultUnavailable(body) { function formatResultUnavailable(body) {
@@ -1205,11 +1206,14 @@
candidate_consumables: candidateConsumables, candidate_consumables: candidateConsumables,
}); });
if (!res.ok) { if (!res.ok) {
const dup = body?.detail?.code === "SURGERY_ALREADY_RECORDING"; const code = body?.detail?.code;
showBanner( const dup = code === "SURGERY_ALREADY_RECORDING";
dup ? "请勿重复开始:" + formatResultUnavailable(body) : "开录失败:" + formatDetail(body), const busy = code === "SURGERY_IN_PROGRESS";
"err", 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; return;
} }
showBanner("开录已接受,请打开语音终端", "ok"); showBanner("开录已接受,请打开语音终端", "ok");

View File

@@ -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 ```bash
cd backend cd backend
docker compose up -d --build docker compose up -d --build