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整段素材保留
Oracle 按 NAS 推送的 `ss_motion_events`start_time/durationUnix epochffmpeg 分割运动片段
`-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推送
/api/ss/motion游标 MariaDB 续用 + 失败批次不前进 + 心跳。Webhook 端点保留为可选补充。
- **SS duration=0 = 动作进行中**(结束才回填真实时长),分割只处理已结束事件。
@@ -18,10 +21,11 @@
- 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`
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`
手动 setsid 会端口冲突venv `/opt/fam-edge/venv`;库 `/opt/fam-edge/data/oracle.db`
素材 `/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
## 提交规范(强制)

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 |
@@ -192,7 +192,7 @@
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
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 够用)
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 超时
@@ -378,7 +378,7 @@ AI 分析瓶颈: 帧5耗时 229s (疑似 ARM CPU 热降频), 其余帧 50-65s
/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}`
- **验证结果**
- Edge 单测:`/api/edge/chat/ask` → 200`provider=nvidia`Gemini 30s 超时后 NVIDIA 兜底成功,耗时 51s

View File

@@ -3,7 +3,7 @@
> 多模型容灾降级 + 交互式命名 + AI 对话的家庭监控系统。
> 本文档为项目需求文档与 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-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
- **数据流向**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
@@ -63,13 +63,13 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
| 节点 | 角色 | 硬件 | 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 |
| 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 调用) |
| 家庭网络 | 用户入口 | 普通终端 | 192.168.50.0/24 | 浏览器访问 `http://192.168.50.64:8000` |
| 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 + 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 调用) |
| 家庭网络 | 用户入口 | 普通终端 | 公网 / 家庭网络 | 浏览器访问 `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 轮询驱动)**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供其他项目直接接入不止服务本系统
- Tailscale 两节点已安装在线,但 NAS tailscaled 为 userspace 模式且防火墙端口不通,暂走公网 IP
@@ -82,7 +82,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
└──────────────┬───────────────────────┘
│ rclone 定时同步systemd timer
┌──────────────────────── Oracle Cloud (129.146.203.203) ────────────────────────┐
┌──────────────────────── Oracle Cloud / 云服务器 (129.146.26.249) ────────────────────────┐
│ FAM-Edge (Flask :5000) │
│ ├─ Video-Queue: 30s 轮询 /opt/fam-edge/gdrive_videos 登记整段素材 │
│ ├─ Video-Processor: 素材按 ss_motion_events 分割运动片段 → 只分析片段 │
@@ -98,6 +98,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
│ ├─ /v1/chat/completionsNVIDIA → Gemini多 Key 轮换)→ 本地 Ollama 降级链 │
│ ├─ Bearer token 鉴权fail-closed对外 0.0.0.0:5100非本系统专属 │
│ └─ 独立 .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 分钟)
@@ -109,7 +110,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
│ ▼ │
│ MariaDB (sentinel_home_ai): sync_videos / sync_events / sync_people / │
│ 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` |
| 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本系统**
> - 外网入口 `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 需重新登录。
@@ -570,11 +571,11 @@ server:
database: # MariaDBunix_socket 优先)
unix_socket: "/run/mysqld/mysqld10.sock"
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 一致
interval_sec: 1800 # 每 30 分钟拉一次增量
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
motion_notifier: # 运动监测(轮询主路径)
enabled: true
@@ -585,7 +586,7 @@ motion_notifier: # 运动监测(轮询主路径)
dsm_password: "${DSM_PASSWORD}"
camera_ids: [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}"
poll_interval_sec: 60
poll_window_hours: 2
@@ -720,7 +721,7 @@ cd /volume1/web/sentinel-home-ai && bash start_core.sh
# 3. 启动 FAM-EdgeOraclesystemd 守护)
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/
# 或使用脚本
@@ -756,11 +757,11 @@ cd fam-ui && npm run build # 产物 fam-ui/dist/
| 项目 | 值 |
|------|-----|
| 公网 IP | 129.146.203.203 |
| SSH 用户 | ubuntu密钥 `~/.ssh/oracle_sentinel` |
| 公网 IP | 129.146.26.249 |
| SSH 用户 | ubuntu密钥 `~/.ssh/oracle_new` |
| 系统 | aarch64 (Ampere A1 2C12G), Ubuntu 20.04 LTS |
| Tailscale | 100.74.137.126(已安装在线,与 NAS 端口不通 |
| 登录命令 | `ssh -i ~/.ssh/oracle_sentinel ubuntu@129.146.203.203` |
| Tailscale | 已安装未启用(备用;新机公网直连为主 |
| 登录命令 | `ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249` |
### 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` 幂等),**只分析运动片段**;前端契约不变
- **数据迁移**2026-08-22整段提取的旧数据Oracle videos/events/people + NAS 镜像已清空并按运动视频重新提取历史素材NAS 轮询启动前)无运动事件,时间轴已过滤其空会话
- **人物管理重设计**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 `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
```
### 1.3 FAM-UIVue3 SPA无独立进程
### 1.3 FAM-UIVue3 SPA托管于云服务器 Caddy :80
```bash
cd /Users/ericwyuan/Desktop/Work/sentinel-home-ai/fam-ui # 本地开发机
npm install
npm run build # 产物 fam-ui/dist/
# 部署 dist 到 NAS构建产物不入库
tar czf - fam-ui/dist | ssh -p 2222 ericwyuan@192.168.50.64 \
'cd /volume1/web/sentinel-home-ai && rm -rf fam-ui/dist && tar xzf -'
# 浏览器访问 http://192.168.50.64:8000由 FAM-Core static_app.py 托管Vue Router history 回退
# 部署 dist 到云服务器Caddy 静态目录,/api 经 frp 隧道反代回 NAS
tar czf - fam-ui/dist | ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 \
'sudo tar xzf - --strip-components=2 -C /var/www/fam-ui && sudo chown -R ubuntu:ubuntu /var/www/fam-ui'
# 浏览器访问 http://129.146.26.249/Caddy 托管,/api/* 反代 NAS fam-core
```
## 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
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'
```

View File

@@ -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_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 .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')

View File

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