refactor(fam-core): 移除 SS Webhook 可选路径与 NAS 静态前端托管,兜底 Oracle 地址更新为 129.146.26.249

This commit is contained in:
ericwyuan
2026-08-25 23:56:02 +08:00
parent 7948f55b02
commit cf3d145c57
9 changed files with 19 additions and 329 deletions

View File

@@ -22,7 +22,7 @@ database:
# 甲骨文同步(每 30 分钟拉增量镜像) # 甲骨文同步(每 30 分钟拉增量镜像)
oracle_sync: oracle_sync:
# FAM-Edge 对外同步接口地址(端口同其 server.port=5000 # FAM-Edge 对外同步接口地址(端口同其 server.port=5000
base_url: "http://129.146.203.203:5000" base_url: "http://129.146.26.249:5000"
# 与 Oracle 端 sync_api.token 一致(环境变量注入,避免明文入库) # 与 Oracle 端 sync_api.token 一致(环境变量注入,避免明文入库)
token: "${ORACLE_SYNC_TOKEN}" token: "${ORACLE_SYNC_TOKEN}"
interval_sec: 1800 # 拉取间隔(秒),默认 30 分钟 interval_sec: 1800 # 拉取间隔(秒),默认 30 分钟
@@ -30,14 +30,13 @@ oracle_sync:
chat_handler: chat_handler:
# 智能问答统一走 FAM-Edge 编排端点Gemini → NVIDIA → 本地 Ollama 兜底) # 智能问答统一走 FAM-Edge 编排端点Gemini → NVIDIA → 本地 Ollama 兜底)
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask" qa_url: "http://129.146.26.249:5000/api/edge/chat/ask"
qa_stream_url: "http://129.146.203.203:5000/api/edge/chat/ask/stream" # 流式版 qa_stream_url: "http://129.146.26.249:5000/api/edge/chat/ask/stream" # 流式版
timeout: 120 timeout: 120
# 运动监测通知服务2026-08-22 定稿:轮询主路径) # 运动监测通知服务2026-08-22 定稿:轮询主路径2026-08-25 移除 Webhook 可选路径
# NAS 本机轮询 SS EventCenter.Event.List真实 event_id/start_time/duration # NAS 本机轮询 SS EventCenter.Event.List真实 event_id/start_time/duration
# 增量推送到甲骨文 FAM-Edge单向 NAS -> Oracle甲骨文不再反向访问 NAS # 增量推送到甲骨文 FAM-Edge单向 NAS -> Oracle甲骨文不再反向访问 NAS
# Webhook/api/ss/webhook保留为可选低延迟补充SS 行动规则未配置则不触发。
# SS 凭据走环境变量(${DSM_ACCOUNT}/${DSM_PASSWORD}),由启动脚本 source 的 .env 提供。 # SS 凭据走环境变量(${DSM_ACCOUNT}/${DSM_PASSWORD}),由启动脚本 source 的 .env 提供。
motion_notifier: motion_notifier:
enabled: true enabled: true
@@ -47,10 +46,7 @@ motion_notifier:
dsm_account: "${DSM_ACCOUNT}" dsm_account: "${DSM_ACCOUNT}"
dsm_password: "${DSM_PASSWORD}" dsm_password: "${DSM_PASSWORD}"
camera_ids: [2] # 轮询关注的摄像头Generic_ONVIF-001 camera_ids: [2] # 轮询关注的摄像头Generic_ONVIF-001
# 摄像头名 -> camera_id 静态映射Webhook 路径用;启动时还会从 SS 拉一份补全) oracle_base_url: "http://129.146.26.249:5000" # 与 oracle_sync.base_url 一致
camera_name_to_id:
"Generic_ONVIF-001": 2
oracle_base_url: "http://129.146.203.203:5000" # 与 oracle_sync.base_url 一致
oracle_token: "${ORACLE_SYNC_TOKEN}" # 与 oracle_sync.token 一致 oracle_token: "${ORACLE_SYNC_TOKEN}" # 与 oracle_sync.token 一致
timeout_sec: 10 # 单次 SS 请求超时 timeout_sec: 10 # 单次 SS 请求超时
# 心跳:跟轮询 SS 无关,只是定期空 POST 一下甲骨文的 /api/ss/motion证明 # 心跳:跟轮询 SS 无关,只是定期空 POST 一下甲骨文的 /api/ss/motion证明
@@ -62,7 +58,3 @@ motion_notifier:
poll_interval_sec: 60 # 轮询间隔 poll_interval_sec: 60 # 轮询间隔
poll_window_hours: 2 # 每轮回看窗口(小时),覆盖轮询间隔内的新事件 poll_window_hours: 2 # 每轮回看窗口(小时),覆盖轮询间隔内的新事件
batch_size: 100 # 单批推送上限 batch_size: 100 # 单批推送上限
# Webhook 可选补全(仅 webhook 路径用):收到 webhook 后,以 camera_id+触发时刻
# 为中心开此时间窗(秒)回头查 SS 事件列表,匹配最接近的运动事件回填真实
# start_time/duration/event_idWebhook 本身不提供这些字段)。
enrich_window_sec: 120

