refactor: 全量迁云——fam-core 直读甲骨文 SQLite,NAS 只剩推送进程

前一天刚把登录迁到甲骨文,隔天 NAS 上的 fam-core 又挂了导致数据接口 502。
盘点后确认:甲骨文的 SQLite 才是权威数据源(videos 3113 / events 16159 /
people 60 / model_calls 9876),NAS 的 MariaDB 全是它的镜像——前端读的数据
本来就产自甲骨文,绕了一圈回家又绕回来。

改动:
- db_layer.py 从 725 行重写成 377 行:MySQL 镜像查询改为直读 fam-edge 的
  SQLite。5 个 upsert_sync_*(约 300 行去重逻辑,8/29 和 9/3 两次 1062 事故的
  发源地)连同 oracle_sync.py 整个删除。SQL 方言:JSON_CONTAINS -> json_each
  (前置 json_valid,历史脏数据不会把查询搞崩)、LEFT() -> substr()、%s -> ?。
  函数名 get_sync_* 一并改掉——已经没有 sync 这回事了
- 新增 edge_client.py:写操作(改名/删除)、帧图头像、服务状态都打给同机
  fam-edge,全走 127.0.0.1
- 新增 fam-notifier/:motion_notifier 从 fam-core 拆出独立成服务,游标从
  MariaDB 换成本地 JSON 文件。NAS 上从此没有 Flask、没有数据库、没有监听端口
