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

@@ -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:80NAS 不再托管 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}")

View File

@@ -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/',)

View File

@@ -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 = []

View File

@@ -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

View File

@@ -18,15 +18,10 @@ MotionNotifier - NAS 端运动监测通知服务轮询主路径2026-08-22
- 心跳线程enabled 即跑,与轮询无关):定期空 POST /api/ss/motion证明
NAS->Oracle 推送链路存活,供甲骨文侧判断"无运动"结论是否可信。
Webhook可选补充非主路径:
fam-core 仍暴露 POST /api/ss/webhookSS 行动规则若配置可实时低延迟推送,
经 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 启动时创建并 startWebhook 路由经此获取)。"""
"""模块级单例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_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:
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))

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')