View File

@@ -23,22 +23,19 @@ from .member_manager.member_manager import member_bp
from .img_proxy import img_bp from .img_proxy import img_bp
from .motion_bp import motion_bp from .motion_bp import motion_bp
from .ui_api import ui_bp from .ui_api import ui_bp
from .static_app import static_bp
from .auth import auth_bp, init_auth from .auth import auth_bp, init_auth
logger = setup_logger('fam-core.app') logger = setup_logger('fam-core.app')
app = Flask(__name__) app = Flask(__name__)
# 注册蓝图:/api/* 系列必须先于 static_bp 注册——static_bp 是通配兜底路由 # 注册蓝图:全部 /api/* 路由(前端已迁云服务器 Caddy:80NAS 不再托管 SPA 静态文件)
# Vue Router history 模式回退 index.html排在前面会吞掉 API 请求。
app.register_blueprint(chat_bp) app.register_blueprint(chat_bp)
app.register_blueprint(member_bp) app.register_blueprint(member_bp)
app.register_blueprint(img_bp) app.register_blueprint(img_bp)
app.register_blueprint(motion_bp) app.register_blueprint(motion_bp)
app.register_blueprint(ui_bp) app.register_blueprint(ui_bp)
app.register_blueprint(auth_bp) # 登录页 /api/login 等static_bp 通配之前) app.register_blueprint(auth_bp)
app.register_blueprint(static_bp)
# 登录校验全局拦截(页面未登录 302 /login/api/* 未登录 401 # 登录校验全局拦截(页面未登录 302 /login/api/* 未登录 401
init_auth(app) init_auth(app)
@@ -64,17 +61,11 @@ try:
except Exception as e: except Exception as e:
logger.error(f"Oracle-Sync 启动失败: {e}") logger.error(f"Oracle-Sync 启动失败: {e}")
# 初始化运动监测通知服务(Webhook 驱动,不轮询;提供推送客户端 + 摄像头名映射 # 初始化运动监测通知服务(NAS 轮询 SS EventCenter 主路径
_notifier = None _notifier = None
try: try:
_notifier = get_motion_notifier() _notifier = get_motion_notifier()
_notifier.start() _notifier.start()
# 启动时一次性从 SS 拉取摄像头名->id 映射非轮询失败仅告警Webhook 仍可运行
try:
_notifier.refresh_camera_map()
logger.info("MotionNotifier 摄像头映射已加载")
except Exception as e:
logger.warning(f"摄像头映射加载失败Webhook 仍可运行camera_id 可能为空): {e}")
logger.info("MotionNotifier 已初始化") logger.info("MotionNotifier 已初始化")
except Exception as e: except Exception as e:
logger.error(f"MotionNotifier 初始化失败: {e}") logger.error(f"MotionNotifier 初始化失败: {e}")

View File

