Compare commits

..

2 Commits

14 changed files with 69 additions and 358 deletions

View File

@@ -0,0 +1,16 @@
# 2026-08-25 工作日志
## 前端迁移收尾 + 登录故障修复
- **前端最终落点**:云服务器 129.146.26.249 的 Caddy :80`http://129.146.26.249/`
SPA 静态在 `/var/www/fam-ui``/api/*` 经 frp 隧道反代回 NAS fam-core :8000。
**注意:新服务器 :8000 是 frp 隧道口(直通 NAS API不是前端入口**,登录请用 :80。
- **登录失败根因**:重启 fam-core 时 FAM_AUTH_USER/FAM_AUTH_PASS 未固化进 .env →
auth.py fail-closed未配置即拒绝所有登录→ 全部 401。已补进
`/volume1/web/sentinel-home-ai/.env`5 键ORACLE_SYNC_TOKEN/DSM_ACCOUNT/DSM_PASSWORD/
FAM_AUTH_USER/FAM_AUTH_PASS=ericwyuan/iLoveJava5并重启登录恢复 200。
- **旧 IP 清理**:本地仓库 config.yaml4 处)+ chat_handler/motion_notifier/oracle_sync
兜底默认值 + docs/DEPLOY.md + PROGRESS.md 共 6 文件旧 IP 129.146.203.203 → 129.146.26.249
已清 __pycache__。仓库/部署/README 三处一致。**未提交**。
- 孤儿云库 sentinel_home_ai + famcore 用户已 DROP新服务器
- SS Webhook代码中为可选补充motion_bp.py 文档明确SS 未配行动规则故休眠,
是否删除待用户拍板。

View File

@@ -8,6 +8,9 @@
- **不再分析整段视频**。Google Drive --rclone--> Oracle gdrive_videos整段素材保留 - **不再分析整段视频**。Google Drive --rclone--> Oracle gdrive_videos整段素材保留
Oracle 按 NAS 推送的 `ss_motion_events`start_time/durationUnix epochffmpeg 分割运动片段 Oracle 按 NAS 推送的 `ss_motion_events`start_time/durationUnix epochffmpeg 分割运动片段
`-c:v copy -c:a aac` 保留音频,产物 `/opt/fam-edge/motion_clips/`**只分析运动片段**。 `-c:v copy -c:a aac` 保留音频,产物 `/opt/fam-edge/motion_clips/`**只分析运动片段**。
- **Drive 中转是跨境提速设计(非仅 NAT 原因)**:中国→海外裸 HTTP/SCP 直传 Oracle 很难(跨境单 TCP 流
丢包掉速);改走 GoogleCloudSync 分块并发 + 断点续传,抗抖动)→ Oracle rclone 每 5min 拉取
海外数据中心互联Gbps。**不要轻易提议砍掉 Drive 改 NAS 直连 Oracle**。
- NAS MotionNotifier 轮询 SS `EventCenter.Event.List`60scamera_ids=2, event_types=10推送 - NAS MotionNotifier 轮询 SS `EventCenter.Event.List`60scamera_ids=2, event_types=10推送
/api/ss/motion游标 MariaDB 续用 + 失败批次不前进 + 心跳。Webhook 端点保留为可选补充。 /api/ss/motion游标 MariaDB 续用 + 失败批次不前进 + 心跳。Webhook 端点保留为可选补充。
- **SS duration=0 = 动作进行中**(结束才回填真实时长),分割只处理已结束事件。 - **SS duration=0 = 动作进行中**(结束才回填真实时长),分割只处理已结束事件。
@@ -18,10 +21,11 @@
- NAS (192.168.50.64)SSH 2222, ericwyuan/iLoveJava5fam-core 代码 `/volume1/web/sentinel-home-ai` - NAS (192.168.50.64)SSH 2222, ericwyuan/iLoveJava5fam-core 代码 `/volume1/web/sentinel-home-ai`
venv `fam-core/venv``start_core.sh` source .env重启 = kill gunicorn + `setsid bash start_core.sh` venv `fam-core/venv``start_core.sh` source .env重启 = kill gunicorn + `setsid bash start_core.sh`
NAS 无 systemd 守护)。 NAS 无 systemd 守护)。
- **Oracle (129.146.203.203)**SSH key `~/.ssh/oracle_sentinel`fam-edge 由 **systemd - **Oracle(云服务器 129.146.26.249**SSH key `~/.ssh/oracle_new`fam-edge 由 **systemd
`fam-edge.service` 守护Restart=always**——部署代码后必须 `sudo systemctl restart fam-edge` `fam-edge.service` 守护Restart=always**——部署代码后必须 `sudo systemctl restart fam-edge`
手动 setsid 会端口冲突venv `/opt/fam-edge/venv`;库 `/opt/fam-edge/data/oracle.db` 手动 setsid 会端口冲突venv `/opt/fam-edge/venv`;库 `/opt/fam-edge/data/oracle.db`
素材 `/opt/fam-edge/gdrive_videos`logs 目录 ubuntu 可写。 素材 `/opt/fam-edge/gdrive_videos`logs 目录 ubuntu 可写。
**前端亦托管于此机 Caddy :80**fam-ui SPA + /api 反代回 NAS fam-core :8000 经 frp 隧道);旧机 129.146.203.203 已退役。
- 部署:本地 git 提交 push Gitea → tar 管道NAS 直接解包Oracle `--strip-components=1` 到临时目录再 cp避免动 data/venv - 部署:本地 git 提交 push Gitea → tar 管道NAS 直接解包Oracle `--strip-components=1` 到临时目录再 cp避免动 data/venv
## 提交规范(强制) ## 提交规范(强制)

