diff --git a/fam-core/src/fam_core/db_layer.py b/fam-core/src/fam_core/db_layer.py index 98441ee..cdad331 100644 --- a/fam-core/src/fam_core/db_layer.py +++ b/fam-core/src/fam_core/db_layer.py @@ -154,6 +154,23 @@ def get_sync_video(video_id: int) -> Optional[Dict]: conn.close() +def delete_sync_video(video_id: int): + """删除本地镜像里的这条视频(events 先删,再删 videos)。 + + 增量同步(get_sync_delta)只做 upsert,感知不到 Oracle 那边的物理删除, + 所以删除操作不能走"转发 Oracle + trigger_now() 拉增量"这条老路,必须在 + Oracle 确认删除成功后由调用方显式清理本地镜像。 + """ + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("DELETE FROM sync_events WHERE video_id=%s", (video_id,)) + cur.execute("DELETE FROM sync_videos WHERE id=%s", (video_id,)) + conn.commit() + finally: + conn.close() + + # ============================================================ # 同步镜像:sync_events # ============================================================ diff --git a/fam-core/src/fam_core/oracle_sync.py b/fam-core/src/fam_core/oracle_sync.py index 1007884..9c22ee7 100644 --- a/fam-core/src/fam_core/oracle_sync.py +++ b/fam-core/src/fam_core/oracle_sync.py @@ -169,6 +169,26 @@ class OracleSync: logger.error(f"人物纠错回推失败: {msg}") return False, msg + def push_video_delete(self, video_id: int): + """回推事件时间轴"删除该视频"到 Oracle。返回 (success: bool, error: str)。""" + try: + video_id = int(video_id) + except (TypeError, ValueError): + return False, "video_id 必须是数字" + try: + resp = requests.post( + f"{self.base_url}/api/oracle/video/delete", + json={"video_id": video_id, "token": self.token}, + timeout=(10, 30)) + except requests.RequestException as e: + logger.error(f"视频删除回推失败: {e}") + return False, str(e) + if resp.status_code == 200: + return True, "" + msg = f"HTTP {resp.status_code}: {resp.text[:200]}" + logger.error(f"视频删除回推失败: {msg}") + return False, msg + # ------------------------------------------------------------------ def _run(self): logger.info(f"OracleSync 线程启动,间隔 {self.interval_sec}s,目标 {self.base_url}") diff --git a/fam-core/src/fam_core/ui_api.py b/fam-core/src/fam_core/ui_api.py index cf8adbe..8e2de2f 100644 --- a/fam-core/src/fam_core/ui_api.py +++ b/fam-core/src/fam_core/ui_api.py @@ -1,8 +1,11 @@ """ -UI-API - Vue 前端只读数据接口(新架构 v3:Vue SPA 取代 Streamlit) +UI-API - Vue 前端数据接口(新架构 v3:Vue SPA 取代 Streamlit) -全部包装 db_layer.py 里已有的查询函数,不新写查询逻辑。人物按 canonical_name -聚合的逻辑从旧 Streamlit 版本搬过来,放在服务端做(前端只管渲染,不重复业务规则)。 +以只读查询为主,全部包装 db_layer.py 里已有的查询函数,不新写查询逻辑;人物 +按 canonical_name 聚合的逻辑从旧 Streamlit 版本搬过来,放在服务端做(前端只管 +渲染,不重复业务规则)。**2026-08-24 新增一个写操作**:`DELETE /api/ui/videos/ +`(删除视频会话),因为语义上属于 videos 这个资源,比塞进 member_manager.py +更清晰;写法沿用项目里"先回推 Oracle,成功后处理本地状态"的既有模式。 /api/ui/service-status 需要代理 Oracle 的 /api/oracle/activity(浏览器不直连 Oracle,避免 token 暴露),写法照抄 img_proxy.py 的模式:复用 oracle_sync.get_sync() @@ -62,6 +65,23 @@ def video_detail(video_id): return jsonify({"video": _ser(video), "events": _ser(events)}), 200 +@ui_bp.route('/api/ui/videos/', methods=['DELETE']) +def video_delete(video_id): + """删除视频会话(事件时间轴"删除"入口)。 + + 先回推 Oracle 物理删除(events + videos 行 + 磁盘文件),成功后再清理本地 + MariaDB 镜像——增量同步(get_sync_delta)只做 upsert 感知不到删除,不能像 + 命名纠错那样靠 trigger_now() 拉增量顺带清理,必须显式调 delete_sync_video。 + Oracle 回推失败时不清理本地镜像,避免"Oracle 还留着、NAS 却以为删了"的 + 数据不一致,让用户看错误提示后重试。 + """ + ok, err = get_sync().push_video_delete(video_id) + if not ok: + return jsonify({"error": f"删除失败: {err}"}), 502 + db_layer.delete_sync_video(video_id) + return jsonify({"status": "ok", "video_id": video_id}), 200 + + @ui_bp.route('/api/ui/stats', methods=['GET']) def stats(): """统计卡:视频/事件/人物/关注数(可选按日期过滤)。""" diff --git a/fam-edge/src/fam_edge/api_gateway/api_gateway.py b/fam-edge/src/fam_edge/api_gateway/api_gateway.py index 3c9a41b..9ea378f 100644 --- a/fam-edge/src/fam_edge/api_gateway/api_gateway.py +++ b/fam-edge/src/fam_edge/api_gateway/api_gateway.py @@ -136,6 +136,37 @@ def identity_correct(): "current_name": current_name, "new_name": new_name}), 200 +@api_bp.route('/api/oracle/video/delete', methods=['POST']) +def video_delete(): + """删除视频会话(事件时间轴"删除"入口,NAS 转发)。 + + 请求: {"video_id": 123, "token": "..."} + 删 Oracle 端 events + videos 行 + 磁盘上的运动片段文件;不清理对应的 + ss_motion_events 源事件(独立生命周期,见 oracle_db.delete_video 注释)。 + """ + if not _check_token(): + return jsonify({"error": "unauthorized"}), 401 + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Invalid JSON"}), 400 + video_id = data.get('video_id') + if not video_id: + return jsonify({"error": "缺少 video_id"}), 400 + try: + video_id = int(video_id) + except (TypeError, ValueError): + return jsonify({"error": "video_id 必须是数字"}), 400 + try: + local_path = state.get_db().delete_video(video_id) + except Exception as e: + logger.error(f"video_delete 异常: {e}") + return jsonify({"error": str(e)}), 500 + if local_path is None: + return jsonify({"error": "视频不存在"}), 404 + state.get_db().record_activity('video', 'delete', f"video_id={video_id}") + return jsonify({"status": "ok", "video_id": video_id}), 200 + + @api_bp.route('/api/edge/chat/ask', methods=['POST']) def chat_ask(): """智能问答编排:Gemini → NVIDIA → 本地 Ollama(两云端都失败才用本地兜底) diff --git a/fam-edge/src/fam_edge/oracle_db.py b/fam-edge/src/fam_edge/oracle_db.py index 31fadab..b175685 100644 --- a/fam-edge/src/fam_edge/oracle_db.py +++ b/fam-edge/src/fam_edge/oracle_db.py @@ -543,6 +543,30 @@ class OracleDB: (event_start_time, _now_iso(), video_id)) self._conn.commit() + def delete_video(self, video_id: int) -> Optional[str]: + """删除视频会话(events + videos 行)及其磁盘文件。 + + 不清理对应的 ss_motion_events 源事件——那是运动侦测硬件推送的原始记录, + 跟切出来的片段是独立生命周期;只要素材已经处理完(status='done',不再 + 被生产者重新捡起),删除片段后不会被自动重新分割。 + 返回被删视频的 local_path(不存在则返回 None,供上层判断 404)。 + """ + with self._write_lock: + row = self._conn.execute( + "SELECT local_path FROM videos WHERE id=?", (video_id,)).fetchone() + if not row: + return None + local_path = row['local_path'] + self._conn.execute("DELETE FROM events WHERE video_id=?", (video_id,)) + self._conn.execute("DELETE FROM videos WHERE id=?", (video_id,)) + self._conn.commit() + if local_path and os.path.isfile(local_path): + try: + os.remove(local_path) + except OSError as e: + logger.warning(f"删除视频文件失败 {local_path}: {e}") + return local_path or '' + def mark_video_invalid(self, video_id: int, error: str = ''): """文件校验不通过(损坏/非视频等),标记 invalid,producer 不再重试。""" now = _now_iso() diff --git a/fam-edge/tests/test_oracle_db.py b/fam-edge/tests/test_oracle_db.py index c008ce9..a4fb008 100644 --- a/fam-edge/tests/test_oracle_db.py +++ b/fam-edge/tests/test_oracle_db.py @@ -363,3 +363,64 @@ def test_correct_video_identity_without_prior_mapping_uses_current_name_as_raw_u rows = db._conn.execute( "SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall() assert json.loads(rows[0]["person_list_json"]) == ["爸爸"] + + +def test_delete_video_removes_video_and_events_rows(tmp_path): + db = _db(tmp_path) + vid = _seed_video_with_events(db) + + local_path = db.delete_video(vid) + + assert local_path == f"/tmp/motion_1_1000.mp4" + assert db._conn.execute("SELECT * FROM videos WHERE id=?", (vid,)).fetchone() is None + assert db._conn.execute("SELECT * FROM events WHERE video_id=?", (vid,)).fetchall() == [] + + +def test_delete_video_removes_disk_file(tmp_path): + db = _db(tmp_path) + clip_path = tmp_path / "motion_9_2000.mp4" + clip_path.write_bytes(b"fake mp4 bytes") + vid = db.ensure_video("motion_9_2000.mp4", str(clip_path), event_start_time="2026-08-22 10:00:00") + db.mark_video_processed(vid, "摘要", [], [], "gemini") + + db.delete_video(vid) + + assert not clip_path.exists() + + +def test_delete_video_missing_file_on_disk_does_not_raise(tmp_path): + """核心诉求: local_path 指向的文件已经不存在(比如手动清理过)时,删除记录 + 本身不能因为 os.remove 报错而失败——文件缺失不是数据库操作的错误。""" + db = _db(tmp_path) + vid = db.ensure_video("motion_9_2000.mp4", str(tmp_path / "already_gone.mp4"), + event_start_time="2026-08-22 10:00:00") + db.mark_video_processed(vid, "摘要", [], [], "gemini") + + local_path = db.delete_video(vid) + + assert local_path == str(tmp_path / "already_gone.mp4") + assert db._conn.execute("SELECT * FROM videos WHERE id=?", (vid,)).fetchone() is None + + +def test_delete_video_nonexistent_returns_none(tmp_path): + db = _db(tmp_path) + assert db.delete_video(99999) is None + + +def test_delete_video_does_not_touch_ss_motion_events(tmp_path): + """核心诉求: ss_motion_events 是运动侦测源事件,跟切出来的视频片段生命周期 + 独立,删视频不该连带删掉源事件(否则分割逻辑的幂等判断会被破坏)。""" + db = _db(tmp_path) + db.record_motion_events([ + {"event_id": 555, "camera_id": 2, "event_type": 10, + "start_time": 1700000000, "duration": 10, "thumbnail_url": ""}, + ]) + vid = db.ensure_video("motion_555_1700000000.mp4", "/tmp/motion_555_1700000000.mp4", + event_start_time="2026-08-22 10:00:00", motion_event_id=555) + db.mark_video_processed(vid, "摘要", [], [], "gemini") + + db.delete_video(vid) + + assert db.get_video_by_motion_event_id(555) is None + row = db._conn.execute("SELECT * FROM ss_motion_events WHERE event_id=?", (555,)).fetchone() + assert row is not None diff --git a/fam-ui/src/api.js b/fam-ui/src/api.js index 73655b8..d137eef 100644 --- a/fam-ui/src/api.js +++ b/fam-ui/src/api.js @@ -33,6 +33,7 @@ export const api = { videos: (params = {}) => request(`/api/ui/videos${qs(params)}`), videoDetail: (id) => request(`/api/ui/videos/${id}`), + deleteVideo: (id) => request(`/api/ui/videos/${id}`, { method: 'DELETE' }), stats: (date) => request(`/api/ui/stats${date ? '?date=' + date : ''}`), people: () => request('/api/ui/people'), peopleClips: (label, limit = 10) => request(`/api/ui/people/clips?label=${encodeURIComponent(label)}&limit=${limit}`), diff --git a/fam-ui/src/views/Timeline.vue b/fam-ui/src/views/Timeline.vue index 721d965..7fdf44e 100644 --- a/fam-ui/src/views/Timeline.vue +++ b/fam-ui/src/views/Timeline.vue @@ -94,6 +94,28 @@ const modelBadges = computed(() => { const provider = detail.value?.video?.compute_provider || '' return provider ? String(provider).split(',').map(p => p.trim()).filter(Boolean) : [] }) + +const deleteBusy = ref(false) +const deleteError = ref('') + +async function deleteVideo(id) { + if (!confirm('确定删除这个视频会话吗?此操作不可恢复,会同时删除对应的视频文件。')) return + deleteBusy.value = true + deleteError.value = '' + try { + await api.deleteVideo(id) + videos.value = videos.value.filter(v => v.id !== id) + if (selectedId.value === id) { + selectedId.value = videos.value.length ? videos.value[0].id : null + if (!selectedId.value) detail.value = null + } + loadStats() + } catch (e) { + deleteError.value = e.message + } finally { + deleteBusy.value = false + } +}