diff --git a/fam-core/config/config.yaml b/fam-core/config/config.yaml index 9ae52ac..6dcae07 100644 --- a/fam-core/config/config.yaml +++ b/fam-core/config/config.yaml @@ -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 diff --git a/fam-core/src/fam_core/app.py b/fam-core/src/fam_core/app.py index 7820868..178b1f7 100644 --- a/fam-core/src/fam_core/app.py +++ b/fam-core/src/fam_core/app.py @@ -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}") diff --git a/fam-core/src/fam_core/auth.py b/fam-core/src/fam_core/auth.py index 83228ca..827c180 100644 --- a/fam-core/src/fam_core/auth.py +++ b/fam-core/src/fam_core/auth.py @@ -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/',) diff --git a/fam-core/src/fam_core/chat_handler/chat_handler.py b/fam-core/src/fam_core/chat_handler/chat_handler.py index c67b34d..e56b016 100644 --- a/fam-core/src/fam_core/chat_handler/chat_handler.py +++ b/fam-core/src/fam_core/chat_handler/chat_handler.py @@ -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 = [] diff --git a/fam-core/src/fam_core/motion_bp.py b/fam-core/src/fam_core/motion_bp.py index 690094a..3108c93 100644 --- a/fam-core/src/fam_core/motion_bp.py +++ b/fam-core/src/fam_core/motion_bp.py @@ -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 diff --git a/fam-core/src/fam_core/motion_notifier/motion_notifier.py b/fam-core/src/fam_core/motion_notifier/motion_notifier.py index 0b24cbd..720090a 100644 --- a/fam-core/src/fam_core/motion_notifier/motion_notifier.py +++ b/fam-core/src/fam_core/motion_notifier/motion_notifier.py @@ -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, - } - # ------------------------------------------------------------------ # 推送到甲骨文 # ------------------------------------------------------------------ diff --git a/fam-core/src/fam_core/oracle_sync.py b/fam-core/src/fam_core/oracle_sync.py index 9c22ee7..619d26a 100644 --- a/fam-core/src/fam_core/oracle_sync.py +++ b/fam-core/src/fam_core/oracle_sync.py @@ -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)) diff --git a/fam-core/src/fam_core/static_app.py b/fam-core/src/fam_core/static_app.py deleted file mode 100644 index 5f1b276..0000000 --- a/fam-core/src/fam_core/static_app.py +++ /dev/null @@ -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('/') -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') diff --git a/fam-core/tests/test_motion_notifier.py b/fam-core/tests/test_motion_notifier.py index 47aab2b..a0962a7 100644 --- a/fam-core/tests/test_motion_notifier.py +++ b/fam-core/tests/test_motion_notifier.py @@ -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 就该跑) # ----------------------------------------------------------------------