View File

@@ -56,7 +56,7 @@
## 环境状态 ## 环境状态
### Oracle Cloud (129.146.203.203) - FAM-Edge 节点 ### Oracle Cloud (129.146.26.249) - FAM-Edge 节点
| 项目 | 状态 | 备注 | | 项目 | 状态 | 备注 |
|------|------|------| |------|------|------|
| OS | Ubuntu 20.04 ARM64 | Ampere A1 2C12G | | OS | Ubuntu 20.04 ARM64 | Ampere A1 2C12G |
@@ -192,7 +192,7 @@
5. **MariaDB JSON 路径兼容** - MariaDB 10.11 不支持 MySQL 的 `$[*]` 通配符 JSON 路径和 `->` 操作符name_member() 改用 Python 层解析 + 逐行 UPDATE 5. **MariaDB JSON 路径兼容** - MariaDB 10.11 不支持 MySQL 的 `$[*]` 通配符 JSON 路径和 `->` 操作符name_member() 改用 Python 层解析 + 逐行 UPDATE
6. **Python 3.10 venv** - NAS 系统 Python 3.8 过旧,用 Synology Python3.10 包创建 venv 6. **Python 3.10 venv** - NAS 系统 Python 3.8 过旧,用 Synology Python3.10 包创建 venv
7. **FAM-Edge 聊天代理** - 历史:/api/edge/chat 直连 Ollama 代理;架构重构后被 `/api/edge/chat/ask` 三模型编排端点取代(旧端点保留兼容) 7. **FAM-Edge 聊天代理** - 历史:/api/edge/chat 直连 Ollama 代理;架构重构后被 `/api/edge/chat/ask` 三模型编排端点取代(旧端点保留兼容)
8. **Oracle 公网 IP 替代 Tailscale** - Tailscale 两节点在线但端口不通防火墙edge_url 和 qa_url 改用 Oracle 公网 IP 129.146.203.203 8. **Oracle 公网 IP 替代 Tailscale** - Tailscale 两节点在线但端口不通防火墙edge_url 和 qa_url 改用 Oracle 公网 IP 129.146.26.249
9. **Ollama 模型常驻内存** - systemd 加 `OLLAMA_KEEP_ALIVE=-1`,模型加载后永不卸载,消除 55s 冷启动延迟,常驻占用 4.3GB 内存(系统 12GB 够用) 9. **Ollama 模型常驻内存** - systemd 加 `OLLAMA_KEEP_ALIVE=-1`,模型加载后永不卸载,消除 55s 冷启动延迟,常驻占用 4.3GB 内存(系统 12GB 够用)
10. **关键帧自适应帧数** - 原固定 5-8 帧对长视频太稀疏30分钟仅8帧=每3.75分钟1帧改为随视频时长自适应候选帧 `clamp(duration_min×2, 30, 120)`,关键帧上限 `clamp(duration/150s, 8, 30)`。30分钟→12帧60分钟→24帧封顶30帧 10. **关键帧自适应帧数** - 原固定 5-8 帧对长视频太稀疏30分钟仅8帧=每3.75分钟1帧改为随视频时长自适应候选帧 `clamp(duration_min×2, 30, 120)`,关键帧上限 `clamp(duration/150s, 8, 30)`。30分钟→12帧60分钟→24帧封顶30帧
11. **云端直出直存(架构重构)** - 本地 Ollama 完全移出视频链路:云端 VLMGemini 多图单请求 / NVIDIA 逐帧聚合)直接产出结构化 JSONEdge 仅 `format_cloud_result` 格式化/校验(无模型调用)后直存 NAS DB。根因CPU 版 Ollama 对无上限融合 prompt 预填充极慢导致 300s 超时 11. **云端直出直存(架构重构)** - 本地 Ollama 完全移出视频链路:云端 VLMGemini 多图单请求 / NVIDIA 逐帧聚合)直接产出结构化 JSONEdge 仅 `format_cloud_result` 格式化/校验(无模型调用)后直存 NAS DB。根因CPU 版 Ollama 对无上限融合 prompt 预填充极慢导致 300s 超时
@@ -378,7 +378,7 @@ AI 分析瓶颈: 帧5耗时 229s (疑似 ARM CPU 热降频), 其余帧 50-65s
/api/edge/chat/ask run_qa 三模型降级编排 /api/edge/chat/ask run_qa 三模型降级编排
``` ```
- FAM-Core Chat-Handler 调用 `qa_url` 配置的 `http://129.146.203.203:5000/api/edge/chat/ask`(不再直连 Ollama - FAM-Core Chat-Handler 调用 `qa_url` 配置的 `http://129.146.26.249:5000/api/edge/chat/ask`(不再直连 Ollama
- FAM-Edge `run_qa` 按 Gemini → NVIDIA → 本地 Ollama 顺序调用 `chat()`,首个成功即返回 `{answer, provider}` - FAM-Edge `run_qa` 按 Gemini → NVIDIA → 本地 Ollama 顺序调用 `chat()`,首个成功即返回 `{answer, provider}`
- **验证结果** - **验证结果**
- Edge 单测:`/api/edge/chat/ask` → 200`provider=nvidia`Gemini 30s 超时后 NVIDIA 兜底成功,耗时 51s - Edge 单测:`/api/edge/chat/ask` → 200`provider=nvidia`Gemini 30s 超时后 NVIDIA 兜底成功,耗时 51s

View File

@@ -3,7 +3,7 @@
> 多模型容灾降级 + 交互式命名 + AI 对话的家庭监控系统。 > 多模型容灾降级 + 交互式命名 + AI 对话的家庭监控系统。
> 本文档为项目需求文档与 README 的整合版按当前代码实际状态v1.0 E2E 已打通)编写。 > 本文档为项目需求文档与 README 的整合版按当前代码实际状态v1.0 E2E 已打通)编写。
> 最后更新2026-08-24事件时间轴支持删除视频会话 > 最后更新2026-08-25Oracle 迁至新服务器 129.146.26.249;前端由 NAS 迁至云服务器 Caddy :80
--- ---
@@ -17,7 +17,7 @@
- **FAM-Core**NAS 端单进程):**Oracle-Sync**(每 30 分钟拉增量镜像)+ **MotionNotifier**(轮询 SS 运动事件推送 Oracle+ **Chat-Handler** + **Member-Manager**CPU 占用极低 - **FAM-Core**NAS 端单进程):**Oracle-Sync**(每 30 分钟拉增量镜像)+ **MotionNotifier**(轮询 SS 运动事件推送 Oracle+ **Chat-Handler** + **Member-Manager**CPU 占用极低
- **FAM-Edge**Oracle 端单进程rclone 实时同步 Google 硬盘视频(**整段素材**)→ 按 `ss_motion_events` 运动事件start_time/duration**ffmpeg 分割运动片段** → **只把运动片段送云端 VLM**Gemini 用 Files API / NVIDIA 整视频 `video_url`)→ 结构化 JSON 落本地 SQLite → 对外提供 `/api/oracle/sync` 增量拉取接口(**本地模型不参与视频分析** - **FAM-Edge**Oracle 端单进程rclone 实时同步 Google 硬盘视频(**整段素材**)→ 按 `ss_motion_events` 运动事件start_time/duration**ffmpeg 分割运动片段** → **只把运动片段送云端 VLM**Gemini 用 Files API / NVIDIA 整视频 `video_url`)→ 结构化 JSON 落本地 SQLite → 对外提供 `/api/oracle/sync` 增量拉取接口(**本地模型不参与视频分析**
- **FAM-UI**NAS 端Vue3 SPA 读本地同步镜像sync_videos / sync_events / sync_people事件时间轴 + 人物管理 + AI 对话 + 对话历史 + 统计 - **FAM-UI**云服务器端,由 Caddy :80 托管Vue3 SPA 读本地同步镜像sync_videos / sync_events / sync_people事件时间轴 + 人物管理 + AI 对话 + 对话历史 + 统计;浏览器经云服务器访问,/api 经 frp 隧道反代回 NAS FAM-Core
- **数据库**Oracle 侧 SQLitevideos/events/people/ss_motion_events/sync_cursorNAS 侧 MariaDB 镜像sync_videos / sync_events / sync_people / sync_cursor+ chat_history - **数据库**Oracle 侧 SQLitevideos/events/people/ss_motion_events/sync_cursorNAS 侧 MariaDB 镜像sync_videos / sync_events / sync_people / sync_cursor+ chat_history
- **数据流向**Google 硬盘 ──rclone──► 甲骨文整段素材 ──按运动事件分割片段──► 片段云端分析 ──► Oracle SQLite ──每 30 分钟 NAS 拉取──► NAS MariaDB 镜像 ──► FAM-UI运动事件由 NAS 轮询 SS 推送 OracleNAS → Oracle 单向) - **数据流向**Google 硬盘 ──rclone──► 甲骨文整段素材 ──按运动事件分割片段──► 片段云端分析 ──► Oracle SQLite ──每 30 分钟 NAS 拉取──► NAS MariaDB 镜像 ──► FAM-UI运动事件由 NAS 轮询 SS 推送 OracleNAS → Oracle 单向)
- **AI 对话**:查 sync_events 拼上下文 → FAM-Edge 转发到独立 **ai-gateway** 服务OpenAI 兼容协议NVIDIA → Gemini → 本地 Ollama 降级链在 ai-gateway 内部完成FAM-Edge 不再自己维护模型链)→ 返回并写 chat_history - **AI 对话**:查 sync_events 拼上下文 → FAM-Edge 转发到独立 **ai-gateway** 服务OpenAI 兼容协议NVIDIA → Gemini → 本地 Ollama 降级链在 ai-gateway 内部完成FAM-Edge 不再自己维护模型链)→ 返回并写 chat_history
@@ -63,13 +63,13 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
| 节点 | 角色 | 硬件 | IP | 服务与端口 | | 节点 | 角色 | 硬件 | IP | 服务与端口 |
|------|------|------|-----|-----------| |------|------|------|-----|-----------|
| Synology NAS | FAM-Core + FAM-UI + 数据库 | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 / Tailscale 100.70.234.39 | FAM-Core :8000, FAM-UIVue3 构建产物,由 FAM-Core 托管, MariaDB :3306, Surveillance Station :5000 | | Synology NAS | FAM-Core + 数据库(采集 + 聚合 + 镜像层) | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 | FAM-Core :8000API 聚合 + 轮询 SS + OracleSync, MariaDB :3306, Surveillance Station :5000FAM-UI 已迁至云服务器(见下) |
| Oracle Cloud | FAM-Edge + AI-Gateway | Ampere A1 2C12G ARM64无 GPU, Ubuntu 20.04 | 公网 129.146.203.203 / Tailscale 100.74.137.126 | FAM-Edge :5000systemd 守护,视频分析 + 转发问答), **AI-Gateway :5100systemd 守护,对外监听,问答模型降级链)**, Ollama :11434仅本地被 AI-Gateway 调用) | | Oracle Cloud(云服务器) | FAM-Edge + AI-Gateway + Caddy前端 | Ampere A1 4C23G ARM64无 GPU, Ubuntu 20.04 | 公网 129.146.26.249 / Tailscale(已安装未启用,备用) | FAM-Edge :5000systemd 守护,视频分析 + 转发问答), **AI-Gateway :5100systemd 守护,对外监听,问答模型降级链)**, Caddy :80托管 FAM-UI SPA + 反代 /api/* → NAS FAM-Core, Ollama :11434仅本地被 AI-Gateway 调用) |
| 家庭网络 | 用户入口 | 普通终端 | 192.168.50.0/24 | 浏览器访问 `http://192.168.50.64:8000` | | 家庭网络 | 用户入口 | 普通终端 | 公网 / 家庭网络 | 浏览器访问 `http://129.146.26.249/`Caddy 托管前端,/api 经 frp 隧道回源 NAS FAM-Core :8000 |
**网络要点(推送模式)** **网络要点(推送模式)**
- 服务间通信只有两条出站:**NAS → Oracle 公网 IP:5000**(① `GET /api/oracle/sync` 拉增量 + `POST /api/oracle/people/correct` 命名回推 ② `POST /api/ss/motion` 推送运动侦测事件。Edge 不需要反向访问 NAS - 服务间通信只有两条出站:**NAS → Oracle 公网 IP:5000**(① `GET /api/oracle/sync` 拉增量 + `POST /api/oracle/people/correct` 命名回推 ② `POST /api/ss/motion` 推送运动侦测事件。Edge 不需要反向访问 NAS
- **运动事件获取NAS 轮询驱动)**FAM-Core MotionNotifier 每 60s 轮询本机 Surveillance Station 事件列表 API`SYNO.SurveillanceStation.EventCenter.Event`),增量推送 Oracle。`/api/ss/webhook` 端点在代码中保留为可选低延迟补充SS 行動規則 Webhook 未配置则不触发) - **运动事件获取NAS 轮询驱动)**FAM-Core MotionNotifier 每 60s 轮询本机 Surveillance Station 事件列表 API`SYNO.SurveillanceStation.EventCenter.Event`),增量推送 Oracle。`/api/ss/webhook` 端点在代码中保留为可选低延迟补充SS 行動規則 Webhook 未配置则不触发)。若启用 SS Webhook目标地址改为云服务器公网 `http://129.146.26.249/api/ss/webhook?token=ORACLE_SYNC_TOKEN`
- Oracle 端 Ollama 端口 11434 不对外暴露;聊天请求 NAS → FAM-Edge `/api/edge/chat/ask`(保持不变的对外契约)→ **FAM-Edge 转发到同机的 AI-Gateway :5100**`/v1/chat/completions`OpenAI 兼容协议Bearer token 鉴权)→ NVIDIA/Gemini/Ollama 降级链。AI-Gateway 自己对外监听 0.0.0.0:5100Bearer token 鉴权 fail-closed供其他项目直接接入不止服务本系统 - Oracle 端 Ollama 端口 11434 不对外暴露;聊天请求 NAS → FAM-Edge `/api/edge/chat/ask`(保持不变的对外契约)→ **FAM-Edge 转发到同机的 AI-Gateway :5100**`/v1/chat/completions`OpenAI 兼容协议Bearer token 鉴权)→ NVIDIA/Gemini/Ollama 降级链。AI-Gateway 自己对外监听 0.0.0.0:5100Bearer token 鉴权 fail-closed供其他项目直接接入不止服务本系统
- Tailscale 两节点已安装在线,但 NAS tailscaled 为 userspace 模式且防火墙端口不通,暂走公网 IP - Tailscale 两节点已安装在线,但 NAS tailscaled 为 userspace 模式且防火墙端口不通,暂走公网 IP
@@ -82,7 +82,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
└──────────────┬───────────────────────┘ └──────────────┬───────────────────────┘
│ rclone 定时同步systemd timer │ rclone 定时同步systemd timer
┌──────────────────────── Oracle Cloud (129.146.203.203) ────────────────────────┐ ┌──────────────────────── Oracle Cloud / 云服务器 (129.146.26.249) ────────────────────────┐
│ FAM-Edge (Flask :5000) │ │ FAM-Edge (Flask :5000) │
│ ├─ Video-Queue: 30s 轮询 /opt/fam-edge/gdrive_videos 登记整段素材 │ │ ├─ Video-Queue: 30s 轮询 /opt/fam-edge/gdrive_videos 登记整段素材 │
│ ├─ Video-Processor: 素材按 ss_motion_events 分割运动片段 → 只分析片段 │ │ ├─ Video-Processor: 素材按 ss_motion_events 分割运动片段 → 只分析片段 │
@@ -98,6 +98,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
│ ├─ /v1/chat/completionsNVIDIA → Gemini多 Key 轮换)→ 本地 Ollama 降级链 │ │ ├─ /v1/chat/completionsNVIDIA → Gemini多 Key 轮换)→ 本地 Ollama 降级链 │
│ ├─ Bearer token 鉴权fail-closed对外 0.0.0.0:5100非本系统专属 │ │ ├─ Bearer token 鉴权fail-closed对外 0.0.0.0:5100非本系统专属 │
│ └─ 独立 .env/opt/ai-gateway/.envNVIDIA/Gemini key 与 FAM-Edge 各自一份) │ │ └─ 独立 .env/opt/ai-gateway/.envNVIDIA/Gemini key 与 FAM-Edge 各自一份) │
│ Caddy :80托管 FAM-UI SPA + 反代 /api/* → NAS FAM-Corefrp 隧道 :8000
└───────────────────────────────┬───────────────────────────────────────────────┘ └───────────────────────────────┬───────────────────────────────────────────────┘
│ HTTP GET /api/oracle/sync?since=&token= (每 30 分钟) │ HTTP GET /api/oracle/sync?since=&token= (每 30 分钟)
@@ -109,7 +110,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
│ ▼ │ │ ▼ │
│ MariaDB (sentinel_home_ai): sync_videos / sync_events / sync_people / │ │ MariaDB (sentinel_home_ai): sync_videos / sync_events / sync_people / │
│ sync_cursor / chat_history │ │ sync_cursor / chat_history │
│ FAM-UI (Vue3 SPAFAM-Core 托管 dist) 读同步镜像 FAM-UI 已迁至云服务器 Caddy :80见上
└─────────────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────────────┘
``` ```
@@ -544,9 +545,9 @@ task_id=28930s 测试片段)全链路打通:推送 5.7MB → Edge 分析
| **AI-Gateway** | Oracle | `/opt/ai-gateway/` | **systemd `ai-gateway.service` 守护**Restart=always独立 venvPython 3.8gunicorn 绑定 `0.0.0.0:5100`(对外直接开放,非仅本机);部署代码后 `sudo systemctl restart ai-gateway` | | **AI-Gateway** | Oracle | `/opt/ai-gateway/` | **systemd `ai-gateway.service` 守护**Restart=always独立 venvPython 3.8gunicorn 绑定 `0.0.0.0:5100`(对外直接开放,非仅本机);部署代码后 `sudo systemctl restart ai-gateway` |
| Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1`**被 AI-Gateway 调用,不再被 FAM-Edge 调用** | | Ollama | Oracle | systemd 托管 | 环境变量 `OLLAMA_KEEP_ALIVE=-1`**被 AI-Gateway 调用,不再被 FAM-Edge 调用** |
> **外网访问frp 内网穿透)**NAS 跑 `frpc``/etc/frp/frpc.toml`S99frpc.sh 守护),映射到 Oracle `129.146.203.203`frps :7000 > **外网访问frp 内网穿透)**NAS 跑 `frpc``/etc/frp/frpc.toml`S99frpc.sh 守护),映射到云服务器 `129.146.26.249`frps :7000
> - `3000` → NAS Gitea、`8500` → NAS WordPress(8088)、**`8000` → NAS FAM-Core本系统** > - `3000` → NAS Gitea、`8500` → NAS WordPress(8088)、**`8000` → NAS FAM-Core本系统**
> - 外网入口 `http://129.146.203.203:8000`**需登录**(见 §5.2 `/api/login` > - 前端入口 `http://129.146.26.249/`Caddy 托管 FAM-UI/api 经 frp 隧道反代回 NAS FAM-Core :8000**需登录**(见 §5.2 `/api/login`
> >
> **登录校验2026-08-22 新增2026-08-23 改为 fail-closed**FAM-Core 全站拦截(`auth.py`)——页面未登录 302 `/login`内置深色登录页SPA 零改动),`/api/*` 未登录 401凭据 `FAM_AUTH_USER`/`FAM_AUTH_PASS`NAS `.env` 配置,**无硬编码默认值**——这两个变量跟 NAS SSH 密码是同一个值,公网入口不能有"没配置就退回已知密码"的兜底,`.env` 没配好这两项时直接拒绝所有登录);白名单免登录:`/login` `/api/login` `/api/logout` `/api/auth/check` `/health` `/api/ss/webhook` `/assets/*`。登录态为进程内 token + HttpOnly cookie**2 小时**),过期或重启 fam-core 需重新登录。 > **登录校验2026-08-22 新增2026-08-23 改为 fail-closed**FAM-Core 全站拦截(`auth.py`)——页面未登录 302 `/login`内置深色登录页SPA 零改动),`/api/*` 未登录 401凭据 `FAM_AUTH_USER`/`FAM_AUTH_PASS`NAS `.env` 配置,**无硬编码默认值**——这两个变量跟 NAS SSH 密码是同一个值,公网入口不能有"没配置就退回已知密码"的兜底,`.env` 没配好这两项时直接拒绝所有登录);白名单免登录:`/login` `/api/login` `/api/logout` `/api/auth/check` `/health` `/api/ss/webhook` `/assets/*`。登录态为进程内 token + HttpOnly cookie**2 小时**),过期或重启 fam-core 需重新登录。
@@ -570,11 +571,11 @@ server:
database: # MariaDBunix_socket 优先) database: # MariaDBunix_socket 优先)
unix_socket: "/run/mysqld/mysqld10.sock" unix_socket: "/run/mysqld/mysqld10.sock"
oracle_sync: # 增量镜像线程 oracle_sync: # 增量镜像线程
base_url: "http://129.146.203.203:5000" base_url: "http://129.146.26.249:5000"
token: "${ORACLE_SYNC_TOKEN}" # 与 Oracle 端 sync_api.token 一致 token: "${ORACLE_SYNC_TOKEN}" # 与 Oracle 端 sync_api.token 一致
interval_sec: 1800 # 每 30 分钟拉一次增量 interval_sec: 1800 # 每 30 分钟拉一次增量
chat_handler: chat_handler:
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: 120 timeout: 120
motion_notifier: # 运动监测(轮询主路径) motion_notifier: # 运动监测(轮询主路径)
enabled: true enabled: true
@@ -585,7 +586,7 @@ motion_notifier: # 运动监测(轮询主路径)
dsm_password: "${DSM_PASSWORD}" dsm_password: "${DSM_PASSWORD}"
camera_ids: [2] camera_ids: [2]
camera_name_to_id: {"Generic_ONVIF-001": 2} camera_name_to_id: {"Generic_ONVIF-001": 2}
oracle_base_url: "http://129.146.203.203:5000" oracle_base_url: "http://129.146.26.249:5000"
oracle_token: "${ORACLE_SYNC_TOKEN}" oracle_token: "${ORACLE_SYNC_TOKEN}"
poll_interval_sec: 60 poll_interval_sec: 60
poll_window_hours: 2 poll_window_hours: 2
@@ -720,7 +721,7 @@ cd /volume1/web/sentinel-home-ai && bash start_core.sh
# 3. 启动 FAM-EdgeOraclesystemd 守护) # 3. 启动 FAM-EdgeOraclesystemd 守护)
sudo systemctl restart fam-edge sudo systemctl restart fam-edge
# 4. 前端Vue3构建后由 FAM-Core 托管,无需独立进程) # 4. 前端Vue3构建后由云服务器 Caddy :80 托管,/api 经 frp 隧道反代回 NAS FAM-Core无需在 NAS 独立进程)
cd fam-ui && npm run build # 产物 fam-ui/dist/ cd fam-ui && npm run build # 产物 fam-ui/dist/
# 或使用脚本 # 或使用脚本
@@ -756,11 +757,11 @@ cd fam-ui && npm run build # 产物 fam-ui/dist/
| 项目 | 值 | | 项目 | 值 |
|------|-----| |------|-----|
| 公网 IP | 129.146.203.203 | | 公网 IP | 129.146.26.249 |
| SSH 用户 | ubuntu密钥 `~/.ssh/oracle_sentinel` | | SSH 用户 | ubuntu密钥 `~/.ssh/oracle_new` |
| 系统 | aarch64 (Ampere A1 2C12G), Ubuntu 20.04 LTS | | 系统 | aarch64 (Ampere A1 2C12G), Ubuntu 20.04 LTS |
| Tailscale | 100.74.137.126(已安装在线,与 NAS 端口不通 | | Tailscale | 已安装未启用(备用;新机公网直连为主 |
| 登录命令 | `ssh -i ~/.ssh/oracle_sentinel ubuntu@129.146.203.203` | | 登录命令 | `ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249` |
### 10.4 Gitea 代码仓库 ### 10.4 Gitea 代码仓库
@@ -836,7 +837,7 @@ export NVIDIA_API_KEY="nvapi-9cFAdO5xdbwPuxS8KGRTnlVimn1gJzbbbzWNhPwHa_Yl3pTe-Pf
- **运动事件驱动**v32026-08-22不再分析整段视频。NAS MotionNotifier 轮询 SS 事件列表60s游标续用/失败重试/心跳)推送 `ss_motion_events`Oracle 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac`,只分割已结束事件,`motion_event_id` 幂等),**只分析运动片段**;前端契约不变 - **运动事件驱动**v32026-08-22不再分析整段视频。NAS MotionNotifier 轮询 SS 事件列表60s游标续用/失败重试/心跳)推送 `ss_motion_events`Oracle 整段素材按运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac`,只分割已结束事件,`motion_event_id` 幂等),**只分析运动片段**;前端契约不变
- **数据迁移**2026-08-22整段提取的旧数据Oracle videos/events/people + NAS 镜像已清空并按运动视频重新提取历史素材NAS 轮询启动前)无运动事件,时间轴已过滤其空会话 - **数据迁移**2026-08-22整段提取的旧数据Oracle videos/events/people + NAS 镜像已清空并按运动视频重新提取历史素材NAS 轮询启动前)无运动事件,时间轴已过滤其空会话
- **人物管理重设计**2026-08-22人物卡新增「运动片段」区块缩略图/时间/摘要/事件数,点击跳时间轴定位);新增 `GET /api/ui/people/clips`Timeline 支持 `?video=` 定位 - **人物管理重设计**2026-08-22人物卡新增「运动片段」区块缩略图/时间/摘要/事件数,点击跳时间轴定位);新增 `GET /api/ui/people/clips`Timeline 支持 `?video=` 定位
- 前端从 Streamlit 迁移为 **Vue3 + Vite + Tailwind SPA**(由 FAM-Core 托管 dist - 前端从 Streamlit 迁移为 **Vue3 + Vite + Tailwind SPA**2026-08-25 起前端托管由 NAS 迁至云服务器 Caddy :80/api 经 frp 隧道反代回 NAS FAM-Core
- 运动事件链路NAS→Oracle 单向推送)+ 心跳 fail-openGemini 多 Key 轮换 + 模型调用统计 - 运动事件链路NAS→Oracle 单向推送)+ 心跳 fail-openGemini 多 Key 轮换 + 模型调用统计
- 双端部署NAS `start_core.sh`source .envOracle **systemd `fam-edge.service` 守护** - 双端部署NAS `start_core.sh`source .envOracle **systemd `fam-edge.service` 守护**

View File

@@ -27,15 +27,15 @@ cp config/config.yaml.example config/config.yaml
cd /volume1/web/sentinel-home-ai && bash start_core.sh cd /volume1/web/sentinel-home-ai && bash start_core.sh
``` ```
### 1.3 FAM-UIVue3 SPA无独立进程 ### 1.3 FAM-UIVue3 SPA托管于云服务器 Caddy :80
```bash ```bash
cd /Users/ericwyuan/Desktop/Work/sentinel-home-ai/fam-ui # 本地开发机 cd /Users/ericwyuan/Desktop/Work/sentinel-home-ai/fam-ui # 本地开发机
npm install npm install
npm run build # 产物 fam-ui/dist/ npm run build # 产物 fam-ui/dist/
# 部署 dist 到 NAS构建产物不入库 # 部署 dist 到云服务器Caddy 静态目录,/api 经 frp 隧道反代回 NAS
tar czf - fam-ui/dist | ssh -p 2222 ericwyuan@192.168.50.64 \ tar czf - fam-ui/dist | ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 \
'cd /volume1/web/sentinel-home-ai && rm -rf fam-ui/dist && tar xzf -' 'sudo tar xzf - --strip-components=2 -C /var/www/fam-ui && sudo chown -R ubuntu:ubuntu /var/www/fam-ui'
# 浏览器访问 http://192.168.50.64:8000由 FAM-Core static_app.py 托管Vue Router history 回退 # 浏览器访问 http://129.146.26.249/Caddy 托管,/api/* 反代 NAS fam-core
``` ```
## 2. Oracle 端部署 (FAM-Edge + Ollama + FFmpeg) ## 2. Oracle 端部署 (FAM-Edge + Ollama + FFmpeg)
@@ -73,7 +73,7 @@ tar czf - --exclude=venv --exclude=__pycache__ fam-core | \
# Oraclefam-edge/opt/fam-edge 是 fam-edge 根,--strip-components=1 解临时目录再 cp # Oraclefam-edge/opt/fam-edge 是 fam-edge 根,--strip-components=1 解临时目录再 cp
tar czf - --exclude=venv --exclude=__pycache__ --exclude=data --exclude=gdrive_videos fam-edge | \ tar czf - --exclude=venv --exclude=__pycache__ --exclude=data --exclude=gdrive_videos fam-edge | \
ssh ubuntu@129.146.203.203 'mkdir -p /opt/fam-edge/tmp_d && tar xzf - --strip-components=1 -C /opt/fam-edge/tmp_d && \ ssh ubuntu@129.146.26.249 'mkdir -p /opt/fam-edge/tmp_d && tar xzf - --strip-components=1 -C /opt/fam-edge/tmp_d && \
cp -rf /opt/fam-edge/tmp_d/* /opt/fam-edge/ && rm -rf /opt/fam-edge/tmp_d && sudo systemctl restart fam-edge' cp -rf /opt/fam-edge/tmp_d/* /opt/fam-edge/ && rm -rf /opt/fam-edge/tmp_d && sudo systemctl restart fam-edge'
``` ```

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 就该跑)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------