refactor(fam-core): 移除 SS Webhook 可选路径与 NAS 静态前端托管,兜底 Oracle 地址更新为 129.146.26.249
This commit is contained in:
@@ -22,7 +22,7 @@ database:
|
||||
# 甲骨文同步(每 30 分钟拉增量镜像)
|
||||
oracle_sync:
|
||||
# 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 一致(环境变量注入,避免明文入库)
|
||||
token: "${ORACLE_SYNC_TOKEN}"
|
||||
interval_sec: 1800 # 拉取间隔(秒),默认 30 分钟
|
||||
@@ -30,14 +30,13 @@ oracle_sync:
|
||||
|
||||
chat_handler:
|
||||
# 智能问答统一走 FAM-Edge 编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)
|
||||
qa_url: "http://129.146.203.203:5000/api/edge/chat/ask"
|
||||
qa_stream_url: "http://129.146.203.203:5000/api/edge/chat/ask/stream" # 流式版
|
||||
qa_url: "http://129.146.26.249:5000/api/edge/chat/ask"
|
||||
qa_stream_url: "http://129.146.26.249:5000/api/edge/chat/ask/stream" # 流式版
|
||||
timeout: 120
|
||||
|
||||
# 运动监测通知服务(2026-08-22 定稿:轮询主路径)
|
||||
# 运动监测通知服务(2026-08-22 定稿:轮询主路径;2026-08-25 移除 Webhook 可选路径)
|
||||
# NAS 本机轮询 SS EventCenter.Event.List(真实 event_id/start_time/duration),
|
||||
# 增量推送到甲骨文 FAM-Edge(单向 NAS -> Oracle,甲骨文不再反向访问 NAS)。
|
||||
# Webhook(/api/ss/webhook)保留为可选低延迟补充,SS 行动规则未配置则不触发。
|
||||
# SS 凭据走环境变量(${DSM_ACCOUNT}/${DSM_PASSWORD}),由启动脚本 source 的 .env 提供。
|
||||
motion_notifier:
|
||||
enabled: true
|
||||
@@ -47,10 +46,7 @@ motion_notifier:
|
||||
dsm_account: "${DSM_ACCOUNT}"
|
||||
dsm_password: "${DSM_PASSWORD}"
|
||||
camera_ids: [2] # 轮询关注的摄像头(Generic_ONVIF-001)
|
||||
# 摄像头名 -> camera_id 静态映射(Webhook 路径用;启动时还会从 SS 拉一份补全)
|
||||
camera_name_to_id:
|
||||
"Generic_ONVIF-001": 2
|
||||
oracle_base_url: "http://129.146.203.203:5000" # 与 oracle_sync.base_url 一致
|
||||
oracle_base_url: "http://129.146.26.249:5000" # 与 oracle_sync.base_url 一致
|
||||
oracle_token: "${ORACLE_SYNC_TOKEN}" # 与 oracle_sync.token 一致
|
||||
timeout_sec: 10 # 单次 SS 请求超时
|
||||
# 心跳:跟轮询 SS 无关,只是定期空 POST 一下甲骨文的 /api/ss/motion,证明
|
||||
@@ -62,7 +58,3 @@ motion_notifier:
|
||||
poll_interval_sec: 60 # 轮询间隔
|
||||
poll_window_hours: 2 # 每轮回看窗口(小时),覆盖轮询间隔内的新事件
|
||||
batch_size: 100 # 单批推送上限
|
||||
# Webhook 可选补全(仅 webhook 路径用):收到 webhook 后,以 camera_id+触发时刻
|
||||
# 为中心开此时间窗(秒)回头查 SS 事件列表,匹配最接近的运动事件回填真实
|
||||
# start_time/duration/event_id(Webhook 本身不提供这些字段)。
|
||||
enrich_window_sec: 120
|
||||
|
||||
@@ -23,22 +23,19 @@ from .member_manager.member_manager import member_bp
|
||||
from .img_proxy import img_bp
|
||||
from .motion_bp import motion_bp
|
||||
from .ui_api import ui_bp
|
||||
from .static_app import static_bp
|
||||
from .auth import auth_bp, init_auth
|
||||
|
||||
logger = setup_logger('fam-core.app')
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# 注册蓝图:/api/* 系列必须先于 static_bp 注册——static_bp 是通配兜底路由
|
||||
# (Vue Router history 模式回退 index.html),排在前面会吞掉 API 请求。
|
||||
# 注册蓝图:全部 /api/* 路由(前端已迁云服务器 Caddy:80,NAS 不再托管 SPA 静态文件)
|
||||
app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(member_bp)
|
||||
app.register_blueprint(img_bp)
|
||||
app.register_blueprint(motion_bp)
|
||||
app.register_blueprint(ui_bp)
|
||||
app.register_blueprint(auth_bp) # 登录页 /api/login 等(static_bp 通配之前)
|
||||
app.register_blueprint(static_bp)
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
# 登录校验全局拦截(页面未登录 302 /login;/api/* 未登录 401)
|
||||
init_auth(app)
|
||||
@@ -64,17 +61,11 @@ try:
|
||||
except Exception as e:
|
||||
logger.error(f"Oracle-Sync 启动失败: {e}")
|
||||
|
||||
# 初始化运动监测通知服务(Webhook 驱动,不轮询;提供推送客户端 + 摄像头名映射)
|
||||
# 初始化运动监测通知服务(NAS 轮询 SS EventCenter 主路径)
|
||||
_notifier = None
|
||||
try:
|
||||
_notifier = get_motion_notifier()
|
||||
_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 已初始化")
|
||||
except Exception as e:
|
||||
logger.error(f"MotionNotifier 初始化失败: {e}")
|
||||
|
||||
@@ -73,7 +73,6 @@ def is_authed() -> bool:
|
||||
_WHITELIST_EXACT = {
|
||||
'/login', '/api/login', '/api/logout', '/api/auth/check',
|
||||
'/health', '/favicon.ico',
|
||||
'/api/ss/webhook', # SS 推送(无登录态,必须放行)
|
||||
}
|
||||
_WHITELIST_PREFIX = ('/assets/',)
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ def _call_edge_qa(prompt: str) -> str:
|
||||
"""调用 FAM-Edge 问答编排端点(Gemini → NVIDIA → 本地 Ollama 兜底)"""
|
||||
cfg = load_config()
|
||||
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)
|
||||
|
||||
@@ -164,7 +164,7 @@ def chat_ask_stream():
|
||||
|
||||
cfg = load_config()
|
||||
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)
|
||||
|
||||
full_answer = []
|
||||
|
||||
@@ -1,29 +1,11 @@
|
||||
"""
|
||||
运动监测 Webhook 接收(fam-core,可选补充,非主路径)
|
||||
运动监测状态接口(fam-core)
|
||||
|
||||
主数据源是 MotionNotifier 轮询 SS EventCenter.Event.List(真实
|
||||
event_id/start_time/duration)。本端点是可选的低延迟补充:若在群晖
|
||||
Surveillance Station 配置「行動規則 -> 事件=偵測到動作 -> 動作=Webhook」,
|
||||
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:[...]} / 裸数组,字段名已做兼容。
|
||||
运动事件采集走 MotionNotifier 轮询 SS EventCenter.Event.List(真实
|
||||
event_id/start_time/duration),不再提供 /api/ss/webhook 接收端点
|
||||
(SS Webhook 行动规则已弃用,2026-08-25 移除)。
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from .logger import setup_logger
|
||||
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__)
|
||||
|
||||
|
||||
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'])
|
||||
def ss_status():
|
||||
return jsonify(get_motion_notifier().status()), 200
|
||||
|
||||
@@ -18,15 +18,10 @@ MotionNotifier - NAS 端运动监测通知服务(轮询主路径,2026-08-22
|
||||
- 心跳线程(enabled 即跑,与轮询无关):定期空 POST /api/ss/motion,证明
|
||||
NAS->Oracle 推送链路存活,供甲骨文侧判断"无运动"结论是否可信。
|
||||
|
||||
Webhook(可选补充,非主路径):
|
||||
fam-core 仍暴露 POST /api/ss/webhook,SS 行动规则若配置可实时低延迟推送,
|
||||
经 build_event_from_webhook 映射后与轮询共用 push_events_to_oracle;
|
||||
其合成 event_id 与轮询真实 event_id 不冲突(不同命名空间,幂等互不干扰)。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import hashlib
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -42,7 +37,7 @@ _MOTION_NOTIFIER = None
|
||||
|
||||
|
||||
def get_motion_notifier():
|
||||
"""模块级单例(app.py 启动时创建并 start,Webhook 路由经此获取)。"""
|
||||
"""模块级单例(app.py 启动时创建并 start,状态接口经此获取)。"""
|
||||
global _MOTION_NOTIFIER
|
||||
if _MOTION_NOTIFIER is None:
|
||||
_MOTION_NOTIFIER = MotionNotifier()
|
||||
@@ -59,7 +54,7 @@ class MotionNotifier:
|
||||
self.dsm_password = self._resolve(cfg.get('dsm_password', ''))
|
||||
self.camera_ids = cfg.get('camera_ids', [2])
|
||||
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.poll_interval_sec = int(cfg.get('poll_interval_sec', 60))
|
||||
self.poll_window_hours = int(cfg.get('poll_window_hours', 2))
|
||||
@@ -74,14 +69,8 @@ class MotionNotifier:
|
||||
# poll_enabled 影响),供甲骨文侧 has_motion_in_range_local() 判断
|
||||
# "这段时间没收到运动事件"是真的没运动,还是推送链路已经挂了。
|
||||
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])
|
||||
# 摄像头名 -> 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._sid = None
|
||||
self._running = False
|
||||
@@ -160,134 +149,6 @@ class MotionNotifier:
|
||||
return events
|
||||
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_id(int|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,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 推送到甲骨文
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -40,7 +40,7 @@ def get_sync():
|
||||
class OracleSync:
|
||||
def __init__(self):
|
||||
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.interval_sec = int(cfg.get('interval_sec', 1800))
|
||||
self.timeout = int(cfg.get('timeout', 120))
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
Static-App - 提供 Vue 前端构建产物(新架构 v3:fam-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')
|
||||
@@ -11,7 +11,6 @@ def _cfg(**overrides):
|
||||
"dsm_port": 5000,
|
||||
"dsm_account": "ericwyuan",
|
||||
"dsm_password": "secret",
|
||||
"camera_name_to_id": {"Generic_ONVIF-001": 2},
|
||||
"oracle_base_url": "http://oracle.example:5000",
|
||||
"oracle_token": "tok123",
|
||||
"heartbeat_interval_sec": 300,
|
||||
@@ -38,70 +37,6 @@ class _FakeResp:
|
||||
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 就该跑)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user