@@ -73,7 +73,6 @@ def is_authed() -> bool:
_WHITELIST_EXACT = { _WHITELIST_EXACT = {
'/login', '/api/login', '/api/logout', '/api/auth/check', '/login', '/api/login', '/api/logout', '/api/auth/check',
'/health', '/favicon.ico', '/health', '/favicon.ico',
'/api/ss/webhook', # SS 推送(无登录态,必须放行)
} }
_WHITELIST_PREFIX = ('/assets/',) _WHITELIST_PREFIX = ('/assets/',)

View File

@@ -47,7 +47,7 @@ def _call_edge_qa(prompt: str) -> str:
"""调用 FAM-Edge 问答编排端点Gemini → NVIDIA → 本地 Ollama 兜底)""" """调用 FAM-Edge 问答编排端点Gemini → NVIDIA → 本地 Ollama 兜底)"""
cfg = load_config() cfg = load_config()
qa_url = cfg.get('chat_handler', {}).get( qa_url = cfg.get('chat_handler', {}).get(
'qa_url', 'http://129.146.203.203:5000/api/edge/chat/ask' 'qa_url', 'http://129.146.26.249:5000/api/edge/chat/ask'
) )
timeout = cfg.get('chat_handler', {}).get('timeout', 120) timeout = cfg.get('chat_handler', {}).get('timeout', 120)
@@ -164,7 +164,7 @@ def chat_ask_stream():
cfg = load_config() cfg = load_config()
stream_url = cfg.get('chat_handler', {}).get( stream_url = cfg.get('chat_handler', {}).get(
'qa_stream_url', 'http://129.146.203.203:5000/api/edge/chat/ask/stream') 'qa_stream_url', 'http://129.146.26.249:5000/api/edge/chat/ask/stream')
timeout = cfg.get('chat_handler', {}).get('timeout', 120) timeout = cfg.get('chat_handler', {}).get('timeout', 120)
full_answer = [] full_answer = []

View File

@@ -1,29 +1,11 @@
""" """
运动监测 Webhook 接收fam-core可选补充非主路径 运动监测状态接口fam-core
主数据源是 MotionNotifier 轮询 SS EventCenter.Event.List真实 运动事件采集走 MotionNotifier 轮询 SS EventCenter.Event.List真实
event_id/start_time/duration。本端点是可选的低延迟补充:若在群晖 event_id/start_time/duration,不再提供 /api/ss/webhook 接收端点
Surveillance Station 配置「行動規則 -> 事件=偵測到動作 -> 動作=Webhook」 SS Webhook 行动规则已弃用2026-08-25 移除)。
SS 会把运动事件实时 POST 到本端点,本端点映射字段后走同一个
push_events_to_oracle 推送到甲骨文 FAM-Edge数据方向 NAS -> Oracle 单向)。
SS Webhook 仅提供模板变量(无 event_id / camera_id 数字字段):
%EVENT_TIME% -> event_time (本地时间字符串)
%DEVICE_NAME% -> device_name (摄像头名,需映射到 camera_id)
%EVENT_NAME% -> event_name
%SERVER_NAME% -> server_name
%THUMBNAIL_URL% -> thumbnail_url
因此 SS 端配置 Webhook 时,请按以下「参数名 -> 模板变量」一一添加(参数名必须一致):
event_time = %EVENT_TIME%
device_name = %DEVICE_NAME%
event_name = %EVENT_NAME%
server_name = %SERVER_NAME%
thumbnail_url = %THUMBNAIL_URL%
本端点接受 JSON 或表单;支持单条对象 / {events:[...]} / 裸数组,字段名已做兼容。
""" """
from flask import Blueprint, request, jsonify from flask import Blueprint, jsonify
from .logger import setup_logger from .logger import setup_logger
from .motion_notifier.motion_notifier import get_motion_notifier from .motion_notifier.motion_notifier import get_motion_notifier
@@ -33,44 +15,6 @@ logger = setup_logger('fam-core.motion_bp')
motion_bp = Blueprint('motion_bp', __name__) motion_bp = Blueprint('motion_bp', __name__)
def _coerce_events(payload) -> list:
"""从 SS Webhook 各种形态中提取事件列表。"""
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in ('events', 'data', 'event', 'items'):
v = payload.get(key)
if isinstance(v, list):
return v
# 单事件对象(表单字段即顶层 key
return [payload]
return []
@motion_bp.route('/api/ss/webhook', methods=['POST'])
def ss_webhook():
data = request.get_json(silent=True)
if not data:
data = request.form.to_dict() or None
if not data:
return jsonify({"error": "Invalid payload"}), 400
raw_events = _coerce_events(data)
if not raw_events:
return jsonify({"error": "no events found in payload"}), 400
notifier = get_motion_notifier()
norm = []
for raw in raw_events:
ev = notifier.build_event_from_webhook(raw)
if ev:
norm.append(ev)
if not norm:
return jsonify({"error": "no mappable events",
"received": len(raw_events)}), 400
pushed = notifier.push_events_to_oracle(norm)
return jsonify({"status": "ok", "received": len(raw_events),
"mapped": len(norm), "pushed": pushed}), 200
@motion_bp.route('/api/ss/status', methods=['GET']) @motion_bp.route('/api/ss/status', methods=['GET'])
def ss_status(): def ss_status():
return jsonify(get_motion_notifier().status()), 200 return jsonify(get_motion_notifier().status()), 200

