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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -85,3 +85,4 @@ clients/voice-confirmation/dist/
|
|||||||
logs/
|
logs/
|
||||||
Ultralytics/
|
Ultralytics/
|
||||||
scripts/
|
scripts/
|
||||||
|
!backend/scripts/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/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)---
|
# --- 应用日志(loguru 持久化;目录已在 .gitignore)---
|
||||||
# APP_LOG_DIR=logs/app
|
# APP_LOG_DIR=logs/app
|
||||||
# APP_LOG_FILE_NAME=server.log
|
# APP_LOG_FILE_NAME=server.log
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 连接与抽帧、推理门控(不含 URL,URL 在 Settings)---
|
# --- RTSP 连接与抽帧、推理门控(不含 URL,URL 在 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
|
||||||
|
|||||||
46
backend/app/host_log_permissions.py
Normal file
46
backend/app/host_log_permissions.py
Normal 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
|
||||||
@@ -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 logging(uvicorn 等)转发到 loguru。"""
|
"""将 stdlib logging(uvicorn 等)转发到 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
|
||||||
|
|||||||
@@ -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="人类可读说明。")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
20
backend/scripts/docker-entrypoint.sh
Executable file
20
backend/scripts/docker-entrypoint.sh
Executable 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 "$@"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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={},
|
||||||
|
|||||||
27
backend/tests/test_host_log_permissions.py
Normal file
27
backend/tests/test_host_log_permissions.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user