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:
7
fam-notifier/tests/conftest.py
Normal file
7
fam-notifier/tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 让测试能直接 `from fam_notifier.xxx import yyy`,无需先 pip install -e .
|
||||
_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
114
fam-notifier/tests/test_notifier.py
Normal file
114
fam-notifier/tests/test_notifier.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import time
|
||||
|
||||
from fam_notifier.notifier import MotionNotifier
|
||||
|
||||
|
||||
def _cfg(**overrides):
|
||||
base = {
|
||||
"enabled": True,
|
||||
"poll_enabled": False,
|
||||
"dsm_host": "192.168.50.64",
|
||||
"dsm_port": 5000,
|
||||
"dsm_account": "ericwyuan",
|
||||
"dsm_password": "secret",
|
||||
"oracle_base_url": "http://oracle.example:5000",
|
||||
"oracle_token": "tok123",
|
||||
"heartbeat_interval_sec": 300,
|
||||
"timeout_sec": 5,
|
||||
}
|
||||
base.update(overrides)
|
||||
return {"motion_notifier": base}
|
||||
|
||||
|
||||
def _make_notifier(monkeypatch, **cfg_overrides):
|
||||
monkeypatch.setattr(
|
||||
"fam_notifier.notifier.load_config",
|
||||
lambda: _cfg(**cfg_overrides))
|
||||
return MotionNotifier()
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status_code=200, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload or {}
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 心跳(跟轮询无关,enabled=true 就该跑)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
def test_send_heartbeat_success_updates_state(monkeypatch):
|
||||
n = _make_notifier(monkeypatch)
|
||||
calls = []
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
calls.append((url, json))
|
||||
return _FakeResp(200, {"status": "ok", "received": 0, "stored": 0})
|
||||
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
|
||||
ok = n.send_heartbeat()
|
||||
assert ok is True
|
||||
assert n._last_heartbeat_at is not None
|
||||
assert n._last_heartbeat_error is None
|
||||
assert calls[0][0] == "http://oracle.example:5000/api/ss/motion"
|
||||
assert calls[0][1]["events"] == []
|
||||
assert calls[0][1]["token"] == "tok123"
|
||||
|
||||
|
||||
def test_send_heartbeat_http_error_records_failure(monkeypatch):
|
||||
n = _make_notifier(monkeypatch)
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
return _FakeResp(500, {}, text="boom")
|
||||
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
|
||||
ok = n.send_heartbeat()
|
||||
assert ok is False
|
||||
assert n._last_heartbeat_error == "HTTP 500"
|
||||
|
||||
|
||||
def test_send_heartbeat_network_exception_records_failure(monkeypatch):
|
||||
import requests as _requests
|
||||
n = _make_notifier(monkeypatch)
|
||||
|
||||
def fake_post(url, json=None, timeout=None):
|
||||
raise _requests.RequestException("connection refused")
|
||||
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
|
||||
ok = n.send_heartbeat()
|
||||
assert ok is False
|
||||
assert "connection refused" in n._last_heartbeat_error
|
||||
|
||||
|
||||
def test_start_runs_heartbeat_thread_even_when_poll_disabled(monkeypatch):
|
||||
"""核心诉求: 心跳跟"是否轮询 SS"是两回事——poll_enabled=false 时轮询线程
|
||||
不应该启动,但心跳线程必须照样跑,否则甲骨文永远收不到心跳,
|
||||
has_motion_in_range_local() 会一直 fail-open,省配额的效果就没了。"""
|
||||
n = _make_notifier(monkeypatch, poll_enabled=False, heartbeat_interval_sec=3600)
|
||||
monkeypatch.setattr(
|
||||
"fam_notifier.notifier.requests.post",
|
||||
lambda url, json=None, timeout=None: _FakeResp(200, {"stored": 0}))
|
||||
try:
|
||||
n.start()
|
||||
time.sleep(0.2)
|
||||
assert n.is_alive() is False # 轮询线程未启动
|
||||
assert n._hb_thread is not None and n._hb_thread.is_alive()
|
||||
finally:
|
||||
n.stop()
|
||||
|
||||
|
||||
def test_start_does_nothing_when_disabled(monkeypatch):
|
||||
n = _make_notifier(monkeypatch, enabled=False)
|
||||
n.start()
|
||||
time.sleep(0.1)
|
||||
assert n._hb_thread is None
|
||||
assert n.is_alive() is False
|
||||
|
||||
|
||||
def test_status_includes_heartbeat_fields(monkeypatch):
|
||||
n = _make_notifier(monkeypatch)
|
||||
st = n.status()
|
||||
assert "heartbeat_running" in st
|
||||
assert "last_heartbeat_at" in st
|
||||
assert "last_heartbeat_error" in st
|
||||
Reference in New Issue
Block a user