View File

@@ -18,15 +18,10 @@ MotionNotifier - NAS 端运动监测通知服务轮询主路径2026-08-22
- 心跳线程enabled 即跑,与轮询无关):定期空 POST /api/ss/motion证明 - 心跳线程enabled 即跑,与轮询无关):定期空 POST /api/ss/motion证明
NAS->Oracle 推送链路存活,供甲骨文侧判断"无运动"结论是否可信。 NAS->Oracle 推送链路存活,供甲骨文侧判断"无运动"结论是否可信。
Webhook可选补充非主路径:
fam-core 仍暴露 POST /api/ss/webhookSS 行动规则若配置可实时低延迟推送,
经 build_event_from_webhook 映射后与轮询共用 push_events_to_oracle
其合成 event_id 与轮询真实 event_id 不冲突(不同命名空间,幂等互不干扰)。
""" """
import os import os
import re import re
import time import time
import hashlib
import threading import threading
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -42,7 +37,7 @@ _MOTION_NOTIFIER = None
def get_motion_notifier(): def get_motion_notifier():
"""模块级单例app.py 启动时创建并 startWebhook 路由经此获取)。""" """模块级单例app.py 启动时创建并 start状态接口经此获取)。"""
global _MOTION_NOTIFIER global _MOTION_NOTIFIER
if _MOTION_NOTIFIER is None: if _MOTION_NOTIFIER is None:
_MOTION_NOTIFIER = MotionNotifier() _MOTION_NOTIFIER = MotionNotifier()
@@ -59,7 +54,7 @@ class MotionNotifier:
self.dsm_password = self._resolve(cfg.get('dsm_password', '')) self.dsm_password = self._resolve(cfg.get('dsm_password', ''))
self.camera_ids = cfg.get('camera_ids', [2]) self.camera_ids = cfg.get('camera_ids', [2])
self.oracle_base_url = cfg.get('oracle_base_url', self.oracle_base_url = cfg.get('oracle_base_url',
'http://129.146.203.203:5000').rstrip('/') 'http://129.146.26.249:5000').rstrip('/')
self.oracle_token = self._resolve(cfg.get('oracle_token', '${ORACLE_SYNC_TOKEN}')) self.oracle_token = self._resolve(cfg.get('oracle_token', '${ORACLE_SYNC_TOKEN}'))
self.poll_interval_sec = int(cfg.get('poll_interval_sec', 60)) self.poll_interval_sec = int(cfg.get('poll_interval_sec', 60))
self.poll_window_hours = int(cfg.get('poll_window_hours', 2)) self.poll_window_hours = int(cfg.get('poll_window_hours', 2))
@@ -74,14 +69,8 @@ class MotionNotifier:
# poll_enabled 影响),供甲骨文侧 has_motion_in_range_local() 判断 # poll_enabled 影响),供甲骨文侧 has_motion_in_range_local() 判断
# "这段时间没收到运动事件"是真的没运动,还是推送链路已经挂了。 # "这段时间没收到运动事件"是真的没运动,还是推送链路已经挂了。
self.heartbeat_interval_sec = int(cfg.get('heartbeat_interval_sec', 300)) self.heartbeat_interval_sec = int(cfg.get('heartbeat_interval_sec', 300))
# 轮询路径poll_enabled=true 时)关注的摄像头Webhook 路径用摄像头名映射 # 轮询路径poll_enabled=true 时)关注的摄像头
self.camera_ids = cfg.get('camera_ids', [2]) self.camera_ids = cfg.get('camera_ids', [2])
# 摄像头名 -> camera_id 静态映射(优先),启动时再从 SS 拉一份补全
self.camera_name_to_id = {
str(k): int(v) for k, v in (cfg.get('camera_name_to_id') or {}).items()
}
self._ss_name_to_id = {}
self._camera_loaded = False
self._base = f"http://{self.dsm_host}:{self.dsm_port}/webapi" self._base = f"http://{self.dsm_host}:{self.dsm_port}/webapi"
self._sid = None self._sid = None
self._running = False self._running = False
@@ -160,134 +149,6 @@ class MotionNotifier:
return events return events
return None return None
# ------------------------------------------------------------------
# 摄像头名 -> camera_id 映射(一次性从 SS 拉取,供 Webhook 使用,非轮询)
# ------------------------------------------------------------------
def refresh_camera_map(self):
"""登录 SS 拉取摄像头列表,构建 name->id 映射(启动时调用一次)。
失败时仅告警,不影响 Webhook 运行(配置兜底 camera_name_to_id 仍可生效)。
"""
try:
sid = self._login()
if not sid:
logger.warning("摄像头映射刷新失败SS 登录失败")
return
resp = requests.get(
f"{self._base}/entry.cgi",
params={"api": "SYNO.SurveillanceStation.Camera", "version": 1,
"method": "List", "_sid": sid},
timeout=self.timeout)
data = resp.json()
if not data.get('success'):
logger.warning(f"摄像头映射刷新失败:{data.get('error')}")
return
for c in (data.get('data') or {}).get('cameras', []):
name = c.get('name')
cid = c.get('id')
if name and cid is not None:
self._ss_name_to_id[str(name)] = int(cid)
self._camera_loaded = True
logger.info(f"摄像头映射已刷新:{self._ss_name_to_id}")
except Exception as e:
logger.warning(f"摄像头映射刷新异常:{e}")
def resolve_camera_id(self, device_name):
"""把 SS Webhook 的 %DEVICE_NAME%(摄像头名)解析为 camera_idint|None"""
if not device_name:
return None
name = str(device_name).strip()
for mapping in (self.camera_name_to_id, self._ss_name_to_id):
if name in mapping:
return mapping[name]
low = name.lower()
for k, v in mapping.items():
if k.lower() == low:
return v
return None
@staticmethod
def parse_ss_time_to_epoch(s):
"""把 SS Webhook 的 %EVENT_TIME%(本地时间字符串)解析为 Unix epoch。
兼容 '2026-08-22T10:35:00' / '2026-08-22T10:35' / 带空格 / 带时区偏移。
无时区信息时按 NAS 本地时区CST, +8解释得到绝对 epoch
与甲骨文 ss_motion_events.start_time同为绝对 epoch一致。
"""
if not s:
return None
s = str(s).strip()
# 带 Z / +08:00 偏移:交给 fromisoformat
try:
dt = datetime.fromisoformat(s.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
return int(dt.timestamp())
except ValueError:
pass
# 无偏移的多种格式兜底
base = s.replace('T', ' ')
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M',
'%Y/%m/%d %H:%M:%S', '%Y/%m/%d %H:%M'):
try:
dt = datetime.strptime(base, fmt)
dt = dt.replace(tzinfo=datetime.now().astimezone().tzinfo)
return int(dt.timestamp())
except ValueError:
continue
logger.warning(f"无法解析 SS 时间:{s}")
return None
@staticmethod
def _synth_event_id(device_name, event_time, thumbnail_url):
"""合成稳定的 event_id用于幂等去重
含 thumbnail_url 使同分钟内的多次真实事件可区分SS 重试(相同 payload
则哈希相同 -> 甲骨文 UNIQUE 约束幂等覆盖。
"""
raw = f"{device_name}|{event_time}|{thumbnail_url or ''}"
return int(hashlib.sha256(raw.encode('utf-8')).hexdigest()[:12], 16)
def build_event_from_webhook(self, raw):
"""把一条 SS Webhook 原始字段映射为甲骨文所需的归一化运动事件。
SS Webhook 不提供 event_id / camera_id 数字字段,此处:
- event_id : 由 device_name+event_time+thumbnail_url 合成(稳定、可去重)
- camera_id : 由 device_name 经摄像头映射解析(解析失败则 None
- event_type : 运动固定 10
- start_time : EVENT_TIME 解析为 epoch
- duration : Webhook 无此字段,置 0
返回 dict 或 None字段不足无法构造
"""
if not isinstance(raw, dict):
return None
device_name = (raw.get('device_name') or raw.get('camera') or
raw.get('device') or raw.get('cam_name'))
event_time = raw.get('event_time') or raw.get('time')
thumbnail_url = (raw.get('thumbnail_url') or raw.get('thumbnail') or
raw.get('thumb'))
if not device_name or not event_time:
logger.warning(f"Webhook 事件缺字段 device_name/event_time: {raw}")
return None
start_ts = self.parse_ss_time_to_epoch(event_time)
if start_ts is None:
# 解析失败:退化为当前时间,宁可误报也不漏报
start_ts = int(datetime.now(timezone.utc).timestamp())
logger.warning(f"Webhook 事件时间解析失败,退化为当前时间: {event_time}")
camera_id = self.resolve_camera_id(device_name)
if camera_id is None and not self._camera_loaded:
# 懒加载一次摄像头映射后重试
self.refresh_camera_map()
camera_id = self.resolve_camera_id(device_name)
return {
"event_id": self._synth_event_id(device_name, event_time, thumbnail_url),
"camera_id": camera_id,
"event_type": 10,
"start_time": start_ts,
"duration": 0,
"thumbnail_url": thumbnail_url,
}
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 推送到甲骨文 # 推送到甲骨文
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -40,7 +40,7 @@ def get_sync():
class OracleSync: class OracleSync:
def __init__(self): def __init__(self):
cfg = load_config().get('oracle_sync', {}) cfg = load_config().get('oracle_sync', {})
self.base_url = cfg.get('base_url', 'http://129.146.203.203:5000').rstrip('/') self.base_url = cfg.get('base_url', 'http://129.146.26.249:5000').rstrip('/')
self.token = cfg.get('token', '') self.token = cfg.get('token', '')
self.interval_sec = int(cfg.get('interval_sec', 1800)) self.interval_sec = int(cfg.get('interval_sec', 1800))
self.timeout = int(cfg.get('timeout', 120)) self.timeout = int(cfg.get('timeout', 120))