- fam-core 移到甲骨文 /opt/fam-core(systemd,gunicorn -w 2,只绑
  127.0.0.1:5401——5400 被 chat-relay 占了)。Caddy 的 /api/* 从"frp 隧道
  回源 NAS"改成同机反代,forward_auth 闸门不变
- 前端删掉侧边栏同步面板、统计页同步状态、服务状态页的"NAS 同步"卡片与
  "立即同步"按钮(背后的镜像层已不存在);换成"NAS 运动推送"卡片,读
  fam-edge activity 新增的 motion 段(心跳年龄 + 最近事件)
- 顺带修掉一个隐蔽 bug:镜像表为保外键稳定用的是 NAS 本地自增 id,而帧图接口
  要的是甲骨文的 id,两边在 9/3 那次 id 重排后就对不上了。现在只有一套 id

测试:fam-core 21(新增 12 个 db_layer 用例:脏 JSON 不崩、人物精确匹配不误伤
"人物B"、日期过滤、统计口径、chat_history 懒建表)、fam-notifier 6、
fam-edge 157,全绿。

生产验证:甲骨文 /api/ui/stats 返回 videos 2965 / events 16159 / people 59;
NAS 侧 fam-notifier 已推送成功(事件 33070-33072 落库,心跳新鲜);
chat_history 19 条经 scripts/import_chat_history.py 迁移完成。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-09-13 08:03:32 +08:00
parent dc284f4bf5
commit 1964e976f4
33 changed files with 1178 additions and 1187 deletions

View File

@@ -0,0 +1,155 @@
"""db_layer 直读 SQLite 的单测2026-09-13 迁云重写后新增)。
重点验证从 MySQL 翻译过来的几处方言json_each 取代 JSON_CONTAINS、
substr 取代 LEFT、以及历史脏数据person_list_json 不是合法 JSON不能
把整条查询搞崩——这在 MySQL 下 JSON_CONTAINS 会直接报错SQLite 下
json_each 同样会,所以查询里加了 json_valid 前置判断。
"""
import sqlite3
import pytest
from fam_core import db_layer
# fam-edge 建表语句的最小子集(列名与 oracle_db.py 保持一致)
_SCHEMA = """
CREATE TABLE videos (
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE, camera_name TEXT,
duration_sec REAL, event_start_time TEXT, status TEXT DEFAULT 'pending',
summary_json TEXT, events_json TEXT, people_json TEXT, compute_provider TEXT,
created_at TEXT, updated_at TEXT, processed_at TEXT);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER, ts TEXT, description TEXT,
person_list_json TEXT, person_appearances_json TEXT, is_attention_event INTEGER DEFAULT 0);
CREATE TABLE people (
id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT UNIQUE, canonical_name TEXT,
first_seen TEXT, appearances INTEGER DEFAULT 0, source TEXT, features_json TEXT,
display_uid TEXT, updated_at TEXT);
CREATE TABLE model_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT, model TEXT, video_id INTEGER,
filename TEXT, started_at TEXT, duration_sec REAL, success INTEGER, error TEXT,
created_at TEXT);
"""
@pytest.fixture
def db(tmp_path, monkeypatch):
path = tmp_path / "oracle.db"
conn = sqlite3.connect(path)
conn.executescript(_SCHEMA)
conn.executescript("""
INSERT INTO videos (id, filename, camera_name, event_start_time, status, processed_at)
VALUES (1, 'motion_20260913_080000.mp4', '客厅', '2026-09-13 08:00:00', 'done', '2026-09-13 08:10:00'),
(2, 'whole_20260912_090000.mp4', '客厅', '2026-09-12 09:00:00', 'done', '2026-09-12 09:10:00'),
(3, 'whole_20260911_090000.mp4', '客厅', '2026-09-11 09:00:00', 'done', '2026-09-11 09:10:00'),
(4, 'motion_20260910_070000.mp4', '客厅', '2026-09-10 07:00:00', 'pending', NULL);
-- video 2 有事件应展示video 3 没有事件且不是 motion_应被过滤掉
INSERT INTO events (video_id, ts, description, person_list_json, is_attention_event)
VALUES (1, '00:00:05', '汤圆在客厅玩耍', '["汤圆"]', 0),
(1, '00:00:20', '有人靠近门口', '["人物B"]', 1),
(2, '00:01:00', '汤圆和奶奶', '["汤圆","奶奶"]', 0),
(2, '00:02:00', '脏数据事件', '不是合法JSON', 0);
INSERT INTO people (id, label, canonical_name, appearances, source)
VALUES (1, '人物A', '汤圆', 12, 'manual'),
(2, '人物B', NULL, 3, 'llm'),
(3, '汤圆', '汤圆', 5, 'manual');
INSERT INTO model_calls (provider, model, success, duration_sec, created_at)
VALUES ('gemini', 'flash', 1, 10.0, '2026-09-13 08:10:00'),
('gemini', 'flash', 0, 20.0, '2026-09-13 08:20:00'),
('nvidia', 'vila', 1, 5.0, '2026-09-12 08:10:00');
""")
conn.commit()
conn.close()
monkeypatch.setattr(db_layer, '_db_path', str(path))
monkeypatch.setattr(db_layer, '_chat_schema_ready', False)
return path
# ---------------------------------------------------------------- videos
def test_get_videos_only_returns_content_sessions(db):
"""只展示运动片段或含事件的会话:分割 0 段的整段素材不该淹没时间轴。"""
ids = [v['id'] for v in db_layer.get_videos()]
assert ids == [1, 2] # 3 无事件且非 motion_4 未完成
assert db_layer.get_videos()[0]['event_count'] == 2
def test_get_videos_date_filter_and_paging(db):
assert [v['id'] for v in db_layer.get_videos(date_filter='2026-09-12')] == [2]
assert [v['id'] for v in db_layer.get_videos(limit=1, offset=1)] == [2]
def test_get_video_and_events(db):
assert db_layer.get_video(1)['filename'].startswith('motion_')
assert db_layer.get_video(999) is None
evs = db_layer.get_events_for_video(1)
assert [e['ts'] for e in evs] == ['00:00:05', '00:00:20']
def test_get_attention_events(db):
rows = db_layer.get_attention_events()
assert len(rows) == 1 and rows[0]['ev_date'].startswith('2026-09-13')
# ---------------------------------------------------------------- 人物命中JSON
def test_people_clips_matches_label_and_its_aliases(db):
"""'人物A' 的规范名是"汤圆",而事件里记的是"汤圆"——要能顺着别名找到。"""
clips = db_layer.get_people_clips('人物A')
assert {c['video_id'] for c in clips} == {1, 2}
assert clips[0]['video_id'] == 1 # 按录制时间倒序
assert clips[0]['first_ts'] == '00:00:05'
assert clips[0]['clip_events'] == 1
def test_person_hit_is_exact_not_substring(db):
"""json_each 是精确匹配:查"人物"不该命中"人物B""""
assert db_layer.get_people_clips('人物') == []
assert {c['video_id'] for c in db_layer.get_people_clips('人物B')} == {1}
def test_malformed_person_json_does_not_break_queries(db):
"""video 2 里混了一条非 JSON 的脏数据,查询必须照常返回而不是整条报错。"""
rows = db_layer.query_events_for_person_date('汤圆', '2026-09-12')
assert len(rows) == 1 and rows[0]['ts'] == '00:01:00'
def test_query_events_for_person_date(db):
assert db_layer.query_events_for_person_date('汤圆', '2026-09-13')[0]['description'] == '汤圆在客厅玩耍'
assert db_layer.query_events_for_person_date('汤圆', '2026-09-01') == []
# ---------------------------------------------------------------- people / model_calls
def test_people_and_named_members(db):
assert len(db_layer.get_people()) == 3
assert db_layer.get_named_members() == ['汤圆'] # distinct 且非空
ctx = db_layer.get_known_members_context()
assert '- 汤圆标识人物A' in ctx and '- 人物B' in ctx
def test_model_calls_stats(db):
stats = {s['model']: s for s in db_layer.get_model_calls_stats()}
assert stats['flash']['ok_cnt'] == 1 and stats['flash']['fail_cnt'] == 1
assert stats['flash']['avg_duration'] == 15.0
assert len(db_layer.get_model_calls(limit=2)) == 2
# ---------------------------------------------------------------- 统计
def test_stats_全量与按日(db):
total = db_layer.get_stats()
assert total['videos'] == 2 and total['events'] == 4 and total['attention'] == 1
day = db_layer.get_stats('2026-09-13')
assert day['videos'] == 1 and day['events'] == 2
# 人物数:汤圆(含 label 人物A/汤圆两行都归一到"汤圆"+ 人物B
assert total['people'] >= 2
# ---------------------------------------------------------------- chat_history
def test_chat_history_roundtrip_creates_table_on_demand(db):
"""chat_history 是本模块唯一写的表,建表是懒加载的(库文件属于 fam-edge"""
chat_id = db_layer.insert_chat_history('今天有人来吗', '08:00 有人靠近门口',
'上下文', '2026-09-13', '汤圆')
assert chat_id == 1
rows = db_layer.get_chat_history(limit=10)
assert rows[0]['user_question'] == '今天有人来吗'
assert rows[0]['created_at']
assert db_layer.get_chat_history(person_filter='不存在的人') == []
assert len(db_layer.get_chat_history(date_filter='2026-09-13')) == 1