feat(timeline): 事件时间轴支持删除视频会话
三端联动实现(fam-ui -> fam-core -> fam-edge),沿用项目里"NAS 转发写请求 到 Oracle"的既有模式(照抄 identity_correct 那套骨架): - fam-edge:oracle_db.py 新增 delete_video()(删 events+videos 行 + 磁盘上 的运动片段文件;不清理 ss_motion_events 源事件,因为素材一旦处理完就不会 被生产者重新捡起,删片段不会触发重新分割);api_gateway.py 新增 POST /api/oracle/video/delete - fam-core:新增 db_layer.delete_sync_video()(第一个"NAS 直接写自己镜像表" 的函数——增量同步只做 upsert 感知不到删除,不能像纠错那样靠 trigger_now() 拉增量顺带清理)+ oracle_sync.push_video_delete() + ui_api.py 新增 DELETE /api/ui/videos/<id>(先回推 Oracle,成功后才清本地镜像,避免数据 不一致) - fam-ui:Timeline.vue 详情卡片加删除按钮(原生 confirm() 二次确认,项目里 之前没有确认弹窗组件先例);api.js 新增 deleteVideo() 新增 6 个 delete_video 单元测试;已在 Oracle/NAS 用自造测试数据完整跑通端 到端链路(转发成功、两端记录清理、磁盘文件删除、幂等 404),未触碰任何真 实监控数据。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -154,6 +154,23 @@ def get_sync_video(video_id: int) -> Optional[Dict]:
|
|||||||
conn.close()
|
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
|
# 同步镜像:sync_events
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -169,6 +169,26 @@ class OracleSync:
|
|||||||
logger.error(f"人物纠错回推失败: {msg}")
|
logger.error(f"人物纠错回推失败: {msg}")
|
||||||
return False, 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):
|
def _run(self):
|
||||||
logger.info(f"OracleSync 线程启动,间隔 {self.interval_sec}s,目标 {self.base_url}")
|
logger.info(f"OracleSync 线程启动,间隔 {self.interval_sec}s,目标 {self.base_url}")
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
UI-API - Vue 前端只读数据接口(新架构 v3:Vue SPA 取代 Streamlit)
|
UI-API - Vue 前端数据接口(新架构 v3:Vue SPA 取代 Streamlit)
|
||||||
|
|
||||||
全部包装 db_layer.py 里已有的查询函数,不新写查询逻辑。人物按 canonical_name
|
以只读查询为主,全部包装 db_layer.py 里已有的查询函数,不新写查询逻辑;人物
|
||||||
聚合的逻辑从旧 Streamlit 版本搬过来,放在服务端做(前端只管渲染,不重复业务规则)。
|
按 canonical_name 聚合的逻辑从旧 Streamlit 版本搬过来,放在服务端做(前端只管
|
||||||
|
渲染,不重复业务规则)。**2026-08-24 新增一个写操作**:`DELETE /api/ui/videos/
|
||||||
|
<id>`(删除视频会话),因为语义上属于 videos 这个资源,比塞进 member_manager.py
|
||||||
|
更清晰;写法沿用项目里"先回推 Oracle,成功后处理本地状态"的既有模式。
|
||||||
|
|
||||||
/api/ui/service-status 需要代理 Oracle 的 /api/oracle/activity(浏览器不直连
|
/api/ui/service-status 需要代理 Oracle 的 /api/oracle/activity(浏览器不直连
|
||||||
Oracle,避免 token 暴露),写法照抄 img_proxy.py 的模式:复用 oracle_sync.get_sync()
|
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
|
return jsonify({"video": _ser(video), "events": _ser(events)}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@ui_bp.route('/api/ui/videos/<int:video_id>', 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'])
|
@ui_bp.route('/api/ui/stats', methods=['GET'])
|
||||||
def stats():
|
def stats():
|
||||||
"""统计卡:视频/事件/人物/关注数(可选按日期过滤)。"""
|
"""统计卡:视频/事件/人物/关注数(可选按日期过滤)。"""
|
||||||
|
|||||||
@@ -136,6 +136,37 @@ def identity_correct():
|
|||||||
"current_name": current_name, "new_name": new_name}), 200
|
"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'])
|
@api_bp.route('/api/edge/chat/ask', methods=['POST'])
|
||||||
def chat_ask():
|
def chat_ask():
|
||||||
"""智能问答编排:Gemini → NVIDIA → 本地 Ollama(两云端都失败才用本地兜底)
|
"""智能问答编排:Gemini → NVIDIA → 本地 Ollama(两云端都失败才用本地兜底)
|
||||||
|
|||||||
@@ -543,6 +543,30 @@ class OracleDB:
|
|||||||
(event_start_time, _now_iso(), video_id))
|
(event_start_time, _now_iso(), video_id))
|
||||||
self._conn.commit()
|
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 = ''):
|
def mark_video_invalid(self, video_id: int, error: str = ''):
|
||||||
"""文件校验不通过(损坏/非视频等),标记 invalid,producer 不再重试。"""
|
"""文件校验不通过(损坏/非视频等),标记 invalid,producer 不再重试。"""
|
||||||
now = _now_iso()
|
now = _now_iso()
|
||||||
|
|||||||
@@ -363,3 +363,64 @@ def test_correct_video_identity_without_prior_mapping_uses_current_name_as_raw_u
|
|||||||
rows = db._conn.execute(
|
rows = db._conn.execute(
|
||||||
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
|
"SELECT person_list_json FROM events WHERE video_id=?", (vid,)).fetchall()
|
||||||
assert json.loads(rows[0]["person_list_json"]) == ["爸爸"]
|
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
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const api = {
|
|||||||
|
|
||||||
videos: (params = {}) => request(`/api/ui/videos${qs(params)}`),
|
videos: (params = {}) => request(`/api/ui/videos${qs(params)}`),
|
||||||
videoDetail: (id) => request(`/api/ui/videos/${id}`),
|
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 : ''}`),
|
stats: (date) => request(`/api/ui/stats${date ? '?date=' + date : ''}`),
|
||||||
people: () => request('/api/ui/people'),
|
people: () => request('/api/ui/people'),
|
||||||
peopleClips: (label, limit = 10) => request(`/api/ui/people/clips?label=${encodeURIComponent(label)}&limit=${limit}`),
|
peopleClips: (label, limit = 10) => request(`/api/ui/people/clips?label=${encodeURIComponent(label)}&limit=${limit}`),
|
||||||
|
|||||||
@@ -94,6 +94,28 @@ const modelBadges = computed(() => {
|
|||||||
const provider = detail.value?.video?.compute_provider || ''
|
const provider = detail.value?.video?.compute_provider || ''
|
||||||
return provider ? String(provider).split(',').map(p => p.trim()).filter(Boolean) : []
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -143,9 +165,14 @@ const modelBadges = computed(() => {
|
|||||||
<span>{{ detail.video.camera_name || '未知摄像头' }}</span>
|
<span>{{ detail.video.camera_name || '未知摄像头' }}</span>
|
||||||
<Badge tone="ok">会话 #{{ detail.video.id }}</Badge>
|
<Badge tone="ok">会话 #{{ detail.video.id }}</Badge>
|
||||||
<Badge v-for="m in modelBadges" :key="m" tone="neutral">{{ m }}</Badge>
|
<Badge v-for="m in modelBadges" :key="m" tone="neutral">{{ m }}</Badge>
|
||||||
|
<button :disabled="deleteBusy" @click="deleteVideo(detail.video.id)"
|
||||||
|
class="ml-auto rounded-lg border border-danger/35 bg-danger/14 px-2.5 py-1 text-xs font-medium text-danger transition-colors hover:bg-danger/25 disabled:opacity-40">
|
||||||
|
{{ deleteBusy ? '删除中…' : '🗑 删除会话' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1.5 font-mono text-[13px] text-text-dim tabular">⏱ {{ rangeStr }} · 文件 {{ detail.video.filename }}</div>
|
<div class="mt-1.5 font-mono text-[13px] text-text-dim tabular">⏱ {{ rangeStr }} · 文件 {{ detail.video.filename }}</div>
|
||||||
<div class="mt-2.5 whitespace-pre-wrap text-sm leading-relaxed text-[#d6dce6]">{{ detail.video.summary_json || '暂无全局摘要' }}</div>
|
<div class="mt-2.5 whitespace-pre-wrap text-sm leading-relaxed text-[#d6dce6]">{{ detail.video.summary_json || '暂无全局摘要' }}</div>
|
||||||
|
<div v-if="deleteError" class="mt-2.5 rounded-lg border border-danger/35 bg-danger/14 px-3 py-2 text-xs text-danger">{{ deleteError }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmptyState v-if="!detail.events.length" icon="🎞" text="该会话暂无时间点事件" />
|
<EmptyState v-if="!detail.events.length" icon="🎞" text="该会话暂无时间点事件" />
|
||||||
|
|||||||
Reference in New Issue
Block a user