View File

@@ -1,32 +0,0 @@
"""
Static-App - 提供 Vue 前端构建产物(新架构 v3fam-ui 不再单独起 Streamlit 进程)
fam-ui/dist/ 是本地 `npm run build` 出的静态文件,部署时整个目录拷到 NAS。
本模块只做两件事: 命中真实静态文件(如 /assets/xxx.js直接下发其余任何路径
Vue Router history 模式的前端路由)一律回退到 index.html由浏览器端路由接管。
必须最后注册app.py 里排在 chat_bp/member_bp/img_bp/ui_bp 之后),否则这里的
通配路由会先于 /api/* 匹配,把 API 请求也吞成 index.html。
"""
import os
from flask import Blueprint, send_from_directory, abort
DIST_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'..', 'fam-ui', 'dist'
)
DIST_DIR = os.path.normpath(DIST_DIR)
static_bp = Blueprint('static_app', __name__)
@static_bp.route('/', defaults={'path': ''})
@static_bp.route('/<path:path>')
def spa(path):
if not os.path.isdir(DIST_DIR):
abort(404, "前端构建产物不存在,请先 npm run build 并部署 fam-ui/dist")
full = os.path.join(DIST_DIR, path)
if path and os.path.isfile(full):
return send_from_directory(DIST_DIR, path)
return send_from_directory(DIST_DIR, 'index.html')

