From 5fd0851a44e85c51b437d693e064c65ca52b098a Mon Sep 17 00:00:00 2001 From: op Date: Mon, 25 May 2026 16:39:08 +0800 Subject: [PATCH] update cam id --- backend/app/api.py | 21 +++- .../app/resources/or_site_config.sample.json | 4 +- backend/app/routers/recording_demo.py | 15 +++ backend/app/services/surgery_pipeline.py | 5 + backend/tests/test_algo_host_batch.py | 8 ++ backend/tests/test_recording_modes_status.py | 47 +++++++ clients/demo-client/README.md | 4 +- clients/demo-client/app.js | 116 +++++++++++++++++- clients/demo-client/index.html | 7 +- 9 files changed, 214 insertions(+), 13 deletions(-) diff --git a/backend/app/api.py b/backend/app/api.py index 7d67117..32923e8 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -141,10 +141,21 @@ async def health() -> HealthResponse | JSONResponse: summary="Demo 离线 batch(链路 3)是否可用", description="供 demo 页探测;始终注册,不依赖 DEMO_ORCHESTRATOR_ENABLED。", ) -async def recording_modes_status() -> dict: +async def recording_modes_status( + surgery_id: Annotated[ + str | None, + Query( + min_length=6, + max_length=6, + pattern=r"^\d{6}$", + description="可选:查询该手术号是否仍有链路 1 实时开录占用。", + ), + ] = None, + pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)] = ..., +) -> dict: f = (settings.or_site_config_json_file or "").strip() enabled = bool(settings.demo_orchestrator_enabled) - return { + body: dict = { "demo_recording_modes_enabled": enabled, "orchestrator_enabled": enabled, "offline_batch_method": "POST", @@ -167,6 +178,12 @@ async def recording_modes_status() -> dict: "rtsp_segment_ttl_hours": float(settings.rtsp_segment_ttl_hours), "video_batch_vis_ttl_hours": float(bp.VIDEO_BATCH_VIS_TTL_HOURS), } + if surgery_id is not None: + phase = pipeline.live_recording_phase(surgery_id) + body["surgery_id"] = surgery_id + body["live_recording_phase"] = phase + body["live_recording_active"] = phase is not None + return body class HlsPreviewEnsureRequest(BaseModel): diff --git a/backend/app/resources/or_site_config.sample.json b/backend/app/resources/or_site_config.sample.json index 0a2343c..5f041bc 100644 --- a/backend/app/resources/or_site_config.sample.json +++ b/backend/app/resources/or_site_config.sample.json @@ -1,8 +1,8 @@ { "video_rtsp_urls": { "or-cam-01": "rtsp://admin:Aa183137@192.168.3.2:554/Streaming/Channels/101", - "or-cam-02": "rtsp://admin:Aa183137@192.168.3.3:554/Streaming/Channels/101", - "or-cam-03": "rtsp://admin:Aa183137@192.168.3.4:554/Streaming/Channels/101", + "or-cam-03": "rtsp://admin:Aa183137@192.168.3.3:554/Streaming/Channels/101", + "or-cam-02": "rtsp://admin:Aa183137@192.168.3.4:554/Streaming/Channels/101", "or-cam-04": "rtsp://admin:Aa183137@192.168.3.5:554/Streaming/Channels/101" }, "voice_or_room_bindings": [ diff --git a/backend/app/routers/recording_demo.py b/backend/app/routers/recording_demo.py index 681ccea..e23b2f2 100644 --- a/backend/app/routers/recording_demo.py +++ b/backend/app/routers/recording_demo.py @@ -148,6 +148,21 @@ async def offline_batch( ) candidates = normalize_candidate_consumables_raw(candidates) + live_phase = pipeline.live_recording_phase(surgery_id) + if live_phase is not None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "LIVE_RECORDING_ACTIVE", + "message": ( + "该手术号仍有链路 1 实时开录(RTSP 切片)进行中,与离线精确互斥。" + "请先 POST /client/surgeries/end 结束实时会话,或更换 surgery_id 后再上传。" + ), + "surgery_id": surgery_id, + "live_recording_phase": live_phase, + }, + ) + raw = await video1.read() if not raw: raise HTTPException( diff --git a/backend/app/services/surgery_pipeline.py b/backend/app/services/surgery_pipeline.py index ac56455..408a562 100644 --- a/backend/app/services/surgery_pipeline.py +++ b/backend/app/services/surgery_pipeline.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +from typing import Literal from sqlalchemy.ext.asyncio import async_sessionmaker @@ -84,6 +85,10 @@ class SurgeryPipeline: except SurgeryPipelineError: raise + def live_recording_phase(self, surgery_id: str) -> Literal["starting", "recording"] | None: + """链路 1 实时会话是否占用该 surgery_id(starting=开录中,recording=已开录)。""" + return self._sessions.active_recording_phase(surgery_id) + async def save_video_batch_result( self, surgery_id: str, diff --git a/backend/tests/test_algo_host_batch.py b/backend/tests/test_algo_host_batch.py index 982e811..7e006ea 100644 --- a/backend/tests/test_algo_host_batch.py +++ b/backend/tests/test_algo_host_batch.py @@ -626,6 +626,10 @@ def test_demo_video_batch_endpoint_writes_queryable_result( def __init__(self) -> None: self.rows: dict[str, list[SurgeryConsumptionStored]] = {} + def live_recording_phase(self, surgery_id: str) -> None: + del surgery_id + return None + async def save_video_batch_result( self, surgery_id: str, @@ -729,6 +733,10 @@ def test_demo_video_batch_endpoint_stages_vis_and_purges_cache_when_requested( vis_calls.append((surgery_id, video_path, result_path)) class _FakePipeline: + def live_recording_phase(self, surgery_id: str) -> None: + del surgery_id + return None + async def save_video_batch_result( self, surgery_id: str, diff --git a/backend/tests/test_recording_modes_status.py b/backend/tests/test_recording_modes_status.py index 884f079..2f98179 100644 --- a/backend/tests/test_recording_modes_status.py +++ b/backend/tests/test_recording_modes_status.py @@ -1,14 +1,30 @@ """GET /internal/demo/recording-modes-status 契约。""" +from __future__ import annotations + +from typing import Literal + from fastapi import FastAPI from fastapi.testclient import TestClient from app.api import router as api_router +from app.dependencies import get_surgery_pipeline +from app.routers import recording_demo + + +class _FakePipeline: + def __init__(self, *, phase: Literal["starting", "recording"] | None = None) -> None: + self._phase = phase + + def live_recording_phase(self, surgery_id: str) -> Literal["starting", "recording"] | None: + del surgery_id + return self._phase def test_recording_modes_status_paths() -> None: app = FastAPI() app.include_router(api_router) + app.dependency_overrides[get_surgery_pipeline] = lambda: _FakePipeline() client = TestClient(app) res = client.get("/internal/demo/recording-modes-status") assert res.status_code == 200 @@ -19,3 +35,34 @@ def test_recording_modes_status_paths() -> None: assert body["rtsp_segment_ttl_hours"] == 24.0 assert body["rtsp_segment_duration_sec"] == 120.0 assert body["video_batch_vis_ttl_hours"] == 24.0 + assert "live_recording_active" not in body + + +def test_recording_modes_status_with_surgery_id() -> None: + app = FastAPI() + app.include_router(api_router) + app.dependency_overrides[get_surgery_pipeline] = lambda: _FakePipeline(phase="recording") + client = TestClient(app) + res = client.get("/internal/demo/recording-modes-status?surgery_id=100101") + assert res.status_code == 200 + body = res.json() + assert body["surgery_id"] == "100101" + assert body["live_recording_phase"] == "recording" + assert body["live_recording_active"] is True + + +def test_offline_batch_rejects_active_live_session(monkeypatch) -> None: + monkeypatch.setattr(recording_demo.settings, "demo_orchestrator_enabled", True) + + app = FastAPI() + app.include_router(recording_demo.router) + app.dependency_overrides[get_surgery_pipeline] = lambda: _FakePipeline(phase="recording") + + client = TestClient(app) + res = client.post( + "/internal/demo/offline-batch", + data={"surgery_id": "100101", "candidate_consumables_json": "[]"}, + files={"video1": ("case.mp4", b"video-bytes", "video/mp4")}, + ) + assert res.status_code == 409, res.text + assert res.json()["detail"]["code"] == "LIVE_RECORDING_ACTIVE" diff --git a/clients/demo-client/README.md b/clients/demo-client/README.md index c030076..3b91714 100755 --- a/clients/demo-client/README.md +++ b/clients/demo-client/README.md @@ -14,8 +14,8 @@ ## 界面说明 - **链路 1**:只需填 `camera_ids` 与耗材候选,点「开始手术」;**无需上传视频**(服务端自动 RTSP 拉流切片)。 -- **链路 1 / 3 共用「步骤 2 · 耗材候选」**:标签区显示「编号 + 名称」;提交时以 `{"消耗品编号","名称"}` 对象数组发送(与医院导出表一致)。 -- 误把 `.mp4` 路径填进耗材候选时,页面会提示并阻止提交。 +- **链路 3**:独立 MP4 上传区;与链路 1 **互斥**——同一手术号若仍有实时开录,离线上传会被拒绝;页面会提示先「结束手术」或换号。 +- **模式记忆**:上次选择的链路会保存在浏览器 localStorage;默认首次打开为链路 3。 详见 [`docs/video-backends.md`](../../docs/video-backends.md)。 diff --git a/clients/demo-client/app.js b/clients/demo-client/app.js index 12c6be3..6b35110 100644 --- a/clients/demo-client/app.js +++ b/clients/demo-client/app.js @@ -15,6 +15,10 @@ let videoVisToken = 0; const hlsPlayers = {}; let serverRecordingConfig = null; + let liveRecordingConflict = null; + + const LS_RUN_MODE = "orm_demo_run_mode"; + const VALID_RUN_MODES = new Set(["live-rtsp", "offline-batch"]); const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, ""); const surgeryId = () => $("surgery-id").value.trim(); @@ -51,6 +55,76 @@ return activeMode; } + function loadSavedRunMode() { + try { + const saved = localStorage.getItem(LS_RUN_MODE); + if (saved && VALID_RUN_MODES.has(saved)) return saved; + } catch { + /* ignore */ + } + return "offline-batch"; + } + + function persistRunMode(mode) { + try { + localStorage.setItem(LS_RUN_MODE, mode); + } catch { + /* ignore */ + } + } + + function liveRecordingConflictMessage(body) { + const phase = body?.live_recording_phase; + const phaseText = phase === "starting" ? "开录启动中" : phase === "recording" ? "录制中" : "进行中"; + return ( + `手术号 ${body?.surgery_id || surgeryId()} 仍有链路 1 实时会话(${phaseText},会 RTSP 切片)。` + + "请先点「结束手术」或更换手术号,再跑离线精确。" + ); + } + + function updateLiveRecordingConflictBanner() { + const el = $("live-recording-conflict"); + if (!el) return; + if (activeMode !== "offline-batch" || !liveRecordingConflict?.live_recording_active) { + el.textContent = ""; + el.classList.add("hidden"); + return; + } + el.textContent = liveRecordingConflictMessage(liveRecordingConflict); + el.classList.remove("hidden"); + } + + async function refreshLiveRecordingConflict(sid) { + if (!sid || !/^\d{6}$/.test(sid)) { + liveRecordingConflict = null; + updateLiveRecordingConflictBanner(); + return null; + } + const url = baseUrl() + `/internal/demo/recording-modes-status?surgery_id=${encodeURIComponent(sid)}`; + try { + const res = await fetch(url); + const data = await res.json(); + addLog("GET", url, res.status, data, { error: !res.ok }); + if (res.ok) { + liveRecordingConflict = data; + updateLiveRecordingConflictBanner(); + return data; + } + } catch (e) { + addLog("GET", url, "NETWORK", String(e), { error: true }); + } + liveRecordingConflict = null; + updateLiveRecordingConflictBanner(); + return null; + } + + async function ensureOfflineNotBlockedByLiveSession(sid) { + const status = await refreshLiveRecordingConflict(sid); + if (status?.live_recording_active) { + throw new Error(liveRecordingConflictMessage(status)); + } + } + function showBanner(msg, type) { const el = $("banner"); if (!el) return; @@ -536,7 +610,9 @@ } function setActiveMode(mode) { + if (!VALID_RUN_MODES.has(mode)) mode = "offline-batch"; activeMode = mode; + persistRunMode(mode); document.querySelectorAll(".mode-card").forEach((card) => { card.classList.toggle("active", card.dataset.mode === mode); }); @@ -555,7 +631,11 @@ $("pill-mode").textContent = mode === "live-rtsp" ? "链路 1 · 真摄像头" : "链路 3 · 离线精确"; refreshModeHints(); - if (mode !== "offline-batch") hideVideoBatchVisualization(); + if (mode !== "offline-batch") { + hideVideoBatchVisualization(); + liveRecordingConflict = null; + updateLiveRecordingConflictBanner(); + } refreshRecordingModesStatus(); if (mode === "live-rtsp") { rebuildPreviewGrid(); @@ -770,13 +850,22 @@ } async function refreshRecordingModesStatus() { - const url = baseUrl() + "/internal/demo/recording-modes-status"; + const sid = surgeryId(); + const query = + activeMode === "offline-batch" && /^\d{6}$/.test(sid) + ? `?surgery_id=${encodeURIComponent(sid)}` + : ""; + const url = baseUrl() + "/internal/demo/recording-modes-status" + query; try { const res = await fetch(url); const data = await res.json(); addLog("GET", url, res.status, data, { error: !res.ok }); if (res.ok) { applyRecordingConfigHints(data); + if (data.surgery_id && Object.prototype.hasOwnProperty.call(data, "live_recording_active")) { + liveRecordingConflict = data; + updateLiveRecordingConflictBanner(); + } } updateDemoModesPill(data, res.ok); } catch (e) { @@ -1054,6 +1143,12 @@ showBanner("请先选择完整 MP4 文件", "err"); return; } + try { + await ensureOfflineNotBlockedByLiveSession(sid); + } catch (e) { + showBanner(e.message || String(e), "err"); + return; + } const fd = new FormData(); fd.append("surgery_id", sid); fd.append("video1", f, f.name); @@ -1064,7 +1159,14 @@ ); const { res, body } = await apiOfflineBatch(fd); if (!res.ok) { - showBanner("离线处理失败:" + formatDetail(body), "err"); + const liveConflict = body?.detail?.code === "LIVE_RECORDING_ACTIVE"; + showBanner( + liveConflict + ? liveRecordingConflictMessage(body.detail) + : "离线处理失败:" + formatDetail(body), + "err", + ); + if (liveConflict) void refreshLiveRecordingConflict(sid); return; } lastVideoBatchDoctorDisplay = body?.doctor_display || ""; @@ -1194,7 +1296,7 @@ document.querySelectorAll(".mode-card").forEach((card) => { card.addEventListener("click", () => setActiveMode(card.dataset.mode)); }); - setActiveMode("live-rtsp"); + setActiveMode(loadSavedRunMode()); } function initOfflineUpload() { @@ -1256,6 +1358,12 @@ if (logEl) logEl.innerHTML = ""; }); $("base-url")?.addEventListener("change", refreshRecordingModesStatus); + $("surgery-id")?.addEventListener("change", () => { + if (activeMode === "offline-batch") void refreshLiveRecordingConflict(surgeryId()); + }); + $("surgery-id")?.addEventListener("blur", () => { + if (activeMode === "offline-batch") void refreshLiveRecordingConflict(surgeryId()); + }); $("camera-ids")?.addEventListener("input", () => { if (activeMode === "live-rtsp") startPreviewPolling(); }); diff --git a/clients/demo-client/index.html b/clients/demo-client/index.html index 18ce96e..1876ed4 100755 --- a/clients/demo-client/index.html +++ b/clients/demo-client/index.html @@ -44,7 +44,7 @@

选择运行模式

- @@ -55,7 +55,7 @@
-
+
正式对接链路。客户端只传 camera_ids 与耗材候选;视频由服务端从 RTSP 拉流并切片,不要在此页上传 MP4。
@@ -73,6 +73,7 @@

步骤 1 · 上传 MP4

不启动实时会话,处理完成后直接查结果。

+
MP4
点击或拖放 MP4 到此处
@@ -103,7 +104,7 @@
-
+

步骤 2 · 耗材候选(AI 白名单)

选择本次手术可能用到的耗材名称或产品编码;留空表示使用全部标签。此处不是视频上传区,请勿填写 .mp4 路径。