View File

@@ -11,7 +11,6 @@ def _cfg(**overrides):
"dsm_port": 5000, "dsm_port": 5000,
"dsm_account": "ericwyuan", "dsm_account": "ericwyuan",
"dsm_password": "secret", "dsm_password": "secret",
"camera_name_to_id": {"Generic_ONVIF-001": 2},
"oracle_base_url": "http://oracle.example:5000", "oracle_base_url": "http://oracle.example:5000",
"oracle_token": "tok123", "oracle_token": "tok123",
"heartbeat_interval_sec": 300, "heartbeat_interval_sec": 300,
@@ -38,70 +37,6 @@ class _FakeResp:
return self._payload return self._payload
# ----------------------------------------------------------------------
# build_event_from_webhook / 时间解析 / 摄像头映射
# ----------------------------------------------------------------------
def test_build_event_from_webhook_maps_known_camera(monkeypatch):
n = _make_notifier(monkeypatch)
ev = n.build_event_from_webhook({
"device_name": "Generic_ONVIF-001",
"event_time": "2026-08-22T10:35:00",
"thumbnail_url": "abc,123",
})
assert ev is not None
assert ev["camera_id"] == 2
assert ev["event_type"] == 10
assert ev["duration"] == 0
assert isinstance(ev["start_time"], int)
def test_build_event_from_webhook_missing_fields_returns_none(monkeypatch):
n = _make_notifier(monkeypatch)
assert n.build_event_from_webhook({"device_name": "Generic_ONVIF-001"}) is None
assert n.build_event_from_webhook({"event_time": "2026-08-22T10:35:00"}) is None
def test_build_event_from_webhook_unmapped_camera_gives_none_id(monkeypatch):
n = _make_notifier(monkeypatch)
ev = n.build_event_from_webhook({
"device_name": "某个从没见过的摄像头",
"event_time": "2026-08-22T10:35:00",
})
assert ev is not None
assert ev["camera_id"] is None
def test_synth_event_id_stable_for_same_input(monkeypatch):
n = _make_notifier(monkeypatch)
a = n._synth_event_id("cam1", "2026-08-22T10:00:00", "thumb1")
b = n._synth_event_id("cam1", "2026-08-22T10:00:00", "thumb1")
assert a == b
def test_synth_event_id_differs_for_different_thumbnail(monkeypatch):
"""核心诉求: 同一分钟内的多次真实事件要能区分开,不能因为哈希碰撞被去重掉。"""
n = _make_notifier(monkeypatch)
a = n._synth_event_id("cam1", "2026-08-22T10:00:00", "thumb1")
b = n._synth_event_id("cam1", "2026-08-22T10:00:00", "thumb2")
assert a != b
def test_parse_ss_time_to_epoch_iso_format(monkeypatch):
n = _make_notifier(monkeypatch)
assert n.parse_ss_time_to_epoch("2026-08-22T10:35:00") is not None
def test_parse_ss_time_to_epoch_invalid_returns_none(monkeypatch):
n = _make_notifier(monkeypatch)
assert n.parse_ss_time_to_epoch("not a time") is None
def test_parse_ss_time_to_epoch_empty_returns_none(monkeypatch):
n = _make_notifier(monkeypatch)
assert n.parse_ss_time_to_epoch("") is None
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 心跳跟轮询无关enabled=true 就该跑) # 心跳跟轮询无关enabled=true 就该跑)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------