Compare commits

..

21 Commits

Author SHA1 Message Date
ericwyuan
deaebf22b4 fix(fam-edge): 生产者扫描跳过已被 DiskGuard 清理的文件
生产者列目录之后、读 mtime 之前,DiskGuard 可能刚好把那个文件删了(两个后台线程
的正常竞态),os.path.getmtime 抛 FileNotFoundError。代价不是少处理一个文件,而是
**整轮扫描中断**——排在后面的新素材本轮全都登记不上,要等下一轮。

线上日志:生产者扫描异常: [Errno 2] No such file or directory:
/opt/fam-edge/gdrive_videos/20260911PM/Generic_ONVIF-001-20260911-234730-...mp4
(01:30:01 抛出,同一秒 DiskGuard 正在清理那批文件)

改成捕获 OSError 跳过该文件继续扫。新增 tests/test_video_queue.py:被删的跳过且
后面的照常登记、仍在写入的(mtime 太新)依然跳过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:35:15 +08:00
ericwyuan
3b5f7db51d fix(fam-edge): SQLite 改每线程一条连接,修好 DiskGuard 等一切并发写
OracleDB 原来在 __init__ 里建一条 check_same_thread=False 的连接给全进程共用,
而 VideoQueue / PersonService / DiskGuard 三个后台线程 + gunicorn 的请求线程都在
并发读写它。sqlite3 的连接对象本来就不是可并发共享的,事务状态互相踩踏,线上
累计刷出三类错误(同一个根因):

  database is locked                            71604 次
  cannot start a transaction within a transaction  378 次
  no more rows available                           92 次(堆栈落在 commit())

最严重的后果是磁盘守护形同虚设:DiskGuard 的清理被打断 2837 次,成功仅 128 次,
剩余空间在 2.7GB 和 16GB 之间来回荡——赶上 rclone 集中下载(实测 5 分钟写入
12.6GB)就掉进危险区。NAS 推来的运动事件被 500 打回也是它(失败批次不推进游标,
下一轮补推,所以没丢事件)。

改法:_conn 改成 @property,从 threading.local() 取当前线程的连接,没有就新建
(WAL + busy_timeout=10000)。做成 property 是因为外部调用方(api_gateway 的
activity 端点)也在直接用 db._conn.execute(...),这样全部现有调用点原样工作。
close() 相应改成收掉所有线程开过的连接。_write_lock 保留,复合写语义不变。

测试:新增 8 线程 × 25 轮并发读写、close 收连接两个用例;在旧代码上稳定复现
同族错误 cannot commit transaction - SQL statements in progress。

线上验证:重启后 DiskGuard 立刻跑通一轮(清 40 个文件,剩余回到 15.3GB),
三类 SQLite 错误在重启后的日志里均为 0。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:34:24 +08:00
ericwyuan
a494d60361 docs: 补入 2026-09-03 工作日志(同步 1062 复发 + 甲骨文过载)
这份日志一直没入库,同目录 8/20 到 8/31 的都在。内容是 9/3 那次排查记录:
Oracle 侧 SQLite id 整体重排导致 sync_videos/sync_people 二次 1062、游标卡死
(修复即 0238030),以及同一天甲骨文整机过载挂死、SSH 握手被拒的环境问题。

后半段那个「Oracle 盒子过载」当时记为未解决,现在有了后续:运动事件推送从那天
起断流十天,直到 2026-09-13 全量迁云时才发现并恢复(见 PROGRESS 同日条目)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:55:08 +08:00
ericwyuan
da60365214 feat(fam-notifier): 补开机自启脚本,NAS 重启后推送不再静默断流
NAS 上没有 systemd,rc.d 是唯一的守护手段。旁边 frpc / nas-monitor / comic-api
都有 S99 脚本,唯独这个推送进程没有——它一旦随重启消失,运动事件就悄无声息地
断流,而且不打开网站根本发现不了。2026-09-04 到 09-12 断档整整十天就是这个剧本
(当时它还在 fam-core 里,fam-core 挂了没人重启)。

- 照抄 S99nas-monitor.sh 的写法:等 volume1 挂载就绪(最多 90s)、pidfile 判重
- 以 ericwyuan 而非 root 运行,保持 logs/ 与 data/cursor.json 属主跟手动启动一致
- DSM 上没有 pgrep,用 grep 字符类找 pid
- su 自身的 stdio 也断开:否则通过 SSH 执行时命令跑完了会话却不退出(实测踩到)

已装到 NAS 的 /usr/local/etc/rc.d/ 并验证 start/stop/restart/status;
restart 后游标从本地文件正确读回(33081),没有被重置成 SS 当前最大 id。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:35:36 +08:00
ericwyuan
1964e976f4 refactor: 全量迁云——fam-core 直读甲骨文 SQLite,NAS 只剩推送进程
前一天刚把登录迁到甲骨文,隔天 NAS 上的 fam-core 又挂了导致数据接口 502。
盘点后确认:甲骨文的 SQLite 才是权威数据源(videos 3113 / events 16159 /
people 60 / model_calls 9876),NAS 的 MariaDB 全是它的镜像——前端读的数据
本来就产自甲骨文,绕了一圈回家又绕回来。

改动:
- db_layer.py 从 725 行重写成 377 行:MySQL 镜像查询改为直读 fam-edge 的
  SQLite。5 个 upsert_sync_*(约 300 行去重逻辑,8/29 和 9/3 两次 1062 事故的
  发源地)连同 oracle_sync.py 整个删除。SQL 方言:JSON_CONTAINS -> json_each
  (前置 json_valid,历史脏数据不会把查询搞崩)、LEFT() -> substr()、%s -> ?。
  函数名 get_sync_* 一并改掉——已经没有 sync 这回事了
- 新增 edge_client.py:写操作(改名/删除)、帧图头像、服务状态都打给同机
  fam-edge,全走 127.0.0.1
- 新增 fam-notifier/:motion_notifier 从 fam-core 拆出独立成服务,游标从
  MariaDB 换成本地 JSON 文件。NAS 上从此没有 Flask、没有数据库、没有监听端口
- fam-core 移到甲骨文 /opt/fam-core(systemd,gunicorn -w 2,只绑
  127.0.0.1:5401——5400 被 chat-relay 占了)。Caddy 的 /api/* 从"frp 隧道
  回源 NAS"改成同机反代,forward_auth 闸门不变
- 前端删掉侧边栏同步面板、统计页同步状态、服务状态页的"NAS 同步"卡片与
  "立即同步"按钮(背后的镜像层已不存在);换成"NAS 运动推送"卡片,读
  fam-edge activity 新增的 motion 段(心跳年龄 + 最近事件)
- 顺带修掉一个隐蔽 bug:镜像表为保外键稳定用的是 NAS 本地自增 id,而帧图接口
  要的是甲骨文的 id,两边在 9/3 那次 id 重排后就对不上了。现在只有一套 id

测试:fam-core 21(新增 12 个 db_layer 用例:脏 JSON 不崩、人物精确匹配不误伤
"人物B"、日期过滤、统计口径、chat_history 懒建表)、fam-notifier 6、
fam-edge 157,全绿。

生产验证:甲骨文 /api/ui/stats 返回 videos 2965 / events 16159 / people 59;
NAS 侧 fam-notifier 已推送成功(事件 33070-33072 落库,心跳新鲜);
chat_history 19 条经 scripts/import_chat_history.py 迁移完成。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:03:32 +08:00
ericwyuan
dc284f4bf5 feat(fam-edge): 统一登录从 NAS fam-core 迁入,回调失败不再对跳死循环
登录入口原先跑在 NAS 的 fam-core 里,NAS 或 frp 隧道一挂,
smart-camera.zichuan.xyz/login 直接 502——连登录页都打不开。登录是入口,
不该依赖家里那台机器,整体搬到甲骨文(前端静态文件和 auth-hub 本来就在这台)。

跟旧实现的三处关键差异:
- 换 token / 拉 JWKS 走 AUTH_HUB_INTERNAL_BASE(本机 :5300),不再跨公网 TLS。
  旧链路上 PyJWKClient 用 urllib + 系统 CA(群晖易 CERTIFICATE_VERIFY_FAILED)、
  两机时钟偏差会让 iat 显得来自未来,这两个坑一起消失;iss 校验和浏览器跳转
  仍用公网 issuer
- 会话改无状态 HS256 签名 cookie,服务重启不掉线(旧实现是进程内 token 表)
- 回调失败渲染错误页,不再 302 回 /login。旧实现失败即跳 /login,而 auth-hub
  只要还有会话就立刻再签一个 code 跳回来,两边对跳成死循环,浏览器只报
  「重定向次数过多」,既看不到登录页也看不到原因

fam-core 侧删除 auth.py 与 test_auth.py,鉴权改由甲骨文 Caddy 的 forward_auth
前置拦截(打 /api/auth/verify)。

测试:fam-edge 新增 16 个用例,含「失败分支绝不 302」回归测试、
「服务端走内网但 iss 按公网校验」、「cookie 无状态」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:02:59 +08:00
ericwyuan
0238030128 fix(sync): 修复 Oracle id 重排导致的 sync_videos/sync_people 1062
Oracle 库重建/重排后 videos/people 的 id 跳变(2303 段→3408/4600+ 段),
旧 upsert 按 Oracle id 作主键插入时与已存在同 filename/label 行在 UNIQUE 键
上二次冲突报 1062,整批中止、游标不推进。

- upsert_sync_videos: 改为以 filename 业务键去重,命中就地 UPDATE 保留 NAS 原
  id(防 sync_events/model_calls/identity_map 的 video_id 外键失效),Oracle id
  落 oracle_id 列溯源;未命中插入优先用 Oracle id 对齐子表引用,主键冲突回退自增
- upsert_sync_people: 同模式,以 label 业务键去重(people.id 无外键引用)
- ddl.sql: sync_videos/sync_people 的 id 改为 NAS 本地自增主键 + 新增 oracle_id 列
- NAS 现网已迁移(加列/回填/改自增)并重启验证:单批补拉 videos+348 events+1017
  people+9 model_calls+500 identity_map+238,游标 09-02 13:55:21 → 09-03 08:51:11
2026-09-03 09:00:50 +08:00
ericwyuan
2ad0472578 fix(fam-ui): 未登录自动跳转 OIDC + 登录/退出入口,打通 smart-camera 登录 2026-09-01 12:23:48 +08:00
ericwyuan
db4f46140b fix(start_core): export AUTH_HUB_* env + fix .env path; docs: cutover to smart-camera.zichuan.xyz 2026-09-01 12:14:51 +08:00
ericwyuan
9c4db682d5 docs: 记录 8/29 同步 1062 修复与 8/31 auth-hub SSO 接入/清理部署;gitignore 排除独立项目 ai-gateway 2026-08-31 22:38:29 +08:00
ericwyuan
feb096ff3d docs: 订正 FAM-UI 部署位置描述(NAS -> 云服务器 Caddy)
多处历史遗留描述仍写 FAM-UI 部署在 NAS(含不存在的 static_app.py),
实际已迁至甲骨文云服务器由 Caddy 静态托管,逐处订正。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:50:42 +08:00
ericwyuan
5e1230d9b0 docs: 记录 auth-hub 生产 client 已注册,NAS 部署待执行
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:33:16 +08:00
ericwyuan
8120d2ae6a feat(fam-core): 登录改接 auth-hub 统一登录(OAuth2 Authorization Code + PKCE / OIDC)
移除本地 FAM_AUTH_USER/PASS 账号密码校验和内置登录表单,/login 改为 302
跳转 auth-hub /authorize,新增 /api/auth/callback 完成 code 换 token +
id_token 签名验证(PyJWT + JWKS),验证通过后种回原有 fam_session cookie,
is_authed()/全局登录拦截逻辑不变。接入参数走环境变量,未配置齐全 fail
closed。本地起 auth-hub 开发实例 + 真实浏览器验证过完整登录/登出闭环。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:32:26 +08:00
ericwyuan
988c63a8f9 fix(fam-core): 修复 sync_identity_map 增量同步 1062 主键冲突导致游标卡死
Oracle→NAS 增量同步反复报 (1062, "Duplicate entry '1073-汤圆' for key
'uq_video_raw_uid'"),整批失败、游标永不推进,同步状态页卡在"同步中"。

根因:sync_identity_map 以 Oracle person_identity_map.id(代理自增键)
作 NAS 主键,但 Oracle 库重建/恢复后这个 id 会被复用——实测 Oracle 现在
的 id=744 对应 (1073,'汤圆'),NAS 上 id=744 还是旧的 (1302,'人物E')。
upsert 先按 PK id 命中旧行,UPDATE 成 (1073,'汤圆') 后与已存在的同名行
在 uq_video_raw_uid 上二次冲突。

修复:镜像表改为 NAS 本地自增 id 主键,业务键 (video_id, raw_uid) 唯一,
Oracle 的 id 只落 oracle_id 列做溯源;upsert 的 UPDATE 子句不再改写
video_id/raw_uid 这两个键列,只更新 oracle_id/canonical_name/source 等。
ddl.sql 同步更新表定义。

已在 NAS 上完成现网迁移(ADD COLUMN oracle_id -> 回填 -> 主键改自增)并
验证:补拉同步成功恢复,游标正常推进,手动触发同步验证通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:11:10 +08:00
ericwyuan
05c7a8b70a feat(fam-edge): 闭集人物识别新增奶奶(成年女性按年龄段区分媳妇/奶奶)
家里从 4 人变成 5 人后,"媳妇=唯一成年女性"这条免费规则的前提被打破——
人物管理页面已经能看到一条 label="奶奶" 但从未稳定命中的记录(只出现过
1 次,特征都没落库),说明现在系统大概率把奶奶也误判成了媳妇。

先用免费的年龄段字段区分(老年=奶奶,其余=媳妇),不引入额外的模型调用
(区别于爷爷/爸爸那种"两个成年男性区分不开必须靠视觉比对参考图"的方案)
——如果后续观察发现年龄段判断不稳定导致误判,再考虑改成视觉比对。

新增 3 个单元测试覆盖(中年→媳妇/老年→奶奶/年龄段缺失默认媳妇),fam-edge
全量 142/142 通过。已部署 Oracle 并重启验证。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 22:42:26 +08:00
ericwyuan
c9354ed1a1 docs: 同步 8/28 迁移故障排查记录(FFmpeg 缺失 + 磁盘写满死循环 + DiskGuard)
README.md 新增 Disk-Guard 模块表条目、修正一处遗漏的机器规格描述
(2C12G -> 4C23G)、当前进度补充这次完整排查记录;PROGRESS.md 服务运行
状态表更新 + 新增详细的故障排查时间线记录。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:10:14 +08:00
ericwyuan
ef56ae5662 feat(fam-edge): 新增 DiskGuard 磁盘空间守护,剩余空间不足自动清理旧素材
背景:Oracle 磁盘曾经被写满(gdrive_videos 持续下载新素材、旧文件迟迟没
清理),触发 rclone 的一个安全机制——同步遇到 IO 错误就整体拒绝执行删除,
形成"越满越删不掉,越删不掉越满"的死循环,最终连新视频都下载不了。这次
排查+手动清理已经解决了当次故障,但需要一道独立于 rclone 同步之外的兜底,
防止再次悄悄写满没人发现。

- oracle_db.py 新增 get_oldest_purgeable_material():只挑最旧的、已完成
  分割阶段(status='done')的整段素材(非 motion_ 前缀),绝不碰运动片段
  (事件时间轴/人物头像依赖它)和还在处理中的素材
- disk_guard.py 新增 DiskGuard 后台线程:5 分钟检查一次,剩余空间 <10GB
  触发清理,删到 15GB 水位为止(留缓冲避免刚清完又立刻触发),复用已有的
  delete_video() 完成实际删除
- app.py 启动这个后台服务;/api/oracle/activity 新增 disk 字段(实时剩余
  空间 + 最近一次清理动作),供服务状态页展示
- 新增 9 个单元测试,全部通过(139/139)

已部署 Oracle 验证:DiskGuard 正常启动,配置生效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 12:08:04 +08:00
ericwyuan
3ec79de911 docs: 清理已删除的 /api/ss/webhook 端点在文档中的残留描述
上次 SS Webhook 重构(cf3d145)已经彻底移除了 /api/ss/webhook 接收端点
(motion_bp.py 不再注册该路由),但 README.md 里 5 处描述、鉴权白名单
说明都还停留在"保留为可选低延迟补充",跟实际代码不符,逐处订正。

顺带更新 vite.config.js 里一句过时注释(还写"生产环境由 fam-core 的
Flask 同源提供",实际前端已经迁到云服务器 Caddy 托管)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:31:41 +08:00
ericwyuan
09708bd9a4 fix(fam-core): 修复 /api/status 和 /api/ss/status 500(移除 Webhook 重构遗留死引用)
上次"移除 SS Webhook 可选路径"重构(cf3d145)删掉了 MotionNotifier 的
camera_name_to_id/_ss_name_to_id/_camera_loaded 三个属性初始化,但
status() 方法忘记同步删除对它们的引用,导致任何调用 status() 的地方
(/api/status、/api/ss/status)直接抛 AttributeError 500——这两个端点
自那次重构部署后就一直是坏的,服务状态页无法访问。

顺带修 test_auth.py 里断言 /api/ss/webhook 应该被鉴权白名单放行的过时
测试——这个端点本身已经在同一次重构里被彻底删除(motion_bp.py 不再
注册该路由),白名单移除是对的,是测试没跟上。

已在 NAS 上部署验证:登录后 /api/status 恢复正常返回。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 09:29:09 +08:00
ericwyuan
cf3d145c57 refactor(fam-core): 移除 SS Webhook 可选路径与 NAS 静态前端托管,兜底 Oracle 地址更新为 129.146.26.249 2026-08-25 23:56:02 +08:00
ericwyuan
7948f55b02 docs: 同步 8/25 迁移(前端迁云 Caddy:80、Oracle 换新机 129.146.26.249、旧 IP 清理、Drive 中转设计说明) 2026-08-25 23:55:57 +08:00
61 changed files with 2705 additions and 1811 deletions

3
.gitignore vendored
View File

@@ -42,3 +42,6 @@ logs/
# Env
.env
.env.local
# 其他独立项目(各有独立仓库,勿提交进本仓库)
ai-gateway/

View File

@@ -0,0 +1,20 @@
# 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 未配行动规则故休眠,
是否删除待用户拍板。
- **用户拍板:删除 webhook + NAS 前端托管,并已提交推送**(两个 commit 已 push 到 Gitea
`7948f55` docs 迁移文档、`cf3d145` refactor 移除 webhook/static_app 共删 329 行)。
**待办:代码尚未部署到 NAS**——需同步 fam-core 到 NAS、删 NAS fam-ui 目录 + start_ui.sh、
重启 fam-core 验证 /api 与登录(云端 :80 前端不受影响)。

View File

@@ -0,0 +1,37 @@
# 2026-08-29 工作日志
## 修复 Oracle→NAS 增量同步 1062 (uq_video_raw_uid 重复键) — 已部署验证
### 现象
- 同步状态页:状态"同步中"、`最近 —``本次增量 —`、游标卡在 `2026-08-29 04:57:15`
- 报错:`(1062, "Duplicate entry '1073-汤圆' for key 'uq_video_raw_uid'")`,游标永不推进
### 根因(两层)
1. **镜像表主键设计缺陷(主因)**`sync_identity_map` 以 Oracle `person_identity_map.id` 作主键。
Oracle 端 id 是 AUTOINCREMENT 代理键,但 Oracle 库重建/恢复后 id 会被复用——实测 Oracle
现在的 id=744 是 `(1073,'汤圆')`,而 NAS 上 id=744 还是旧的 `(1302,'人物E')`。旧 upsert 的
`ON DUPLICATE KEY UPDATE` 先按 **PK id** 命中旧行,再把它 UPDATE 成 `(1073,'汤圆')`,与
已有的 `(1073,'汤圆')` 行在 `uq_video_raw_uid` 上二次冲突 → 1062 → 整批失败 → 游标不推进
→ 每 30 分钟重复失败。
2. **陈旧 __pycache__部署陷阱**NAS 上 `.pyc` 时间戳比 `.py` 新(部署时 `.py` 带旧 mtime 拷贝),
Python 信任 pyc 加载了旧逻辑;另曾有两个 gunicorn master 并存8487/10914只有一个绑 8000。
### 修复
- **db_layer.py `upsert_sync_identity_map` v2**:镜像表改为 NAS 本地自增 `id` 主键,
业务键 `(video_id, raw_uid)` 唯一Oracle id 只落 `oracle_id` 列溯源UPDATE 子句不再改写
video_id/raw_uid只更新 oracle_id/canonical_name/source/updated_at/synced_at
- **scripts/ddl.sql**:同步更新表定义(`id INT AUTO_INCREMENT PRIMARY KEY` + `oracle_id INT`)。
- **现网迁移**`ADD COLUMN oracle_id` → 回填 `oracle_id=id``MODIFY id AUTO_INCREMENT`(无重复对,
迁移安全);修复早前测试误改的 3061 行 canonical_name。
- **部署**db_layer.py 用 stdin 管道覆盖 NAS**清空全部 __pycache__**;杀掉双 gunicorn单实例重启。
### 验证(通过)
- 09:55 一次补拉成功:`videos+260 events+1456 people+37 model_calls+744 identity_map+285`,游标推进到 09:55:06
- `/api/status``last_error=null``last_sync_at` 有值;手动 `/api/sync/trigger` ok拉 2videos/2events/1people/6calls/1identity
- `(1073,'汤圆')` 行已自愈:`oracle_id=744`NAS 旧 `(744→1302,'人物E')` 保留不冲突
### 待办/注意
- 本地 repo 有未提交改动:`fam-core/src/fam_core/db_layer.py``scripts/ddl.sql`(等用户拍板提交)
- 同类隐患sync_videos/sync_events/sync_model_calls 同样以 Oracle id 为主键,但 Oracle 侧这些表
只插入不 UPDATE、id 稳定,风险低;若将来 Oracle 也重建,需同样改法
- NAS 部署后必须清 __pycache__(或 touch .py否则旧 pyc 被加载(已踩坑)

View File

@@ -0,0 +1,43 @@
# 2026-08-31 工作日志
## fam-core 登录接入 auth-hub 统一登录(已部署 + 端到端验证)
### 目标(用户明确)
`http://129.146.26.249:8000`fam-core 公网入口frp → NAS:8000的登录改走 auth-hub
统一登录,账号 `ericwyuan` / `iLoveJava5` 登录。
### 现状与动作
- SSO 代码本地早已提交(`8120d2a feat(fam-core): 登录改接 auth-hub 统一登录`NAS 未部署(旧密码登录版)。
- **auth-hub 侧(甲骨文 /opt/auth-hub**
- FAM-Core client `a2PJTZuKQYxt1oGh`(已存在)→ 轮换 secret 得明文
`yZfJfD3xJmrCj9PQlbt7P8An9ebZ-nT2f3v4BTtOSu8`(旧密钥失效)
- redirect_uris 增加 `http://129.146.26.249:8000/api/auth/callback`(原 :80 回调保留,
auth-hub 精确匹配,用户入口是 :8000 所以回调也要 :8000 路径)
- **NAS 侧fam-core**
- venv 补装 `PyJWT==2.13.0` + `cryptography==50.0.1`RS256 验签必需)
- 部署新 `auth.py`cat 管道覆盖)
- `.env` 追加 4 个变量:`AUTH_HUB_ISSUER=http://129.146.26.249:5300`
`AUTH_HUB_CLIENT_ID=a2PJTZuKQYxt1oGh``AUTH_HUB_CLIENT_SECRET=<上面>`
`AUTH_HUB_REDIRECT_URI=http://129.146.26.249:8000/api/auth/callback`
-__pycache__、杀双 gunicorn今天 18:56 又一个旧进程 + 8/29 旧 master、单实例重启
### 端到端验证curl cookie jar 全链路)
`/login` 302 → auth-hub `/authorize`PKCE→ hub 登录ericwyuan/iLoveJava5 200
`authorize` 302 回 `:8000/api/auth/callback?code&state` → fam-core 换 token+JWKS 验签 →
种 fam_session → `/api/auth/check` = `{"authed":true}``/api/status` 200 ✓
### 注意
- 本地 repo 3 个提交待 push8120d2a SSO + 2 docs等用户确认推送
- NAS 仍跑旧版其余代码webhook 移除等未部署,独立待办)
- 密码登录 `/api/login` 已移除fam-ui SPA 无登录逻辑,不受影响
## 补充webhook/旧前端清理部署到 NAS用户确认"清理"
- 全量比对 NAS vs repo`app.py``motion_bp.py``config.yaml` 三处是旧版(其余已同步)
- 同步3 个文件app.py 移除 static_bp/refresh_camera_map 调用motion_bp.py 移除 webhook 端点;
config.yaml 移除 camera_name_to_id/enrich_window_sec
- 删除废弃:`static_app.py``fam-ui/` 目录、根 `start_ui.sh``scripts/start_ui.sh`
- 清 pycache + 单实例重启
- 验证:启动日志无 `refresh_camera_map` 告警;`/api/ss/webhook` → 401已移除白名单与路由
SSO 登录 `/login` 302 → auth-hub 正常、`{"authed":true}``/api/ss/status` 200Oracle 同步正常
- 至此 NAS fam-core 代码与本地 repo 全量一致(除 .env/venv/logs

View File

@@ -0,0 +1,39 @@
# 2026-09-03 同步 1062 复发 + Oracle 盒子过载
## 问题
用户报 sync 状态卡在「同步中」+ `1062 Duplicate entry '...mp4' for key 'filename'`
游标停在 `2026-09-02 13:55:21`
## 根因(与 8/29 identity_map 同一类)
Oracle FAM-Edge 的 SQLite 视频/人物 id 整体重排2303 段 → 3408/4600+ 段),
NAS 镜像表 `sync_videos`/`sync_people` 旧 upsert 以 Oracle id 当主键插入,
与已存在的同 `filename`/`label` 行在 UNIQUE 键上二次冲突 → 1062整批中止、游标不推进。
量化:本次积压 348 videos 中 **114 行**会 10629 people 中 **6 行**会 1062。
events/model_calls 仅 PK idON DUPLICATE 按 id 更新,不会 1062→ 无需改。
## 修复(已部署 NAS + 提交推送 0238030
- `upsert_sync_videos`:改以 `filename` 业务键去重,命中就地 UPDATE 保留 NAS 原 id
(防 sync_events/model_calls/identity_map 的 video_id 外键失效Oracle id 落
`oracle_id` 列溯源;未命中插入优先用 Oracle id 对齐子表引用,主键冲突回退自增。
- `upsert_sync_people`:同模式,以 `label` 业务键去重people.id 无外键引用)。
- `scripts/ddl.sql`sync_videos/sync_people 的 id 改 NAS 本地自增 + 新增 oracle_id 列。
- NAS 现网迁移:两表加 oracle_id、回填 oracle_id=id、id 改 AUTO_INCREMENT重启。
- 验证:单批补拉 videos+348 events+1017 people+9 model_calls+500 identity_map+238
游标 → `2026-09-03 08:51:11`,无 1062。目标行保留 id=2303、oracle_id=3408。
## 第二个问题Oracle 盒子过载/挂死(未解决,环境层)
NAS 日志 08:5108:56 持续:`推送运动事件到 Oracle 失败 Connection reset` /
`500 {"error":"database is locked"}` / `RemoteDisconnected`
FAM-Edge /health 先 200status ok后变 HTTP 000最终整盒 129.146.26.249 全暗
:80/:5000/:5300/:7000/:8123 均 000但 TCP :22/:5000 端口 OPEN、SSH 握手被拒
kex 阶段 Connection closed→ 进程卡死/资源耗尽4C23G 上 FAM-Edge 分析管线 +
ai-gateway + garmin + auth-hub + frps + caddy 挤一起SQLite 锁竞争 + 可能 OOM
**SSH 连不进去,无法远程修复。** 需用户在 Oracle Cloud 控制台重启 VM或等其自恢复。
恢复后 NAS 的 oracle_sync 后台线程30min 周期)会自动从游标 08:51:11 续拉,
motion_notifier 也会恢复推送。
## 经验
- 所有以 Oracle id 为镜像主键的表迟早会因 Oracle 库重建/重排踩坑。已修:
identity_map(8/29)、videos+people(9/3)。events/model_calls 目前 PK-only 安全,
但若将来 Oracle 重建导致子表引用错位,需同样改造(加 oracle_id + 业务键)。
- Oracle 盒子服务过多挤在 4C23G建议拆分或给 FAM-Edge SQLite 开 WAL + 限速。

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,11 +21,23 @@
- 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 已退役。
- **登录2026-08-31 起)**fam-core 不再自己校验密码,登录全权走 auth-hub 统一登录
OAuth2 Auth Code + PKCE / OIDC`http://129.146.26.249:5300`。fam-core client
`a2PJTZuKQYxt1oGh`,回调 `:8000/api/auth/callback`auth-hub 侧注册了 :80 和 :8000 两个回调)。
NAS `.env``AUTH_HUB_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI`(缺任一 fail closed
账号:`ericwyuan / iLoveJava5`hub 端。NAS venv 需 PyJWT + cryptographyRS256 验签)。
- 部署:本地 git 提交 push Gitea → tar 管道NAS 直接解包Oracle `--strip-components=1` 到临时目录再 cp避免动 data/venv
- **NAS 部署坑2026-08-29 踩过)**:拷贝 `.py` 会带旧 mtime`.pyc``.py`Python 加载陈旧字节码
(旧逻辑上线,曾导致 1062 假象)。部署后必须 `find src -name __pycache__ -exec rm -rf {} +` 再重启;
`pkill -f` 会匹配 SSH 自身命令导致断连(用 PID 文件或精确 pattern
- **镜像表键设计2026-08-29 定稿)**`sync_identity_map` 不再以 Oracle `person_identity_map.id` 作主键
Oracle 库重建会复用 id改 NAS 本地自增 `id` + 唯一键 `(video_id, raw_uid)`Oracle id 落 `oracle_id` 列溯源;
同批 upsert 的 UPDATE 子句不改写键列,避免 uq 二次冲突 1062。sync_videos/events/model_calls 同理(风险低未改)。
## 提交规范(强制)
- 格式 `[阶段X.Y子任务号] 子任务名称 - 完成内容简述`Bug 修复 `fix(模块): 问题简述`

View File

@@ -1,17 +1,45 @@
# 项目进度追踪
> 最后更新: 2026-08-24
> 最后更新: 2026-08-31
## 服务运行状态
| 服务 | 节点 | 地址 | 状态 | 验证结果 |
|------|------|------|------|---------|
| FAM-Core | NAS | 0.0.0.0:8000 | ✅ 运行中 | health=ok, gunicorn 单 workerOracle-Sync + MotionNotifier 轮询 SS 事件推送(游标 DB 续用/失败重试);已部署事件时间轴删除功能 |
| FAM-Edge | Oracle | 0.0.0.0:5000 | ✅ 运行中 | systemd 守护fam-edge.service素材→运动片段分割→只分析片段问答改为转发 AI-Gateway队列消费正常已部署 `/api/oracle/video/delete` |
| FAM-Core | NAS | 0.0.0.0:8000 | ⚠️ 代码已完成,待生产部署 | health=ok, gunicorn 单 workerOracle-Sync + MotionNotifier 轮询 SS 事件推送(游标 DB 续用/失败重试);已部署事件时间轴删除功能`/api/status`/`/api/ss/status` 500 bug 已修复;**登录改接 auth-hub 统一登录OIDC本地全链路验证通过生产 client 注册 + NAS `.env` 配置 + 部署重启尚未执行** |
| FAM-Edge | Oracle(新机 129.146.26.249 | 0.0.0.0:5000 | ✅ 运行中 | systemd 守护fam-edge.service素材→运动片段分割→只分析片段问答改为转发 AI-Gateway队列消费正常已部署 `/api/oracle/video/delete`FFmpeg 已补装;新增 DiskGuard 磁盘守护 |
| **AI-Gateway** | Oracle | 0.0.0.0:5100 | ✅ 运行中 | systemd 守护ai-gateway.service2026-08-23 新增;独立仓库/独立部署;`/health` 正常端到端问答实测成功provider=nvidia |
| MariaDB | NAS | 127.0.0.1:3306 | ✅ 运行中 | 10.11.11, 6 张表, utf8mb4 |
| Ollama | Oracle | 127.0.0.1:11434 | ✅ 运行中 | qwen2.5:7b仅智能问答兜底不参与视觉/融合);**被 AI-Gateway 调用,不再被 FAM-Edge 直接调用** |
## 2026-08-31 FAM-Core 接入 auth-hub 统一登录OIDC
- **背景**:独立的统一登录/SSO 服务 [auth-hub](http://129.146.26.249:3000/ericwyuan/auth-hub)(跟本项目完全独立的仓库/数据库/部署OAuth2 Authorization Code + PKCE + OIDC已单独开发验证完成本地全链路 47 单测通过目标是自己的几个网站统一接到这一处身份服务不用每个网站各自维护一份账号密码。FAM-Core 是第一个接入方。
- **改造范围**`fam-core/src/fam_core/auth.py` 全量重写commit 待提交):
- 原来自己校验 `FAM_AUTH_USER`/`FAM_AUTH_PASS` 的账号密码逻辑、内置深色登录表单页全部移除;`GET /login` 改为直接 302 跳转 auth-hub `/authorize`(带 PKCE `code_challenge`/`state`,均存进程内 `_pending`10 分钟过期)
- 新增 `GET /api/auth/callback`:收 auth-hub 回跳的 `code`,服务端到服务端 `POST /token``id_token`(用 `PyJWT` + `PyJWKClient` 拉 auth-hub JWKS 验 RS256 签名 + `iss`/`aud`),验证通过后种回原有的 `fam_session` HttpOnly cookie**2 小时有效,机制不变**)——下游 `is_authed()`/`init_auth()` 全局拦截逻辑完全没动
- 接入参数 `AUTH_HUB_ISSUER`/`AUTH_HUB_CLIENT_ID`/`AUTH_HUB_CLIENT_SECRET`/`AUTH_HUB_REDIRECT_URI` 走环境变量NAS `.env`,沿用 `FAM_AUTH_*` 时代"未配置齐全直接拒绝所有登录"的 fail-closed 取舍),`AUTH_HUB_REDIRECT_URI` 必须与 auth-hub 端 `manage_clients create` 登记的 redirect_uri 逐字符一致
- `requirements.txt` 新增 `PyJWT>=2.8.0``cryptography>=42.0.0`
- **访问控制取舍(用户明确决策)**:不在 fam-core 侧加用户名白名单——任何在 auth-hub 上(自助注册 + 管理员审批后)拥有账号的人登录后都能访问本系统;不保留 `FAM_AUTH_USER`/`FAM_AUTH_PASS` 作为备用登录方式SSO 是唯一入口
- 测试:`tests/test_auth.py` 全量重写PKCE 生成、state 校验、token 交换成功/失败、id_token 验签失败、白名单、`before_request` 拦截fam-core 全量 39/39 通过
- **端到端验证(本地)**:本地起了一份 auth-hub 开发实例(:5300+ 一个仅含 `auth_bp` 的最小 Flask 壳子(模拟 fam-core跳过 MariaDB 依赖),用真实浏览器走完整 Authorization Code + PKCE 闭环——`/` 未登录 302 `/login` → auth-hub 登录页 → 登录成功回跳 `/api/auth/callback` → 换 token + 验签 → 种 cookie → 落地首页;`/api/auth/check` 确认 cookie 生效;`/api/logout` 确认清会话。验证完把临时创建的 OAuth client 和测试账号都从 auth-hub 本地库删掉了。
- **生产部署进度**
1. ✅ 已在 Oracle 生产 auth-hub`/opt/auth-hub`:5300注册正式 client `FAM-Core`client_id/secret 已生成,明文只显示过一次,未写入仓库,需要的话找 auth-hub 管理后台或 `manage_clients list` 核对 client_id
2.**NAS `.env` 待补**(本次会话没有 NAS SSH 免密权限,未执行):`AUTH_HUB_ISSUER=http://129.146.26.249:5300``AUTH_HUB_CLIENT_ID``AUTH_HUB_CLIENT_SECRET``AUTH_HUB_REDIRECT_URI=http://129.146.26.249/api/auth/callback`
3. ⏳ 部署新代码到 NAStar 管道,见 `docs/DEPLOY.md` §3`bash start_core.sh` 重启——待执行
4. ⏳ 生产环境浏览器实测一遍完整登录闭环——待执行
## 2026-08-28 Oracle 迁移故障排查FFmpeg 缺失 + 磁盘写满死循环 + DiskGuard
- **触发**:用户反馈事件时间轴历史图片丢失、"谷歌同步是不是有问题"。逐层排查,发现的是三个叠在一起的独立问题,不是一个:
1. **8/25 迁移新机器时漏装 FFmpeg**`ffprobe: command not found`。导致 `videos.duration_sec` 全部读成 0、运动片段分割 `_segment_motion_clips` 全部失败(`ffmpeg` 也没装190 个素材全部"分割 0 段"`events` 表完全是空的,`motion_clips/` 目录空的——事件时间轴从 8/25 起就没有任何新内容,图片自然也生成不出来。**已修复**`apt-get install ffmpeg`,恢复正常。
2. **8/25 之前的历史图片无法找回**:迁移时只搬了 `data/oracle.db`(数据库/文字记录),没有搬 `motion_clips/`磁盘上的运动片段视频文件缩略图的生成源旧机器129.146.203.203)已经释放销毁。历史事件的文字描述还在(早就同步进了 NAS 镜像),但配图永久丢失,无法找回。
3. **Oracle↔Google Drive 同步"只下载不删除"**:排查一度走了弯路(先怀疑 NAS CloudSync 停摆,后发现新文件其实一直在下载,是判断证据不足导致的误判)。最终精确对比 Google Drive 远端141 文件,最早 8/25vs Oracle 本地264 文件,最早 8/15实锤148 个文件是"远端已删、本地未删"的孤儿文件。根因是磁盘被写满到 100%`no space left on device`),触发 rclone 内置安全机制——同步过程中遇到 IO 错误就整体拒绝执行删除,形成"越满删不掉,删不掉越满"的死循环。**已修复**:手动删除 148 个孤儿文件,释放 41G磁盘 100%→59%;重新跑 `rclone_sync.sh` 验证 `exit=0` 无错误,下载+删除恢复正常。
- **新增 DiskGuard 磁盘守护**commit `ef56ae5`,永久性预防措施):`fam-edge/oracle_db.py` 新增 `get_oldest_purgeable_material()`(只挑最旧的、已完成分割阶段的整段素材,绝不碰运动片段和处理中的素材);`disk_guard.py` 新增后台线程5 分钟检查一次,剩余空间 <10GB 触发清理,删到 15GB 水位为止;`/api/oracle/activity` 新增 `disk` 字段(实时剩余空间 + 最近清理动作。9 个新测试fam-edge 全量 139/139 通过。
- **用户决策**8/25-28 期间因 FFmpeg 缺失而"分割 0 段"的 190 个素材/736 个运动事件,明确选择不重新处理,翻页接受这段时间的空白。
- **顺带修复**commit `09708bd``/api/status``/api/ss/status` 500——上次"移除 SS Webhook"重构(`cf3d145`)删了 `MotionNotifier``camera_name_to_id` 等属性但 `status()` 忘记同步更新,导致这两个端点自那次重构起就一直是坏的。
- **文档同步**commit `3ec79de`README 里 5 处 + `vite.config.js` 1 处仍描述"`/api/ss/webhook` 保留为可选"的过时内容,实际已被彻底删除,逐处订正。
## 2026-08-24 事件时间轴支持删除视频会话
- **决策**:用户要求事件时间轴能删除某个视频会话;调研确认项目里没有任何 delete 类端点先例,且增量同步(`get_sync_delta`)只做 upsert 感知不到 Oracle 端的物理删除NAS 镜像必须显式清理,不能靠 `trigger_now()` 拉增量。用户明确要求连同磁盘上的视频文件一起删除,明确选择不做"防止误删事件被重新分割"的额外保护机制(风险极低:素材一旦标记 done 就不会被生产者重新捡起)
@@ -56,7 +84,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 +220,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 +406,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
@@ -478,3 +506,145 @@ AI 分析瓶颈: 帧5耗时 229s (疑似 ARM CPU 热降频), 其余帧 50-65s
- 修复:导航改为页面顶部 `st.segmented_control` 横向分段控制器,任何屏宽直接可见;侧边栏保留品牌标题和任务队列状态。
浏览器验证:顶部分段导航 5 页可达、成员命名页显示 张三/汤圆/爸、AI 对话页表单正常、无报错。
## 摄像头对外入口收口为单一域名 smart-camera.zichuan.xyz (2026-09-01)
**决策**摄像头系统fam-ui 前端 + NAS fam-core :8000 后端)对外只暴露一个域名 `smart-camera.zichuan.xyz`(前端 `/` + 后端 `/api`),下线独立的 `api.zichuan.xyz`,并从 `oracle.zichuan.xyz` 移除摄像头相关路由(其 `/ai``/fam``/wordpress` 仍保留给其它服务)。
**改动(基础设施,不在本仓库)**
- Oracle Caddy`/etc/caddy/Caddyfile`):新增 `smart-camera.zichuan.xyz` 块(`/` 静态 fam-ui + `/api``/login` 反代 `127.0.0.1:8000`,经 frp 隧道回源 NAS删除 `api.zichuan.xyz` 块;`oracle.zichuan.xyz``:80` 兜底块移除摄像头 root + `/api` 路由(改 404
- DNS新增 `smart-camera.zichuan.xyz → 129.146.26.249`RecordId 2387553886
- NAS `/volume1/web/sentinel-home-ai/.env``AUTH_HUB_ISSUER` 改为 `https://auth.zichuan.xyz``AUTH_HUB_REDIRECT_URI` 改为 `https://smart-camera.zichuan.xyz/api/auth/callback`,且 `AUTH_HUB_*` 四行补全 `export` 前缀(见下)。
- auth-hub FAM-Core 客户端 `a2PJTZuKQYxt1oGh` 回调改为 `https://smart-camera.zichuan.xyz/api/auth/callback`(保留旧 IP 作兜底)。
**顺带修复两个生产 Bug**(见 `scripts/start_core.sh`
1. 脚本先 `cd "$APP_DIR"` 再算相对 `SCRIPT_DIR`,导致 `source .env` 路径错位、`set -e` 下直接退出gunicorn 起不来。
2. `.env``AUTH_HUB_*` 是裸赋值(无 `export``source` 后不进环境gunicorn 子进程读不到 → 登录一直 503 fail-closed与之前“生产未部署”一致。脚本加 `set -a` 包裹 source 修复。
**验证**`smart-camera.zichuan.xyz/` 200、`/login` 302→auth.zichuan.xyzredirect_uri=smart-camera`/api/*` 401`oracle.zichuan.xyz/` 404、`/fam` 200`api.zichuan.xyz` 已不通。fam-core 经修复脚本重启后 health ok。
## SPA 登录打通:未登录自动跳转 OIDC2026-09-01
**问题**:域名收口后访问 `https://smart-camera.zichuan.xyz/timeline` 显示「⚠ 未登录 / 登陆不了啊」。后端 `/login`302→auth-hub与 Caddy `/login` 反代此前已验证可用,根因在**前端零登录逻辑**`fam-ui``/api/*` 拿到 401 后只在页面上渲染字面错误 `data.error`"未登录"),从不发起 OIDC 跳转,也没有任何登录入口。
**改动(本仓库 `fam-ui/`**
- `src/api.js` `request()`:捕获 401 时 `window.location.href = '/login'` 触发统一登录(后端经 auth-hub 走 Authorization Code + PKCE用模块级 `_redirectingToLogin` 开关保证单次会话只跳一次,并 `return new Promise(()=>{})` 阻止调用方继续渲染错误态;`/api/auth/check` 等白名单接口不会 401不受影响。
- `src/api.js` `api` 对象新增 `authCheck: () => request('/api/auth/check')`
- `src/App.vue``onMounted``api.authCheck()` 维护 `authed` 状态;左侧栏 + 移动端顶栏新增「🔑 登录」入口(`<a href="/login">`)与「👋 退出登录」按钮(`POST /api/logout` 后回 `/` 由后端 401 自动跳登录)。
**部署**`npm run build``dist/``tar | ssh ubuntu@129.146.26.249` 覆盖 `/var/www/fam-ui`macOS `._*` 元数据已清)。
**端到端验证**
- `smart-camera.zichuan.xyz/timeline` → 200SPA
- `/login` → 302 → `auth.zichuan.xyz/authorize?...&redirect_uri=https://smart-camera.zichuan.xyz/api/auth/callback`
- `/api/auth/check`(无 cookie`{"authed":false}`(白名单,不 401
- `/api/ui/videos`(无 cookie→ 401 → 前端据此自动跳 `/login`
登录链路已通:未登录访问任意页面 → 首个 401 → 自动跳 auth-hub 登录 → 回调种 `fam_session` cookie → 回 `/timeline` 正常加载。
## 统一登录从 NAS 迁到甲骨文 fam-edge2026-09-12
**触发**`smart-camera.zichuan.xyz/login` 报 502。排查链路静态页 `/``/timeline` 200Caddy 读本机磁盘),
只有 `/login``/api/*` 502甲骨文本机 `curl 127.0.0.1:8000/health` 0.6 秒空响应curl exit 52
Caddy 日志 `msg:"EOF"`frps 正常、同隧道的 gitea 也正常 → NAS 上 fam-core 进程没了。
局域网直扫 NAS22/2222/3000/5000/5001/3306 全 OPEN**只有 8000 closed**NAS 本身没事。
根因是登录入口挂在 NAS 上,而 NAS 上的 fam-core 没有任何守护DSM 无 systemd挂了不会自启。
**决策**:登录是入口,不该依赖家里的机器。整体迁到甲骨文的 fam-edge前端静态文件、auth-hub 本来就在这台),
NAS fam-core 退化成纯数据接口。
**改动**
- 新增 `fam-edge/src/fam_edge/auth.py``/login``/api/auth/callback``/api/logout`
`/api/auth/check``/api/auth/verify`(给 Caddy forward_auth。跟旧实现三处关键差异
1. 换 token / 拉 JWKS 走 `AUTH_HUB_INTERNAL_BASE`(本机 :5300不再跨公网 TLS——旧链路上
`PyJWKClient` 用 urllib + 系统 CA群晖易 CERTIFICATE_VERIFY_FAILED、两机时钟偏差会让 `iat`
显得来自未来,这两个坑一起消失;但 `iss` 校验和浏览器跳转仍用公网 issuer
2. 会话改无状态 HS256 签名 cookie服务重启不掉线旧实现进程内 token 表)
3. **回调失败渲染错误页,不再 302 回 `/login`**——旧实现失败即跳 `/login`,而 auth-hub 只要还有
会话就立刻再签一个 code 跳回来,两边对跳成死循环,浏览器只报「重定向次数过多」,
既看不到登录页也看不到原因(这正是 9/1 那次「跳不到登录页」的成因)
- 删除 `fam-core/src/fam_core/auth.py` + `tests/test_auth.py``app.py` 去掉 `init_auth`
fam-core 不再有任何鉴权,改由甲骨文 Caddy `forward_auth` 前置拦截
- `fam-edge/tests/test_auth.py` 16 个用例(含「失败分支绝不 302」的回归测试、
「服务端走内网但 iss 按公网校验」、「cookie 无状态」fam-edge 157 / fam-core 15 全绿
- 写测试时逮到自己写的一个 bug会话 cookie 也套了 60 秒 leeway本进程自签自验根本不需要
会让每个会话白白多活 60 秒,已改成只有 id_token 用 leeway
**遗留风险(已记入 README/DEPLOY**frps 在甲骨文绑的是 `*:8000` 且 iptables 明确放行,
fam-core 去掉鉴权后,绕过 Caddy 直连 `129.146.26.249:8000` 就是无门禁的全量数据接口,
必须靠 `iptables -I INPUT 1 -p tcp --dport 8000 ! -i lo -j DROP` 兜底。
另:登录进程并到 fam-edge 后fam-edge 正在跑视频分析时登录响应可能变慢(单 worker 4 线程)。
## 全量迁云NAS 只剩推送进程2026-09-13
**触发**:前一天刚把登录迁到甲骨文,隔天 NAS 上的 fam-core 又挂了导致数据接口 502。
用户一句话点破「NAS 上只有一个无状态的通知甲骨文的服务,剩下的全部在甲骨文呀」。
盘点后确认这个判断成立——甲骨文的 SQLite 才是权威数据源videos 3113 / events 16159 /
people 60 / model_calls 9876NAS 的 MariaDB 全是它的镜像,前端读的数据本来就产自
甲骨文,绕了一圈回家又绕回来。
**改动**
- `fam-core/db_layer.py` 从 725 行重写成 377 行MySQL 镜像查询 → 直读 fam-edge 的
SQLite。5 个 `upsert_sync_*`(约 300 行去重逻辑8/29 和 9/3 两次 1062 事故的发源地)
连同 `oracle_sync.py` 整个删除。SQL 方言:`JSON_CONTAINS``json_each`(前置
`json_valid`,历史脏数据不会把查询搞崩)、`LEFT()``substr()``%s``?`
函数名 `get_sync_*` 一并改掉——已经没有 sync 这回事了,留着名字会误导。
- 新增 `fam-core/edge_client.py`:写操作(改名/删除)、帧图头像、服务状态都打给同机
fam-edge全走 127.0.0.1,不出公网。
- 新增 `fam-notifier/`:把 `motion_notifier.py` 从 fam-core 拆出来,游标从 MariaDB
换成本地 JSON 文件。NAS 上从此没有 Flask、没有数据库、没有监听端口只有一个
单向推送进程。
- fam-core 移到甲骨文 `/opt/fam-core`systemdgunicorn -w 2**只绑 127.0.0.1:5401**——
5400 被 chat-relay 占了。Caddy 的 `/api/*` 从"frp 隧道回源 NAS"改成同机反代,
forward_auth 闸门不变。
- 前端跟着删:侧边栏同步面板、统计页同步状态、服务状态页的"NAS 同步"卡片和
"立即同步"按钮(背后的镜像层已不存在)。"NAS 同步"换成"NAS 运动推送",读
fam-edge activity 新增的 `motion` 段(心跳年龄 + 最近事件)。
- 顺带修掉一个隐蔽 bug镜像表为保外键稳定用的是 NAS 本地自增 id而帧图接口要的是
甲骨文的 id两边在 9/3 那次 id 重排后就对不上了。现在只有一套 id。
**测试**fam-core 21新增 12 个 db_layer 用例:脏 JSON 不崩、人物精确匹配不误伤
"人物B"、日期过滤、统计口径、chat_history 懒建表、fam-notifier 6、fam-edge 157全绿。
**验证**:甲骨文 `/api/ui/stats` 返回 videos 2965 / events 16159 / attention 13 /
people 59外网 `/` `/timeline` 200、`/login` 302、`/api/*` 未登录 401——**全程 NAS
上的 fam-core 是停着的**,这就是迁云的验收标准。
**待办**NAS 侧部署 fam-notifier只能用户手动密码登录chat_history 一次性迁移
`fam-core/scripts/import_chat_history.py`幂等frpc.toml 里的 8000 映射可删。
## 修复 fam-edge 多线程共用 SQLite 连接2026-09-13
**触发**:用户问「甲骨文剩余硬盘小于 10G 会删东西的逻辑怎么没了?」。查下来逻辑一直在、
也没被迁云动过(`disk_guard.py` 最后一次改动还是 8/28 新增它那次),但它 95% 的检查在空转:
```
DiskGuard「本轮清理完成」 128 次
DiskGuard「检查异常」 2837 次 ← 22 倍
```
失败原因全是 `cannot start a transaction within a transaction`。表现就是磁盘剩余空间在
2.7GB 和 16GB 之间来回荡——清理能不能成功全靠运气,赶上 rclone 集中下载
(实测 5 分钟写入 12.6GB)就掉进危险区。
**根因**`OracleDB.__init__` 建一条 `sqlite3.connect(check_same_thread=False)` 的连接
给全进程共用,而 VideoQueue / PersonService / DiskGuard 三个后台线程 + gunicorn 的 4 个
请求线程都在并发读写它。sqlite3 的连接对象本来就不是可并发共享的,事务状态互相踩踏。
同一个根因在线上刷出三类错误,累计:`database is locked` 71604 次、
`cannot start a transaction within a transaction` 378 次、`no more rows available` 92 次
(后者堆栈落在 `self._conn.commit()`,是游标被别的线程重置的典型症状)。
运动事件推送被 500 打回也是它——NAS 侧失败批次不推进游标,下一轮补推,所以没丢事件。
**修复**`_conn` 改成 `@property`,从 `threading.local()` 取当前线程的连接,没有就新建
WAL + busy_timeout=10000`close()` 相应改成收掉所有线程开过的连接。因为外部调用方
(如 `api_gateway` 的 activity 端点)也在直接用 `db._conn.execute(...)`,做成 property
可以让全部现有调用点原样工作,不用逐个改。`_write_lock` 保留,复合写语义不变。
**测试**:新增 2 个用例8 线程 × 25 轮并发读写、close 要收掉所有连接)。在旧代码上
稳定复现同族错误 `cannot commit transaction - SQL statements in progress`,修复后通过;
fam-edge 全套 159 个测试绿。
**同批修掉的第二个竞态**:生产者列目录之后、读 mtime 之前DiskGuard 可能刚好把那个
文件清掉(两个后台线程的正常竞态),`os.path.getmtime` 抛 FileNotFoundError代价是
**整轮扫描中断**——排在后面的新素材本轮全都登记不上。改成捕获 OSError 跳过该文件,
新增 `fam-edge/tests/test_video_queue.py` 两个用例覆盖(被删的跳过 / 仍在写入的照样跳过)。

117
README.md
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 | 摄像头录像 + 运动事件推送(**仅此而已**2026-09-13 起) | DS220+ (Geminilake), DSM 7 | 家庭局域网 192.168.50.64 | Surveillance Station :5000(录像机本体,搬不走), **fam-notifier**(无端口的推送进程,轮询 SS → 推甲骨文。MariaDB 与 FAM-Core 已随镜像层一起下线 |
| Oracle Cloud(云服务器) | **除录像外的全部**:视频分析 + 数据 + 接口 + 登录 + 前端 | Ampere A1 4C23G ARM64无 GPU, Ubuntu 20.04 | 公网 129.146.26.249 / Tailscale(已安装未启用,备用) | FAM-Edge :5000systemd视频分析 + 统一登录 + SQLite 权威库), **FAM-Core :5401systemdUI/对话/成员接口,仅监听 127.0.0.1**, AI-Gateway :5100systemd问答模型降级链, Caddy :80/:443前端 + 反代 + forward_auth 鉴权), Ollama :11434仅本地 |
| 家庭网络 | 用户入口 | 普通终端 | 公网 / 家庭网络 | 浏览器访问 `https://smart-camera.zichuan.xyz/`(全部由甲骨文提供,**不依赖 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。**2026-08-25 起 `/api/ss/webhook` 接收端点已彻底移除**`motion_bp.py` 不再注册该路由),轮询是唯一路径,不再保留 Webhook 可选补充。
- 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,24 +98,28 @@ 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 各自一份) │
└───────────────────────────────┬───────────────────────────────────────────────┘
│ HTTP GET /api/oracle/sync?since=&token= (每 30 分钟)
│ FAM-Core (Flask :5401仅 127.0.0.1) │
├─ UI-API: /api/ui/*(直读下面那个 SQLite无镜像、无后台线程
├─ Chat-Handler: /api/chat/ask查 events 拼上下文 → 转 fam-edge 编排)
│ ├─ Member-Manager: /api/member/name|merge转 fam-edge它负责合并人物
│ └─ chat_history: 本服务唯一写的表,落在同一个 SQLite 里 │
│ SQLite /opt/fam-edge/data/oracle.dbWALvideos / events / people / │
│ model_calls / person_identity_map / ss_motion_events / chat_history │
│ Caddy :80/:443前端静态 + /login 与 /api/auth/* → fam-edge │
│ + /api/* 先 forward_auth 再反代 fam-core唯一鉴权闸门
└───────────────────────────────▲───────────────────────────────────────────────┘
│ POST /api/ss/motion单向推送 + 5 分钟心跳)
┌─────────────────────────────── NAS (192.168.50.64) ────────────────────────────┐
FAM-Core (Flask :8000)
├─ Oracle-Sync: 唯一后台线程,拉增量写 MariaDB 镜像 + 维护 sync_cursor
├─ Chat-Handler: /api/chat/ask查 sync_events 拼上下文 → 走 Oracle 编排)
│ ├─ Member-Manager: /api/member/name|merge回推 Oracle + 即时拉回) │
│ ▼ │
│ MariaDB (sentinel_home_ai): sync_videos / sync_events / sync_people / │
│ sync_cursor / chat_history │
│ FAM-UI (Vue3 SPAFAM-Core 托管 dist) 读同步镜像 │
Surveillance Station :5000(录像机本体,摄像头插在这台上)
fam-notifier轮询 SS EventCenter → 推运动事件给甲骨文。无端口、无数据库,
游标存本地 JSON 文件。挂了重启即可,不影响网站。
└─────────────────────────────────────────────────────────────────────────────────┘
```
#### 2.2.1 运动监测链路v3NAS 轮询驱动)
摄像头动作事件由 **NAS 端 MotionNotifier 轮询 Surveillance Station 事件列表**获取(简单稳定,事件不遗漏;Webhook 端点在代码中保留为可选低延迟补充
摄像头动作事件由 **NAS 端 MotionNotifier 轮询 Surveillance Station 事件列表**获取(简单稳定,事件不遗漏;2026-08-25 起 Webhook 接收端点已彻底移除,轮询是唯一路径
Surveillance Station (NAS 本机)
│ SYNO.SurveillanceStation.EventCenter.Event method=List
@@ -176,7 +180,7 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
| Chat-Handler | `chat_handler/chat_handler.py` | `/api/chat/ask``sync_events` 拼上下文 → 经 Oracle `/api/edge/chat/ask` 问答FAM-Edge 转发到独立 AI-Gateway 服务,接口契约不变)→ 写 chat_history |
| Member-Manager | `member_manager/member_manager.py` | `/api/member/unnamed` / `/api/member/list` / `/api/member/name` / `/api/member/merge`;命名/合并回推 Oracle 并即时拉回本地镜像 |
| MotionNotifier | `motion_notifier/motion_notifier.py` | **轮询主路径**`poll_enabled=true`):每 `poll_interval_sec`(60s) 查 SS `EventCenter.Event.List`camera_ids/event_types=10→ 增量推送 Oracle `/api/ss/motion`;游标存 MariaDB重启续用/补推停机期间事件,失败批次不前进);心跳线程定期空 POST 证明链路存活;启动时拉取 SS 摄像头「名→id」映射并配置兜底 |
| Motion-BP | `motion_bp.py` | `POST /api/ss/webhook` 可选低延迟补充SS 行動規則 Webhook 未配置则不触发,兼容 JSON/表单/单条/数组)→ 映射 → 推送 Oracle`GET /api/ss/status` 查询状态 |
| Motion-BP | `motion_bp.py` | **2026-08-25 起不再提供 `/api/ss/webhook` 接收端点**(轮询是唯一路径)`GET /api/ss/status` 查询 MotionNotifier 状态 |
| UI-API | `ui_api.py` | `/api/ui/*` 只读接口videos/stats/people/people-clips/attention-events/named-members/model-stats/service-status供 Vue 前端渲染 |
| 公共层 | `db_layer.py` / `config_loader.py` / `logger.py` | PyMySQL 连接unix_socket同步镜像 CRUD`get_sync_people_clips()` 人物→运动片段查询;文件日志 |
@@ -192,13 +196,14 @@ Orchestrator 视觉阶段按 `fallback` 模式顺序降级Gemini → NVIDIA N
| Video-Queue | `video_queue.py` | 生产-消费队列:生产者 30s 轮询 rclone 同步落地目录登记整段素材入队(含重启恢复);消费者(`max_concurrent` 个线程)取队列调 Video-Processor素材分割出的运动片段自动入队模型超时 = 原配置 ×`timeout_multiplier`;失败重试上限 `max_retries` |
| Video-Processor | `video_processor.py` | **素材→分割/片段→分析**双分支:素材按 `ss_motion_events` 已结束运动事件 ffmpeg 分割运动片段(`-c:v copy -c:a aac` 保留音频,`motion_event_id` 幂等),片段只送云端 VLM 分析;按 `vision_order` 调适配器;首个成功即落库 Oracle `videos`+`events`+`people`;全失败标 `failed` |
| Person-Service | `person_service.py` | 汇总全量人物 → LLM 合并为规范名 → `set_canonical`;生成 `known_members_context` 回灌片段提示manual 命名优先不被覆盖 |
| Disk-Guard | `disk_guard.py` | **2026-08-28 新增**5 分钟检查一次磁盘剩余空间,低于 `min_free_gb`(默认 10GB就清理最旧的、已完成分割阶段的整段素材删到 `target_free_gb`(默认 15GB水位为止只碰原始素材`gdrive_videos`),绝不碰运动片段(`motion_clips`,事件时间轴/人物头像依赖它)或还在处理中的素材 |
| OracleDB | `oracle_db.py` | SQLitevideos`motion_event_id`/`camera_id` 列)/ events / people / sync_cursor / ss_motion_events`get_sync_delta(since)` 增量导出;`record_motion_events` / `get_motion_events_in_range`(已结束运动事件窗口查询,分割用)/ `has_motion_in_range_local` / 心跳 |
| Model-Adapters | `model_adapters/` | `BaseModelAdapter.analyze_video(video_path, known_members_context, event_start_time)`GeminiFiles API/ NVIDIA整视频 `video_url`)——**只有视觉分析用2026-08-23 起不再含 Ollama/文字模型**,问答模型完全移交 AI-Gateway |
| QA-Proxy | `qa.py` | **2026-08-23 重写为 HTTP 转发客户端**(原来自己遍历适配器 `chat()` 做 NVIDIA→Gemini→Ollama 三级降级的逻辑已整个搬到 AI-Gateway调 AI-Gateway `/v1/chat/completions`,把 OpenAI 兼容响应翻译回原有 `run_qa`/`run_qa_stream` 契约,`api_gateway.py` 和 FAM-Core 调用方零改动 |
### 3.3 FAM-UINAS 端
### 3.3 FAM-UI云服务器端Caddy 托管
Vue3 + Vite + Tailwind SPA`fam-ui/src/views/*.vue`,构建产物 `fam-ui/dist/` 由 FAM-Core `static_app.py` 托管Vue Router history 模式),页面均读本地同步镜像:
Vue3 + Vite + Tailwind SPA`fam-ui/src/views/*.vue`,构建产物 `fam-ui/dist/` 部署到甲骨文云服务器由 Caddy 静态托管Vue Router history 模式;`/api/*` 经 frp 隧道反代回 NAS FAM-Core),页面均读本地同步镜像:
| 页面 | 功能 |
|------|------|
@@ -313,14 +318,23 @@ chat_history 独立表(问答上下文摘要留存)
**GET /api/oracle/avatar** — 人物头像:`?label=&w=` → jpeg从该人物候选事件 `person_appearances` bbox 裁剪)
**GET /health** — 服务状态(含已处理视频数)
### 5.2 FAM-CoreNAS :8000
**登录相关端点**2026-09-12 从 NAS FAM-Core 迁入,见 `fam-edge/src/fam_edge/auth.py`
| 端点 | 方法 | 说明 |
|------|------|------|
| `/login` | GET | 登录入口:生成 PKCE 参数后 302 跳 auth-hub 公网 `/authorize` |
| `/api/auth/callback` | GET | auth-hub 回跳:用 `AUTH_HUB_INTERNAL_BASE`(本机 :5300换 token + 拉 JWKS 验签,成功后种 HttpOnly cookie `fam_session`HS256 无状态签名2 小时);**任何一步失败都渲染错误页,绝不 302 回 `/login`**——旧实现失败即跳 `/login`,而 auth-hub 有会话时会立刻再签发 code两边对跳成死循环 |
| `/api/logout` | POST | 退出登录(清 cookie不影响 auth-hub 上的 SSO 会话) |
| `/api/auth/check` | GET | 登录态检查:`{"authed": true\|false, "username": "..."}` |
| `/api/auth/verify` | ANY | 给甲骨文 Caddy 的 `forward_auth` 用:已登录 204未登录 401 JSON |
### 5.2 FAM-Core甲骨文 127.0.0.1:5401
> 2026-09-13 起 FAM-Core 与 FAM-Edge 同机,直读它的 SQLite不再有 MariaDB 镜像层),且**只监听 127.0.0.1**——唯一客户端是同机 Caddy。登录端点在 FAM-Edge见 §5.1),鉴权由 Caddy 的 `forward_auth` 前置完成,所以本服务自身不做任何校验。
| 端点 | 方法 | 说明 |
|------|------|------|
| `/health` | GET | 服务健康(**免登录** |
| `/api/login` | POST | 登录:`{"username","password"}` → 校验 `FAM_AUTH_USER`/`FAM_AUTH_PASS`NAS `.env` 配置,无硬编码默认值,未配置则拒绝所有登录)→ 种 HttpOnly cookie `fam_session`2 小时) |
| `/api/logout` | POST | 退出登录(清 cookie |
| `/api/auth/check` | GET | 登录态检查:`{"authed": true\|false}`**免登录** |
| `/api/status` | GET | Oracle-Sync + MotionNotifier 状态sync running/cursormotion poll_enabled/pushed_total/heartbeat |
| `/api/chat/ask` | POST | 用户问答:`{"question","queried_person","queried_date"}``{"answer","context_summary","chat_id"}`(上下文来自 sync_events |
| `/api/chat/history` | GET | 对话历史(`?date=``?person=&limit=` |
@@ -328,7 +342,6 @@ chat_history 独立表(问答上下文摘要留存)
| `/api/member/list` | GET | 全部人物label + canonical_name + 是否命名) |
| `/api/member/name` | POST | 命名:`{"label","canonical_name"}` → 回推 Oracle 并即时拉回本地镜像 |
| `/api/member/merge` | POST | 合并:`{"source","target"}` → 将 source 并入 target 身份(统一 canonical_name |
| `/api/ss/webhook` | POST | **可选 + 免登录**:接收 Surveillance Station Webhook参数 `event_time`/`device_name`/`event_name`/`server_name`/`thumbnail_url`),映射后推送 Oracle `/api/ss/motion`SS 行動規則未配置则不触发SS 推送无法携带登录态故列入白名单) |
| `/api/ss/status` | GET | 运动监测状态:`poll_enabled` / `camera_loaded` / `camera_map` / `pushed_total` / `last_error` / 心跳 |
| `/api/ui/videos` | GET | 运动片段会话列表(`?date=&page=`**只返回 `motion_` 前缀或有事件的会话**0 段素材不展示) |
| `/api/ui/videos/<id>` | GET | 会话详情:`{video, events[]}`(事件含 person_list_json / person_appearances_json / is_attention_event |
@@ -538,24 +551,34 @@ task_id=28930s 测试片段)全链路打通:推送 5.7MB → Edge 分析
| 组件 | 节点 | 路径 | 启动 |
|------|------|------|------|
| FAM-Core | NAS | `/volume1/web/sentinel-home-ai/fam-core/` | `bash start_core.sh`gunicorn -w 1 :8000source .env 注入 DSM_*/ORACLE_SYNC_TOKEN/FAM_AUTH_* |
| FAM-UI | NAS | `/volume1/web/sentinel-home-ai/fam-ui/dist/` | Vue3 构建产物,由 FAM-Core `static_app.py` 托管(无需独立进程);本地改代码后 `npm run build` 并 tar 部署 dist |
| FAM-Core | Oracle | `/opt/fam-core/` | **systemd `fam-core.service`**gunicorn -w 2 --threads 4绑 127.0.0.1:5401部署代码后 `sudo systemctl restart fam-core` |
| fam-notifier | NAS | `/volume1/web/sentinel-home-ai/fam-notifier/` | `setsid bash scripts/start_notifier.sh &`DSM 无 systemd挂了不自启NAS 上唯一运行的本项目进程 |
| FAM-UI | 云服务器甲骨文129.146.26.249 | `/var/www/fam-ui/` | Vue3 构建产物,由 Caddy :80 静态托管(无需独立进程);本地改代码后 `npm run build``dist/` rsync/tar 到云服务器 |
| FAM-Edge | Oracle | `/opt/fam-edge/` | **systemd `fam-edge.service` 守护**Restart=always部署代码后 `sudo systemctl restart fam-edge`(勿手动 setsid会端口冲突 |
| **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
> - `3000` → NAS Gitea、`8500` → NAS WordPress(8088)、**`8000` → NAS FAM-Core本系统**
> - 外网入口 `http://129.146.203.203:8000`**需登录**(见 §5.2 `/api/login`
> **外网访问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 这条已于 2026-09-13 迁云后废弃**(本系统不再有任何"回源 NAS"的流量,可从 frpc.toml 删除
> - 前端入口 `https://smart-camera.zichuan.xyz/`:静态页、接口、登录全部由甲骨文提供,**NAS 离线也能正常访问**(只是不再有新的运动事件推进来),**需登录**(见 §5.1 `/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-31 接入 auth-hub 统一登录2026-09-12 整体迁到甲骨文 FAM-Edge**:登录流程和会话校验现在都在甲骨文 FAM-Edge 的 `auth.py`§5.1NAS FAM-Core 自身不再做任何鉴权。迁移的直接原因NAS 或 frp 隧道一挂,`smart-camera.zichuan.xyz/login` 直接 502**连登录页都打不开**——登录是入口,不该依赖家里那台机器
>
> - **链路**`/login`Caddy → 本机 fam-edge :5000→ 302 到 auth-hub 公网 `/authorize`Authorization Code + PKCE→ 用户在 [auth-hub](https://auth.zichuan.xyz) 登录 → 回跳 `/api/auth/callback`(仍是 fam-edge→ 走**本机** `http://127.0.0.1:5300` 换 token、拉 JWKS 验 `id_token` 签名 → 种 cookie `fam_session`
> - **为什么服务端调用走本机**:旧实现是 NAS 跨公网访问 `https://auth.zichuan.xyz`,那条链路上 `jwt.PyJWKClient` 用 urllib + 系统 CA不像 requests 自带 certifi群晖上容易 `CERTIFICATE_VERIFY_FAILED`;两台机器的时钟偏差还会让 `iat` 看起来来自未来。同机直连把这两个坑一起消掉。但 `iss` 校验和浏览器跳转仍用公网 `AUTH_HUB_ISSUER`
> - **拦截**:甲骨文 Caddy 对 `/api/*` 做 `forward_auth` → fam-edge `/api/auth/verify`200/204 才反代回 NAS`/login`、`/api/auth/*`、`/api/logout` 直接由 fam-edge 处理Caddy 按路径具体程度排序,这几条稳定排在 `/api/*` 前面)
> - **NAS :8000 必须靠防火墙兜底**frps 把它暴露在公网(历史入口 `http://129.146.26.249:8000`),绕过 Caddy 直连就没有任何鉴权。甲骨文 iptables 只允许本机访问 :8000见 `docs/DEPLOY.md` §2.3
> - **参数**`/opt/fam-edge/.env`**无硬编码默认值**,缺任一项拒绝所有登录 fail closed`AUTH_HUB_ISSUER`(公网,浏览器跳转 + `iss` 校验)/ `AUTH_HUB_INTERNAL_BASE`(本机,换 token + JWKS/ `AUTH_HUB_CLIENT_ID` / `AUTH_HUB_CLIENT_SECRET` / `AUTH_HUB_REDIRECT_URI`(必须跟 auth-hub 登记的逐字符一致,只做精确匹配)/ `FAM_SESSION_SECRET`会话签名密钥≥32 字节)
> - **会话是无状态签名 cookie**HS2562 小时fam-edge 重启不掉线(旧实现是进程内 token 表fam-core 一重启全员下线);要强制全员下线就换掉 `FAM_SESSION_SECRET` 再重启
> - auth-hub 侧任何审批通过的账号登录后都能访问本系统,**未再加用户名白名单**——自用场景的有意取舍(见项目记忆)
> Oracle 部署方式:本地 git 提交 push Gitea → tar 管道到 `/opt/fam-edge``--strip-components=1` 解临时目录再 cp避免动 data/venv/gdrive_videos。**AI-Gateway 是独立 git 仓库**http://192.168.50.64:3000/ericwyuan/ai-gateway见 §10.4),同样 tar 管道部署到 `/opt/ai-gateway`,互不影响。
### 8.2 依赖
- **FAM-CoreNAS, Python 3.10 venv**Flask, Gunicorn, **PyMySQL**45KB 纯 Python 替代 19MB mysql-connector, PyYAML, requests
- **FAM-UINAS, Node**Vue3 + Vite + Tailwind`fam-ui/`,构建产物 dist 不入库);**不再依赖 Streamlit**
- **FAM-UI云服务器, Node**Vue3 + Vite + Tailwind`fam-ui/`,构建产物 dist 不入库);**不再依赖 Streamlit**
- **FAM-EdgeOracle, Python venv**Flask, Gunicorn, requests, PyYAML, opencv-python, numpy, **openai**NVIDIA NIM 兼容 OpenAI API 规范仅视觉分析用Gemini 用 requests 直调 REST**问答不再直接调模型,只用 requests 转发到 AI-Gateway**
- **AI-GatewayOracle, Python 3.8 venv独立部署单元**Flask, Gunicorn, requests, PyYAML, **openai**NVIDIA 问答模型用)
- **系统级**FFmpeg两端Oracle 端用于运动片段分割 + 帧图/头像、Ollama + qwen2.5:7bOracle**被 AI-Gateway 调用**、MariaDB 10.11NAS、rcloneOracle同步 Google Drive 素材)
@@ -570,11 +593,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 +608,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
@@ -701,6 +724,7 @@ print('NVIDIA NIM 连接成功:', response.choices[0].message.content)
- NAS scp 子系统被禁用,同样用 stdin 管道传文件
- **Oracle fam-edge 由 systemd `fam-edge.service` 守护Restart=always**:部署代码后必须 `sudo systemctl restart fam-edge`;手动 `setsid` 启动会和守护打架(端口 `Connection in use`
- NAS 远端 kill gunicorn 用 `ps aux | grep "[f]am-core/venv/bin/gunicorn"` 字符类技巧pkill/pgrep 会匹配 SSH 自身命令行导致断连)
- **sqlite3 连接不能跨线程共享**2026-09-13 修):`OracleDB` 原来在 `__init__` 里建一条 `check_same_thread=False` 的连接给全进程用VideoQueue / PersonService / DiskGuard 三个后台线程加 gunicorn 请求线程并发读写它,事务状态互相踩踏,线上累计刷出 `database is locked` 71604 次、`cannot start a transaction within a transaction` 378 次、`no more rows available` 92 次DiskGuard 的清理被打断 2837 次,磁盘守护形同虚设(剩余空间在 2.7G 和 16G 之间来回荡)。改成 `threading.local()` 每线程一条连接后WAL 下读不互斥、写由 SQLite 自己排队。**新增后台线程时不要再去共用某一条连接对象**
- fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app``start_core.sh` 会 source 仓库根 `.env` 注入 `DSM_*/ORACLE_SYNC_TOKEN`
- Edge 单 worker 处理任务期间 `/health` 可能不响应,属正常
- **运动数据清理**:切换架构/重新提取时清 Oracle `videos/events/people` + `motion_clips/`(保留 `ss_motion_events` 与素材)与 NAS `sync_*` 镜像,重启两端自动重新分割分析
@@ -720,7 +744,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/
# 或使用脚本
@@ -731,7 +755,7 @@ cd fam-ui && npm run build # 产物 fam-ui/dist/
## 10. 服务器访问信息
### 10.1 Synology NASFAM-Core + FAM-UI + MariaDB
### 10.1 Synology NASFAM-Core + MariaDB
| 项目 | 值 |
|------|-----|
@@ -756,11 +780,11 @@ cd fam-ui && npm run build # 产物 fam-ui/dist/
| 项目 | 值 |
|------|-----|
| 公网 IP | 129.146.203.203 |
| SSH 用户 | ubuntu密钥 `~/.ssh/oracle_sentinel` |
| 系统 | aarch64 (Ampere A1 2C12G), Ubuntu 20.04 LTS |
| Tailscale | 100.74.137.126(已安装在线,与 NAS 端口不通 |
| 登录命令 | `ssh -i ~/.ssh/oracle_sentinel ubuntu@129.146.203.203` |
| 公网 IP | 129.146.26.249 |
| SSH 用户 | ubuntu密钥 `~/.ssh/oracle_new` |
| 系统 | aarch64 (Ampere A1 4C23G), Ubuntu 20.04 LTS |
| Tailscale | 已安装未启用(备用;新机公网直连为主 |
| 登录命令 | `ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249` |
### 10.4 Gitea 代码仓库
@@ -829,14 +853,15 @@ export NVIDIA_API_KEY="nvapi-9cFAdO5xdbwPuxS8KGRTnlVimn1gJzbbbzWNhPwHa_Yl3pTe-Pf
## 12. 当前进度与 v1.1 计划
### 已完成(截至 2026-08-24
### 已完成(截至 2026-08-28
- **Oracle 迁移故障排查 + DiskGuard 磁盘守护**2026-08-288/25 迁移新机器时漏装了 FFmpeg导致运动片段分割和帧图抽取全部失效`ffprobe: command not found`);同时 `gdrive_videos` 持续下载新素材没有配套清理,磁盘被写满到 100%,触发 rclone 的安全机制IO 错误时拒绝执行删除),形成"越满越删不掉"的死循环,表现为"只下载不删除"。已装回 FFmpeg、手动清理了 148 个远端已不存在的孤儿文件(释放 41G恢复同步新增 `DiskGuard` 后台服务作为永久兜底,剩余空间 <10GB 自动清理最旧的已完成素材。8/25 之前旧机器时代的历史事件帧图因源文件未随迁移保留、旧机器已销毁无法找回8/25-28 期间的 190 个素材/736 个运动事件按用户决定不重新处理
- **事件时间轴支持删除视频会话**2026-08-24三端联动沿用"NAS 转发写请求到 Oracle"的既有模式(新增 `POST /api/oracle/video/delete` + `DELETE /api/ui/videos/<id>`);删 events+videos 行 + 磁盘文件,不清理 `ss_motion_events` 源事件;`db_layer.delete_sync_video()` 是项目里第一个"NAS 直接写自己镜像表"的函数(增量同步机制感知不到删除,不能靠 `trigger_now()` 拉增量清理)
- **问答链路抽离为独立 ai-gateway 服务**2026-08-23FAM-Edge 原本自己维护的问答模型降级链NVIDIA→Gemini→Ollama含 key 轮换/熔断)整个搬到独立仓库/独立部署单元 `ai-gateway`OpenAI 兼容协议 `/v1/chat/completions`Bearer token 鉴权,对外 `:5100`FAM-Edge `qa.py` 重写为转发客户端,`/api/edge/chat/ask(/stream)` 对 FAM-Core 的契约不变;`OllamaAdapter` 从 FAM-Edge 删除
- **运动事件驱动**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

@@ -1,44 +1,58 @@
# 部署指南(v3 运动事件驱动架构2026-08-22 同步)
# 部署指南(2026-09-13 全量迁云后同步)
## 1. NAS 端部署 (FAM-Core + FAM-UI + MariaDB)
> **一句话架构**除了录像本身全部跑在甲骨文。NAS 只剩 Surveillance Station
> 和一个往甲骨文推运动事件的进程fam-notifier——NAS 离线不影响网站访问。
### 1.1 MariaDB
## 1. NAS 端部署(只有 fam-notifier
2026-09-13 迁云后 NAS 上**不再有** MariaDB、FAM-Core、FAM-UI镜像层整个删除
(数据权威源本来就在甲骨文),接口和前端也都搬走了。旧部署的清理见 §1.2。
### 1.1 fam-notifier轮询 Surveillance Station → 推甲骨文)
```bash
# 通过 Synology 套件中心安装 MariaDB10.11unix_socket=/run/mysqld/mysqld10.sock
# 执行 DDL
python3 scripts/init_db.py --host 127.0.0.1 --port 3306 --user root --password <密码>
```
### 1.2 FAM-Core
```bash
cd /volume1/web/sentinel-home-ai/fam-core
cd /volume1/web/sentinel-home-ai/fam-notifier
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip install -r requirements.txt # 只有 requests + PyYAML
# 配置
cp config/config.yaml.example config/config.yaml
# 编辑 config.yaml数据库密码、oracle_sync.base_url/token、motion_notifierDSM 凭据走 .env
# 编辑 config.yamlcamera_ids、oracle_base_urlDSM 凭据与 token 走仓库根 .env
# 仓库根 .env 需提供 DSM_ACCOUNT / DSM_PASSWORD / ORACLE_SYNC_TOKEN
# 仓库根 .env 提供 DSM_ACCOUNT/DSM_PASSWORD/ORACLE_SYNC_TOKENstart_core.sh 会 source
# 启动DSM 没有 systemd用 setsid 脱离 SSH 会话;挂了不会自启,需要人工重启
cd /volume1/web/sentinel-home-ai/fam-notifier
setsid bash scripts/start_notifier.sh >> logs/start.log 2>&1 &
# 启动(含 Oracle-Sync + MotionNotifier 轮询
cd /volume1/web/sentinel-home-ai && bash start_core.sh
# 确认在跑(进程没有监听端口,只能看进程和日志
ps aux | grep "[f]am_notifier"
tail -f logs/fam-notifier.log # 应每 60s 轮询、每 5min 心跳
```
### 1.3 FAM-UIVue3 SPA无独立进程
游标存在 `fam-notifier/data/cursor.json`(迁云前存在 MariaDB。删掉也不致命
窗口回看会把最近的事件补推一遍,甲骨文按 event_id 幂等落库。
### 1.2 清理旧部署(迁云一次性动作)
```bash
# 1) 停掉 NAS 上的 fam-core它已经不该再跑了跑着也没用前端不再连它
ps aux | grep "[f]am-core/venv/bin/gunicorn" # 字符类写法pkill 会杀掉 SSH 自己
kill <上面的 pid>
# 2) frpc 里的 8000 映射可以删了(/etc/frp/frpc.toml 的 fam-core 段),
# 甲骨文侧对应的 iptables DROP 规则也就没有存在意义了
# 3) MariaDB 的 sentinel_home_ai 库确认 chat_history 已迁移§2.4)后再考虑删
```
### 1.3 FAM-UIVue3 SPA构建后托管在甲骨文 Caddy
```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 回退)
tar czf - -C fam-ui dist | ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 \
'sudo tar xzf - --strip-components=1 -C /var/www/fam-ui && sudo chown -R ubuntu:ubuntu /var/www/fam-ui'
# 浏览器访问 https://smart-camera.zichuan.xyz/
```
## 2. Oracle 端部署 (FAM-Edge + Ollama + FFmpeg)
## 2. Oracle 端部署 (FAM-Edge + FAM-Core + Ollama + FFmpeg)
### 2.1 系统依赖
```bash
@@ -64,6 +78,89 @@ sudo systemctl enable fam-edge
sudo systemctl restart fam-edge # 部署代码后必须用 systemctl 重启,勿手动 setsid
```
### 2.3 统一登录2026-09-12 从 NAS 迁入fam-edge + Caddy + 防火墙三件套)
登录入口不再依赖 NAS。三处缺一不可
```bash
# (1) /opt/fam-edge/.env 追加六项(缺任一项 fam-edge 拒绝所有登录fail closed
# client_secret 用 rotate-secret 现拿,明文只显示一次:
# cd /opt/auth-hub && venv/bin/python -m auth_hub.manage_clients rotate-secret <client_id>
# 会话密钥自己生成openssl rand -hex 32
export AUTH_HUB_ISSUER=https://auth.zichuan.xyz # 公网:浏览器跳转 + id_token 的 iss 校验
export AUTH_HUB_INTERNAL_BASE=http://127.0.0.1:5300 # 本机:换 token + 拉 JWKS不走公网 TLS
export AUTH_HUB_CLIENT_ID=<auth-hub 注册的 client_id>
export AUTH_HUB_CLIENT_SECRET=<rotate-secret 输出>
export AUTH_HUB_REDIRECT_URI=https://smart-camera.zichuan.xyz/api/auth/callback # 与 auth-hub 登记的逐字符一致
export FAM_SESSION_SECRET=<openssl rand -hex 32> # 会话 cookie 签名密钥,换掉即全员下线
# (2) venv 补依赖Python 3.8pip 会自动选到兼容版本)后重启
/opt/fam-edge/venv/bin/pip install "PyJWT>=2.8.0" "cryptography>=42.0.0"
sudo systemctl restart fam-edge
# (3) Caddy登录端点走本机 fam-edge其余 /api/* 先 forward_auth 再回源 NAS
# 改完先 caddy validate 再 reload见下方 Caddyfile 片段
sudo caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
```
```caddyfile
smart-camera.zichuan.xyz {
handle /login { reverse_proxy 127.0.0.1:5000 }
handle /api/auth/* { reverse_proxy 127.0.0.1:5000 }
handle /api/logout { reverse_proxy 127.0.0.1:5000 }
handle /api/* {
forward_auth 127.0.0.1:5000 { uri /api/auth/verify } # 删了等于数据接口全裸
reverse_proxy 127.0.0.1:8000
}
handle { root * /var/www/fam-ui; encode gzip; try_files {path} /index.html; file_server }
}
```
```bash
# (4) 防火墙frps 把 NAS fam-core 的 :8000 暴露在公网,而 fam-core 自身已无鉴权,
# 必须只放行本机Caddy访问否则绕过 Caddy 就能读到全部数据
sudo iptables -I INPUT 1 -p tcp --dport 8000 ! -i lo -j DROP
sudo netfilter-persistent save # 否则重启后规则丢失
```
### 2.4 FAM-CoreUI/对话/成员接口2026-09-13 从 NAS 迁入)
```bash
# 代码(本地开发机 → 甲骨文)
tar czf - --exclude=venv --exclude=__pycache__ --exclude=logs --exclude='config/config.yaml' fam-core | \
ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 'tar xzf - --strip-components=1 -C /opt/fam-core'
# 甲骨文上venv + 配置
python3 -m venv /opt/fam-core/venv
/opt/fam-core/venv/bin/pip install -r /opt/fam-core/requirements.txt
cp /opt/fam-core/config/config.yaml.example /opt/fam-core/config/config.yaml
# .env 只需要 ORACLE_SYNC_TOKEN与 fam-edge 的一致config_loader 会自动加载
grep "^export ORACLE_SYNC_TOKEN=" /opt/fam-edge/.env > /opt/fam-core/.env && chmod 600 /opt/fam-core/.env
sudo systemctl restart fam-core # 单元见 /etc/systemd/system/fam-core.service
curl -s http://127.0.0.1:5401/api/status # {"db":{"ok":true},...}
```
**端口 5401 不是笔误**5400 被这台机器上的 chat-relay 占了。fam-core 只绑
127.0.0.1,外部进不来,唯一客户端是同机 Caddy先 forward_auth 再反代)。
**chat_history 迁移(一次性)**:这是 NAS MariaDB 里唯一不是镜像的表。
在 NAS 上导出,再从本地导入甲骨文的 SQLite
```bash
# NAS 上导出(用 fam-core 旧 venv 里的 PyMySQL
ssh -p 2222 ericwyuan@192.168.50.64 '/volume1/web/sentinel-home-ai/fam-core/venv/bin/python -c "
import pymysql, json, yaml
cfg = yaml.safe_load(open(\"/volume1/web/sentinel-home-ai/fam-core/config/config.yaml\"))[\"database\"]
c = pymysql.connect(host=\"127.0.0.1\", user=cfg[\"user\"], password=cfg[\"password\"], database=cfg[\"database\"], cursorclass=pymysql.cursors.DictCursor)
cur = c.cursor(); cur.execute(\"SELECT * FROM chat_history ORDER BY chat_id\")
print(json.dumps(cur.fetchall(), ensure_ascii=False, default=str))
"' > /tmp/chat_history.json
# 本地 → 甲骨文导入(幂等:按 chat_id 跳过已存在的)
cat /tmp/chat_history.json | ssh -i ~/.ssh/oracle_new ubuntu@129.146.26.249 \
'/opt/fam-core/venv/bin/python /opt/fam-core/scripts/import_chat_history.py'
```
## 3. 代码同步tar 管道scp 在 NAS 被禁用)
```bash
@@ -73,7 +170,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'
```
@@ -81,10 +178,12 @@ tar czf - --exclude=venv --exclude=__pycache__ --exclude=data --exclude=gdrive_v
| 项目 | 命令 | 预期 |
|------|------|------|
| NAS MariaDB | `mysql -u root -p -e "SHOW DATABASES"` | 包含 sentinel_home_ai |
| NAS FAM-Core | `curl http://localhost:8000/health` | `{"status":"ok"}` |
| NAS 运动监测 | `curl http://localhost:8000/api/ss/status` | `poll_enabled: true, running: true` |
| NAS FAM-UI | 浏览器访问 `http://192.168.50.64:8000` | Vue3 SPA时间轴/人物管理/统计) |
| 云服务器 FAM-UI | 浏览器访问 `https://smart-camera.zichuan.xyz/` | 未登录时 `/api/*` 返回 401前端自动跳 `/login` → auth-hub 登录页;登录后跳回展示 Vue3 SPA |
| 登录不依赖 NAS | `curl -sI https://smart-camera.zichuan.xyz/login`(此时哪怕 fam-core 是停的) | 302 到 `auth.zichuan.xyz/authorize?...`**不再是 502** |
| 鉴权闸门 | `curl -s -o /dev/null -w '%{http_code}' https://smart-camera.zichuan.xyz/api/ui/videos` | 401Caddy forward_auth 拦下,没到 NAS |
| NAS fam-notifier | NAS 上 `ps aux \| grep "[f]am_notifier"` | 有进程;`fam-notifier/logs/fam-notifier.log` 每 60s 一条轮询、每 5min 一条心跳 |
| 甲骨文 FAM-Core | `curl -s http://127.0.0.1:5401/api/status` | `{"db":{"ok":true,...}}``/api/ui/stats` 返回真实计数 |
| NAS 离线也能用 | 关掉 NAS 后访问 `https://smart-camera.zichuan.xyz/timeline` | 页面与数据照常(只是不再有新运动事件);**这是本次迁云的验收标准** |
| Oracle FAM-Edge | `curl http://localhost:5000/health` | `{"status":"ok","queue_alive":true}` |
| Oracle 运动事件 | `sqlite3 /opt/fam-edge/data/oracle.db "SELECT COUNT(*) FROM ss_motion_events"` | >0NAS 推送) |
| Oracle 运动片段 | `ls /opt/fam-edge/motion_clips/` | 存在 motion_*.mp4素材分割产物 |

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

@@ -1,32 +1,28 @@
# FAM-Core 配置文件 (NAS 端) - 新架构 v2
# FAM-Core 配置文件2026-09-13 起跑在甲骨文,与 fam-edge 同机)
# 复制此文件为 config.yaml 并修改实际值
#
# NAS 仅作管理后台,不再处理视频。唯一后台线程 Oracle-Sync 每 30 分钟
# 从甲骨文 FAM-Edge 拉取增量镜像到本地 MariaDBsync_videos/events/people
# 所有视频分析在 Oracle 完成。
# 本服务是纯查询/转发层:直读 fam-edge 的 SQLite写操作转给 fam-edge。
# 没有后台线程,没有自己的数据库,重启不影响任何数据
server:
host: "0.0.0.0"
port: 8000
# 只监听回环:唯一的客户端是同机 Caddy它做 forward_auth 鉴权后才反代进来)。
# 迁云前这里是 0.0.0.0:8000 并经 frp 暴露到公网,绕过 Caddy 就能拿到全部数据。
host: "127.0.0.1"
port: 5401
database:
host: "127.0.0.1"
port: 3306
user: "root"
password: ""
database: "sentinel_home_ai"
unix_socket: "/run/mysqld/mysqld10.sock"
# fam-edge 的库,本服务只读它写的表(唯一写的是 chat_history
# fam-edge 侧已开 WAL多进程读写安全。
path: "/opt/fam-edge/data/oracle.db"
# 甲骨文同步(每 30 分钟拉增量镜像)
oracle_sync:
# FAM-Edge 对外同步接口地址(端口同其 server.port=5000
base_url: "http://<oracle-public-ip>:5000"
# 与 Oracle 端 sync_api.token 一致
token: ""
interval_sec: 1800 # 拉取间隔(秒),默认 30 分钟
timeout: 120 # 单次拉取超时(秒)
# 同机 fam-edge写操作改名/删除)、帧图头像、服务状态都打给它
edge:
base_url: "http://127.0.0.1:5000"
token: "${ORACLE_SYNC_TOKEN}" # 与 fam-edge 的 sync_api.token 一致
timeout: 60
chat_handler:
# 智能问答统一走 FAM-Edge 编排端点Gemini → NVIDIA → 本地 Ollama 兜底)
qa_url: "http://<oracle-public-ip>:5000/api/edge/chat/ask"
# 智能问答走 fam-edge 编排端点NVIDIA → Gemini → 本地 Ollama 兜底)
qa_url: "http://127.0.0.1:5000/api/edge/chat/ask"
qa_stream_url: "http://127.0.0.1:5000/api/edge/chat/ask/stream"
timeout: 120

View File

@@ -1,5 +1,4 @@
flask>=3.0.0
gunicorn>=21.2.0
PyMySQL>=1.1.0
PyYAML>=6.0
requests>=2.31.0

View File

@@ -0,0 +1,57 @@
"""把 NAS MariaDB 导出的 chat_history 导入甲骨文 SQLite2026-09-13 迁云一次性脚本)。
chat_history 是 NAS 那套库里唯一"不是镜像"的表——其余 sync_* 都能从甲骨文重新
读出来,只有问答历史是本地产生的,迁云时必须搬过来。
用法JSON 从 stdin 进来,导出命令见 docs/DEPLOY.md §2.4
cat chat_history.json | /opt/fam-core/venv/bin/python scripts/import_chat_history.py
幂等:按 chat_id 跳过已存在的行,重复执行不会产生重复记录。
"""
import json
import os
import sys
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src'))
from fam_core import db_layer # noqa: E402
_COLS = ('chat_id', 'user_question', 'ai_answer', 'context_summary',
'queried_date', 'queried_person', 'created_at')
def main():
try:
rows = json.load(sys.stdin)
except ValueError as e:
print(f"stdin 不是合法 JSON: {e}", file=sys.stderr)
return 1
if not isinstance(rows, list):
print("期望一个 JSON 数组", file=sys.stderr)
return 1
conn = db_layer.get_conn()
try:
db_layer._ensure_chat_schema(conn)
inserted = skipped = 0
for r in rows:
cid = r.get('chat_id')
if cid is not None and conn.execute(
"SELECT 1 FROM chat_history WHERE chat_id=?", (cid,)).fetchone():
skipped += 1
continue
conn.execute(
"INSERT INTO chat_history ({}) VALUES ({})".format(
','.join(_COLS), ','.join('?' * len(_COLS))),
tuple(None if r.get(c) is None else str(r.get(c)) for c in _COLS))
inserted += 1
conn.commit()
finally:
conn.close()
print(f"导入 {inserted} 条,跳过 {skipped}chat_id 已存在)")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -1,10 +1,20 @@
"""
FAM-Core 主应用 - Flask 单进程(新架构 v2
FAM-Core 主应用 - Flask2026-09-13 从 NAS 迁到甲骨文
承载: Oracle-Sync每 30 分钟拉取增量镜像)+ Member-Manager + Chat-Handler
承载: UI-API + Member-Manager + Chat-Handler + Img-Proxy —— 全是查询与转发,
**没有任何后台线程**,进程随时可重启,不持有任何状态。
NAS 不再处理视频:无 Scheduler / Dispatcher / Poller / Event-Receiver / Video-Server。
所有视频分析在 Oracle 完成NAS 仅作管理后台拉取展示CPU 占用大幅降低
迁云前它跑在 NAS 上,还扛着两个后台线程,现在都不在这里了:
- Oracle-Sync每 30 分钟把甲骨文数据拉一份镜像进 MariaDB—— 整个删除
本服务现在与 fam-edge 同机,直接读它的 SQLite见 db_layer.py 开头)。
- MotionNotifier轮询 Surveillance Station 推运动事件)—— 留在 NAS
拆成独立的 fam-notifier摄像头插在 NAS 上,这部分搬不走)。
两件跟安全有关的事:
- 本服务只监听 127.0.0.1config.yaml 的 server.host唯一的客户端是同机
Caddy。不像迁云前那样经 frp 把 :8000 暴露到公网。
- 登录校验也不在这里Caddy 用 forward_auth 打 fam-edge 的 /api/auth/verify
通过了才反代进来。
"""
import os
import sys
@@ -16,96 +26,52 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from .config_loader import load_config
from .logger import setup_logger
from .oracle_sync import get_sync
from .motion_notifier.motion_notifier import get_motion_notifier
from .chat_handler.chat_handler import chat_bp
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
from . import db_layer, edge_client
logger = setup_logger('fam-core.app')
app = Flask(__name__)
# 注册蓝图:/api/* 系列必须先于 static_bp 注册——static_bp 是通配兜底路由
# Vue Router history 模式回退 index.html排在前面会吞掉 API 请求。
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)
# 登录校验全局拦截(页面未登录 302 /login/api/* 未登录 401
init_auth(app)
# 健康检查
@app.route('/health', methods=['GET'])
def health():
return jsonify({"status": "ok", "service": "fam-core"}), 200
# 初始化后台同步线程NAS 唯一常驻线程)
_sync = None
try:
_sync = get_sync()
_sync.start()
logger.info("Oracle-Sync 已启动")
# 启动后立刻拉一次,前端无需等待首个周期即有数据
try:
_sync.trigger_now()
logger.info("启动首次同步完成")
except Exception as e:
logger.warning(f"启动首次同步失败(后续周期会重试): {e}")
except Exception as e:
logger.error(f"Oracle-Sync 启动失败: {e}")
# 初始化运动监测通知服务Webhook 驱动,不轮询;提供推送客户端 + 摄像头名映射)
_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}")
@app.route('/api/status', methods=['GET'])
def status():
"""系统状态"""
"""服务自检库能不能读、fam-edge 在不在。
迁云前这里报告的是镜像同步状态(游标/周期/上次增量条数),镜像没了之后
那些字段不再存在,前端侧边栏的同步面板也一并去掉了。
"""
db_ok, db_err = True, None
try:
conn = db_layer.get_conn()
try:
conn.execute("SELECT 1 FROM videos LIMIT 1").fetchone()
finally:
conn.close()
except Exception as e:
db_ok, db_err = False, str(e)
return jsonify({
"service": "fam-core",
"sync": _sync.status() if _sync else {"running": False, "error": "未初始化"},
"motion": _notifier.status() if _notifier else {"running": False, "error": "未初始化"},
"db": {"ok": db_ok, "error": db_err},
"edge_base_url": edge_client.base_url(),
}), 200
@app.route('/api/sync/trigger', methods=['POST'])
def sync_trigger():
"""手动立即触发一次甲骨文增量同步(服务状态页"立即同步"按钮)。
正常情况下后台线程每 30 分钟自动拉一次;这个接口给用户想立刻看到最新数据
时用跟后台线程共用同一把拉取锁oracle_sync._pull_lock不会并发重复拉。
"""
if not _sync:
return jsonify({"error": "同步服务未初始化"}), 503
ok = _sync.trigger_now()
if not ok:
return jsonify({"status": "failed", "error": _sync.status().get("last_error")}), 502
return jsonify({"status": "ok", **_sync.status()}), 200
if __name__ == '__main__':
cfg = load_config()
port = cfg.get('server', {}).get('port', 8000)
app.run(host='0.0.0.0', port=port, debug=False)
server = cfg.get('server', {})
app.run(host=server.get('host', '127.0.0.1'),
port=server.get('port', 5401), debug=False)

View File

@@ -1,222 +0,0 @@
"""
Auth - FAM-Core 登录校验2026-08-22 新增)
背景NAS :8000fam-core + FAM-UI通过 frp 暴露到外网后,需要先登录才能访问。
- 账号密码:环境变量 FAM_AUTH_USER / FAM_AUTH_PASS由 NAS 的 start_core.sh
source .env 注入。2026-08-23 起不再提供硬编码默认值——这两个环境变量跟
NAS SSH 密码是同一个值,公网入口用同一串密码兜底、还写死在代码里是双重
风险;.env 没配置好这两个变量时直接拒绝所有登录fail closed而不是
悄悄退回一个大家都知道的密码。
- 登录态:进程内 token 表 + HttpOnly cookiefam_session2 小时有效;
重启进程后需重新登录(可接受,见 config/auth 说明)。
- 拦截策略app.before_request 全局生效):
* 页面路径未登录 -> 302 重定向 /login
* /api/* 未登录 -> 401 JSON
- 白名单免登录:
* /login /api/login /api/logout /api/auth/check —— 登录流程本身
* /health —— 内部健康检查
* /api/ss/webhook —— Surveillance Station 推送无法携带登录态
* /assets/*、/favicon.ico —— SPA 静态资源
"""
import os
import secrets
import time
from flask import (Blueprint, Response, jsonify, make_response,
redirect, request)
from .logger import setup_logger
logger = setup_logger('fam-core.auth')
auth_bp = Blueprint('auth', __name__)
_SESSION_TTL = 2 * 3600 # cookie 有效期 2 小时
_sessions = {} # token -> 过期时间戳(进程内;重启需重新登录)
_warned_unconfigured = False # 只在第一次拒绝登录时打一条警告日志,别刷屏
def _check_credential(username: str, password: str) -> bool:
"""账号密码校验。凭据必须从环境变量读(.env 注入),不提供硬编码默认值——
这两个变量跟 NAS SSH 密码是同一个值,公网入口不能有"没配置就退回已知密码"
这种兜底宁可直接拒绝所有登录fail closed等运维发现并配置好 .env。"""
global _warned_unconfigured
user = os.environ.get('FAM_AUTH_USER', '')
pwd = os.environ.get('FAM_AUTH_PASS', '')
if not user or not pwd:
if not _warned_unconfigured:
logger.error(
"FAM_AUTH_USER/FAM_AUTH_PASS 未配置,拒绝所有登录——"
"请在 .env 里设置这两个环境变量后重启 fam-core")
_warned_unconfigured = True
return False
return (username or '') == user and (password or '') == pwd
def is_authed() -> bool:
tok = request.cookies.get('fam_session')
if not tok:
return False
exp = _sessions.get(tok)
if not exp:
return False
if time.time() > exp:
_sessions.pop(tok, None)
return False
return True
# ---------------------------------------------------------------------------
# 白名单(免登录)
# ---------------------------------------------------------------------------
_WHITELIST_EXACT = {
'/login', '/api/login', '/api/logout', '/api/auth/check',
'/health', '/favicon.ico',
'/api/ss/webhook', # SS 推送(无登录态,必须放行)
}
_WHITELIST_PREFIX = ('/assets/',)
def _is_whitelisted(path: str) -> bool:
if path in _WHITELIST_EXACT:
return True
return any(path.startswith(p) for p in _WHITELIST_PREFIX)
def init_auth(app):
"""注册全局登录拦截。需在注册完所有蓝图后调用。"""
@app.before_request
def _guard():
path = request.path
if _is_whitelisted(path):
return None
if is_authed():
return None
if path.startswith('/api/'):
return jsonify({"error": "未登录", "code": 401}), 401
return redirect('/login')
# ---------------------------------------------------------------------------
# 端点
# ---------------------------------------------------------------------------
@auth_bp.route('/login', methods=['GET'])
def login_page():
if is_authed():
return redirect('/')
return Response(_LOGIN_HTML, mimetype='text/html')
@auth_bp.route('/api/login', methods=['POST'])
def login():
data = request.get_json(silent=True) or request.form
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not _check_credential(username, password):
return jsonify({"error": "账号或密码错误"}), 401
tok = secrets.token_hex(24)
_sessions[tok] = time.time() + _SESSION_TTL
resp = make_response(jsonify({"ok": True}))
resp.set_cookie('fam_session', tok, max_age=_SESSION_TTL,
httponly=True, samesite='Lax', path='/')
return resp
@auth_bp.route('/api/logout', methods=['POST'])
def logout():
tok = request.cookies.get('fam_session')
if tok:
_sessions.pop(tok, None)
resp = make_response(jsonify({"ok": True}))
resp.delete_cookie('fam_session', path='/')
return resp
@auth_bp.route('/api/auth/check', methods=['GET'])
def check():
return jsonify({"authed": is_authed()})
# ---------------------------------------------------------------------------
# 登录页(内嵌 HTML深色风格与 fam-ui 一致;前端 SPA 无需改动)
# ---------------------------------------------------------------------------
_LOGIN_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>家庭监控 · 登录</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
min-height:100vh; display:flex; align-items:center; justify-content:center;
background: radial-gradient(1200px 600px at 20% -10%, #1e293b 0%, #0b1120 55%, #0b1120 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color:#e2e8f0;
}
.card {
width:360px; padding:40px 36px 32px; border-radius:16px;
background: rgba(15,23,42,.75); border:1px solid rgba(148,163,184,.18);
box-shadow: 0 24px 64px rgba(0,0,0,.45);
}
.logo { font-size:26px; font-weight:700; letter-spacing:.5px; margin-bottom:6px; }
.logo span { color:#38bdf8; }
.sub { font-size:13px; color:#94a3b8; margin-bottom:28px; }
label { display:block; font-size:12px; color:#94a3b8; margin:14px 0 6px; }
input {
width:100%; padding:10px 12px; border-radius:8px; font-size:14px; color:#e2e8f0;
background:#0f172a; border:1px solid #334155; outline:none; transition:border .15s;
}
input:focus { border-color:#38bdf8; }
button {
width:100%; margin-top:24px; padding:11px; border:none; border-radius:8px;
font-size:14px; font-weight:600; color:#0b1120; background:#38bdf8; cursor:pointer;
transition:background .15s, transform .05s;
}
button:hover { background:#0ea5e9; }
button:active { transform: scale(.98); }
.err { margin-top:14px; font-size:13px; color:#f87171; min-height:18px; text-align:center; }
.foot { margin-top:22px; text-align:center; font-size:11px; color:#475569; }
</style>
</head>
<body>
<div class="card">
<div class="logo">家庭监控 <span>FAM</span></div>
<div class="sub">请输入账号密码登录后访问</div>
<form id="f">
<label for="u">账号</label>
<input id="u" name="username" autocomplete="username" autofocus required>
<label for="p">密码</label>
<input id="p" name="password" type="password" autocomplete="current-password" required>
<button type="submit">登 录</button>
</form>
<div class="err" id="err"></div>
<div class="foot">Sentinel Home AI</div>
</div>
<script>
document.getElementById('f').addEventListener('submit', async (e) => {
e.preventDefault();
const err = document.getElementById('err');
err.textContent = '';
try {
const r = await fetch('/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('u').value.trim(),
password: document.getElementById('p').value
})
});
if (r.ok) { location.href = '/'; return; }
const d = await r.json().catch(() => ({}));
err.textContent = d.error || ('登录失败(' + r.status + '');
} catch (ex) {
err.textContent = '网络错误,请重试';
}
});
</script>
</body>
</html>
"""

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://127.0.0.1:5000/api/edge/chat/ask'
)
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
@@ -79,7 +79,7 @@ def chat_ask():
logger.info(f"Chat: person={queried_person}, date={queried_date}, question={question}")
rows = db_layer.query_sync_events_for_person_date(queried_person, queried_date)
rows = db_layer.query_events_for_person_date(queried_person, queried_date)
if len(rows) == 0:
answer = f"今天没有观察到{queried_person}"
@@ -87,7 +87,7 @@ def chat_ask():
else:
context = _format_events(rows)
context_summary = f"查询 sync_events {len(rows)}"
members = db_layer.get_sync_known_members_context()
members = db_layer.get_known_members_context()
prompt = build_chat_prompt(
context=context,
members=members or queried_person,
@@ -134,7 +134,7 @@ def chat_ask_stream():
return jsonify({"error": "缺少必填字段: question, queried_person, queried_date"}), 400
logger.info(f"Chat(stream): person={queried_person}, date={queried_date}, question={question}")
rows = db_layer.query_sync_events_for_person_date(queried_person, queried_date)
rows = db_layer.query_events_for_person_date(queried_person, queried_date)
def sse(obj):
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
@@ -157,14 +157,14 @@ def chat_ask_stream():
yield sse({"type": "context", "count": len(rows), "summary": context_summary,
"preview": context[:800]})
members = db_layer.get_sync_known_members_context()
members = db_layer.get_known_members_context()
prompt = build_chat_prompt(
context=context, members=members or queried_person,
question=question, queried_person=queried_person)
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://127.0.0.1:5000/api/edge/chat/ask/stream')
timeout = cfg.get('chat_handler', {}).get('timeout', 120)
full_answer = []

View File

@@ -1,11 +1,42 @@
"""
配置加载器 - 从 config.yaml 读取配置
配置加载器 - 从 config.yaml 读取配置,支持 ${ENV_VAR} 解析
"""
import os
import re
import yaml
def _load_env_file():
"""加载部署目录下的 .env支持 export KEY=VALUE 格式)。
迁云前是 start_core.sh 负责 source .env 再起 gunicorn现在由 systemd 拉起,
没有那一步,所以在这里兜底加载(跟 fam-edge 的做法一致)。
已存在的环境变量不覆盖。
"""
path = os.environ.get('FAM_ENV_FILE') or os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'.env')
if not os.path.isfile(path):
return
with open(path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if line.startswith('export '):
line = line[7:].strip()
if '=' not in line:
continue
key, _, value = line.partition('=')
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
_load_env_file()
def _resolve_env_vars(value):
"""递归解析字符串中的 ${ENV_VAR} 引用"""
if isinstance(value, str):

View File

@@ -1,269 +1,176 @@
"""
数据库访问层 - MariaDB 连接管理与同步镜像 CRUD
DB-Layer - 直读 fam-edge 的 SQLite 库2026-09-13 迁云重写)
新架构 (2026-08-21 重构):
NAS 不再处理视频,仅作为管理后台。
Oracle (FAM-Edge) 处理整视频分析后存 SQLiteNAS 每 30 分钟拉增量,
镜像到本地三张表:
sync_videos : 视频会话(全局摘要 + 事件 JSON + 人物 JSON
sync_events : 视频拆出的时间点事件(描述 + 涉及人物 + 是否关注)
sync_people : 规范人物表label + canonical_nameOracle 维护)
sync_cursor : 同步游标(上次成功拉取到的 server_time
**这个文件以前是什么样**fam-core 跑在 NAS 上,每 30 分钟把甲骨文的数据拉一份
镜像进 MariaDB`sync_videos`/`sync_events`/…前端读镜像。725 行里有一半是
镜像 upsert 的去重逻辑——8/29 和 9/3 两次线上事故1062 主键冲突、游标卡死)
都出在那一半。
本层只服务同步镜像 + 问答历史,旧 process_tasks/event_details/monitor_events/
family_members 相关逻辑已全部移除(视频处理职责已迁移至 Oracle
**现在**fam-core 跟 fam-edge 同机,直接打开它的 SQLite 库读,镜像层整个不存在了。
连带消失的还有一个隐蔽 bug镜像表为了保外键稳定用的是 NAS 本地自增 id而帧图
接口要的是甲骨文的 id两边在 9/3 那次 id 重排后就对不上了。现在只有一套 id。
要点:
- 库文件是 fam-edge 的(`database.path`**本模块只读它写的表**,唯一写的表是
`chat_history`(问答历史,迁云时从 NAS MariaDB 搬过来的fam-edge 不碰)。
- fam-edge 那边开了 WAL读不会阻塞它的写这边同样设 busy_timeout 兜底。
- 视频/人物的写操作(改名、删除)不在这里做,走 `edge_client` 打给 fam-edge——
它除了改库还要合并人物、删磁盘素材。
"""
import json
import pymysql
from datetime import datetime
from typing import Optional, List, Dict, Any
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
from .config_loader import load_config
from .logger import setup_logger
logger = setup_logger('fam-core.db')
_config = None
_db_path = None
_chat_schema_ready = False
def get_config():
global _config
if _config is None:
_config = load_config()
return _config
def _now() -> str:
return datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')
def get_conn():
"""获取数据库连接(单 worker gunicorn无需连接池"""
cfg = get_config().get('database', {})
kwargs = dict(
host=cfg.get('host', '127.0.0.1'),
port=cfg.get('port', 3306),
user=cfg.get('user', 'root'),
password=cfg.get('password', ''),
database=cfg.get('database', 'sentinel_home_ai'),
charset='utf8mb4',
autocommit=False
)
unix_socket = cfg.get('unix_socket')
if unix_socket:
kwargs['unix_socket'] = unix_socket
return pymysql.connect(**kwargs)
def get_config() -> Dict:
return load_config()
# ============================================================
# 同步镜像sync_videos
# ============================================================
def upsert_sync_videos(rows: List[Dict]) -> int:
"""批量 upsert Oracle 传来的 videos 增量。rows 为 Oracle 端 dict 列表。"""
if not rows:
return 0
conn = get_conn()
n = 0
try:
cur = conn.cursor()
for r in rows:
cur.execute(
"""INSERT INTO sync_videos
(id, drive_file_id, filename, camera_name, duration_sec,
event_start_time, status, summary_json, events_json,
people_json, compute_provider, created_at, updated_at,
processed_at, synced_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE
drive_file_id=VALUES(drive_file_id),
filename=VALUES(filename),
camera_name=VALUES(camera_name),
duration_sec=VALUES(duration_sec),
event_start_time=VALUES(event_start_time),
status=VALUES(status),
summary_json=VALUES(summary_json),
events_json=VALUES(events_json),
people_json=VALUES(people_json),
compute_provider=VALUES(compute_provider),
created_at=VALUES(created_at),
updated_at=VALUES(updated_at),
processed_at=VALUES(processed_at),
synced_at=NOW()""",
(r.get('id'), r.get('drive_file_id'), r.get('filename'),
r.get('camera_name'), r.get('duration_sec') or 0,
r.get('event_start_time'), r.get('status'),
r.get('summary_json'), r.get('events_json'), r.get('people_json'),
r.get('compute_provider'), r.get('created_at'),
r.get('updated_at'), r.get('processed_at'))
)
n += 1
conn.commit()
return n
finally:
conn.close()
def _path() -> str:
global _db_path
if _db_path is None:
_db_path = load_config().get('database', {}).get(
'path', '/opt/fam-edge/data/oracle.db')
return _db_path
def get_sync_videos(limit=15, offset=0, date_filter=None) -> List[Dict]:
"""获取视频会话列表(已完成优先),支持日期筛选与分页
def get_conn() -> sqlite3.Connection:
"""每次调用开一个连接(请求级,跟改写前的 MySQL 用法一致)
排序/日期维度按视频实际录制时间event_start_time文件名解析
为空回退 processed_at/updated_at/created_at
date_filter 形如 '2026-08-21'
只返回"有内容"的会话运动片段filename 前缀 motion_或含事件的视频
整段素材分割 0 段的空会话(历史素材无运动事件)不展示,避免淹没时间轴。
busy_timeoutfam-edge 的写事务提交时会短暂持锁,这里等而不是立刻报
`database is locked`9/3 那次 Oracle 过载时刷过一片这个错)
"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
# 录制时间优先,回退分析时间
date_expr = "COALESCE(NULLIF(event_start_time,''), processed_at, updated_at, created_at)"
# 注意pymysql execute 用 % 做参数占位符SQL 字面量不能含 %,故用 LEFT 判断 motion_ 前缀
content_filter = ("LEFT(v.filename, 7) = 'motion_' "
"OR EXISTS(SELECT 1 FROM sync_events se WHERE se.video_id = v.id)")
if date_filter:
cur.execute(
f"""SELECT v.id, v.filename, v.camera_name, v.event_start_time, v.status,
conn = sqlite3.connect(_path(), timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=10000")
return conn
def _rows(cur) -> List[Dict]:
return [dict(r) for r in cur.fetchall()]
def _row(cur) -> Optional[Dict]:
r = cur.fetchone()
return dict(r) if r else None
# 人物命中判断person_list_json 是 JSON 数组文本,用 json_each 展开精确匹配。
# 必须先 json_valid——历史数据里有非 JSON 的脏值,直接 json_each 会整条查询报错。
_PERSON_HIT = ("{col} IS NOT NULL AND json_valid({col}) "
"AND EXISTS(SELECT 1 FROM json_each({col}) WHERE json_each.value = ?)")
# 日期维度:录制时间优先(文件名解析出来的),回退分析时间
_DATE_EXPR = "COALESCE(NULLIF({a}.event_start_time,''), {a}.processed_at, {a}.updated_at, {a}.created_at)"
# 只展示"有内容"的会话:运动片段,或含事件的视频。
# 整段素材分割 0 段的空会话(历史素材无运动事件)不展示,避免淹没时间轴。
_CONTENT_FILTER = ("substr(v.filename, 1, 7) = 'motion_' "
"OR EXISTS(SELECT 1 FROM events se WHERE se.video_id = v.id)")
# ============================================================
# videos / events
# ============================================================
def get_videos(limit=15, offset=0, date_filter=None) -> List[Dict]:
"""视频会话列表(事件时间轴左侧),按录制时间倒序,支持日期筛选与分页。"""
d = _DATE_EXPR.format(a='v')
sql = f"""SELECT v.id, v.filename, v.camera_name, v.event_start_time, v.status,
v.summary_json, v.events_json, v.people_json, v.compute_provider,
v.processed_at, v.updated_at,
(SELECT COUNT(*) FROM sync_events se WHERE se.video_id = v.id) AS event_count
FROM sync_videos v
WHERE v.status='done' AND ({content_filter}) AND {date_expr} LIKE %s
ORDER BY {date_expr} DESC
LIMIT %s OFFSET %s""",
(SELECT COUNT(*) FROM events se WHERE se.video_id = v.id) AS event_count
FROM videos v
WHERE v.status='done' AND ({_CONTENT_FILTER}) {{extra}}
ORDER BY {d} DESC
LIMIT ? OFFSET ?"""
conn = get_conn()
try:
if date_filter:
cur = conn.execute(sql.format(extra=f"AND {d} LIKE ?"),
(f'{date_filter}%', limit, offset))
else:
cur.execute(
f"""SELECT v.id, v.filename, v.camera_name, v.event_start_time, v.status,
v.summary_json, v.events_json, v.people_json, v.compute_provider,
v.processed_at, v.updated_at,
(SELECT COUNT(*) FROM sync_events se WHERE se.video_id = v.id) AS event_count
FROM sync_videos v
WHERE v.status='done' AND ({content_filter})
ORDER BY {date_expr} DESC
LIMIT %s OFFSET %s""",
(limit, offset))
return cur.fetchall()
cur = conn.execute(sql.format(extra=''), (limit, offset))
return _rows(cur)
finally:
conn.close()
def get_sync_video(video_id: int) -> Optional[Dict]:
def get_video(video_id: int) -> Optional[Dict]:
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute("SELECT * FROM sync_videos WHERE id=%s", (video_id,))
return cur.fetchone()
return _row(conn.execute("SELECT * FROM videos WHERE id=?", (video_id,)))
finally:
conn.close()
def delete_sync_video(video_id: int):
"""删除本地镜像里的这条视频events 先删,再删 videos
增量同步get_sync_delta只做 upsert感知不到 Oracle 那边的物理删除,
所以删除操作不能走"转发 Oracle + trigger_now() 拉增量"这条老路,必须在
Oracle 确认删除成功后由调用方显式清理本地镜像。
"""
def get_events_for_video(video_id: int) -> List[Dict]:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("DELETE FROM sync_events WHERE video_id=%s", (video_id,))
cur.execute("DELETE FROM sync_videos WHERE id=%s", (video_id,))
conn.commit()
finally:
conn.close()
# ============================================================
# 同步镜像sync_events
# ============================================================
def upsert_sync_events(rows: List[Dict]) -> int:
"""批量 upsert Oracle 传来的 events 增量。"""
if not rows:
return 0
conn = get_conn()
n = 0
try:
cur = conn.cursor()
for r in rows:
cur.execute(
"""INSERT INTO sync_events
(id, video_id, ts, description, person_list_json,
person_appearances_json, is_attention_event,
updated_at, synced_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE
video_id=VALUES(video_id),
ts=VALUES(ts),
description=VALUES(description),
person_list_json=VALUES(person_list_json),
person_appearances_json=VALUES(person_appearances_json),
is_attention_event=VALUES(is_attention_event),
updated_at=VALUES(updated_at),
synced_at=NOW()""",
(r.get('id'), r.get('video_id'), r.get('ts'), r.get('description'),
r.get('person_list_json'), r.get('person_appearances_json'),
1 if r.get('is_attention_event') else 0,
r.get('updated_at'))
)
n += 1
conn.commit()
return n
finally:
conn.close()
def get_sync_events_for_video(video_id: int) -> List[Dict]:
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
return _rows(conn.execute(
"""SELECT id, video_id, ts, description, person_list_json,
is_attention_event, updated_at
FROM sync_events WHERE video_id=%s ORDER BY ts ASC""",
(video_id,))
return cur.fetchall()
is_attention_event
FROM events WHERE video_id=? ORDER BY ts ASC""", (video_id,)))
finally:
conn.close()
def get_sync_people_clips(label: str, limit: int = 10) -> List[Dict]:
"""某人物label 或 canonical_name出现过的运动片段列表。
匹配label 本身 + 其 canonical_name 下全部 label命中 sync_events 的
person_list_jsonJSON_CONTAINS→ 关联 video优先运动片段 motion_
返回按 event_start_time 倒序,含 first_ts该人物在片段内最早事件 ts
前端缩略图定位用)与 clip_events片段内该人物事件数
"""
def get_attention_events(limit: int = 200) -> List[Dict]:
"""需关注事件(统计图表页用),按录制日期倒序。"""
conn = get_conn()
try:
return _rows(conn.execute(
"""SELECT COALESCE(NULLIF(v.event_start_time,''), v.processed_at) AS ev_date,
e.person_list_json
FROM events e JOIN videos v ON e.video_id=v.id
WHERE e.is_attention_event = 1
ORDER BY ev_date DESC LIMIT ?""", (limit,)))
finally:
conn.close()
def get_people_clips(label: str, limit: int = 10) -> List[Dict]:
"""某人物出现过的运动片段列表。
匹配 label 本身 + 同一 canonical_name 下的全部 label返回含 first_ts
(该人物在片段内最早事件时间点,前端缩略图定位用)与 clip_events。
"""
hit_e = _PERSON_HIT.format(col='e.person_list_json')
hit_e2 = _PERSON_HIT.format(col='e2.person_list_json')
hit_e3 = _PERSON_HIT.format(col='e3.person_list_json')
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
labels = {label}
cur.execute(
"SELECT label, canonical_name FROM sync_people WHERE label=%s OR canonical_name=%s",
(label, label))
for r in cur.fetchall():
cn = r.get('canonical_name')
if cn:
cur.execute("SELECT label FROM sync_people WHERE canonical_name=%s", (cn,))
labels.update(x['label'] for x in cur.fetchall())
clips = []
seen = set()
for r in _rows(conn.execute(
"SELECT label, canonical_name FROM people WHERE label=? OR canonical_name=?",
(label, label))):
if r.get('canonical_name'):
labels.update(x['label'] for x in _rows(conn.execute(
"SELECT label FROM people WHERE canonical_name=?", (r['canonical_name'],))))
clips, seen = [], set()
for lb in sorted(labels):
cur.execute(
"""SELECT DISTINCT v.id AS video_id, v.filename, v.event_start_time,
cur = conn.execute(
f"""SELECT DISTINCT v.id AS video_id, v.filename, v.event_start_time,
v.duration_sec, v.summary_json, v.camera_name,
(SELECT MIN(e2.ts) FROM sync_events e2
WHERE e2.video_id=v.id AND e2.person_list_json IS NOT NULL
AND JSON_CONTAINS(e2.person_list_json, JSON_QUOTE(%s), '$')) AS first_ts,
(SELECT COUNT(*) FROM sync_events e3
WHERE e3.video_id=v.id AND e3.person_list_json IS NOT NULL
AND JSON_CONTAINS(e3.person_list_json, JSON_QUOTE(%s), '$')) AS clip_events
FROM sync_events e
JOIN sync_videos v ON e.video_id=v.id
WHERE e.person_list_json IS NOT NULL
AND JSON_CONTAINS(e.person_list_json, JSON_QUOTE(%s), '$')
AND v.status='done'""",
(SELECT MIN(e2.ts) FROM events e2
WHERE e2.video_id=v.id AND {hit_e2}) AS first_ts,
(SELECT COUNT(*) FROM events e3
WHERE e3.video_id=v.id AND {hit_e3}) AS clip_events
FROM events e JOIN videos v ON e.video_id=v.id
WHERE {hit_e} AND v.status='done'""",
(lb, lb, lb))
for row in cur.fetchall():
for row in _rows(cur):
if row['video_id'] not in seen:
seen.add(row['video_id'])
clips.append(row)
@@ -273,209 +180,55 @@ def get_sync_people_clips(label: str, limit: int = 10) -> List[Dict]:
conn.close()
def query_sync_events_for_person_date(person: str, date_str: str) -> List[Dict]:
def query_events_for_person_date(person: str, date_str: str) -> List[Dict]:
"""问答上下文:某人在某天的事件。
说明: Oracle 事件 ts 视频内相对时间点(如 00:01:23不是绝对日期
因此按所属视频的录制日期event_start_time回退 processed_at过滤
再按 person_list_json 命中人名。
person 可为真名或抽象标签Oracle 回灌上下文用真名,但历史标签也保留)。
事件 ts 视频内相对时间点(如 00:01:23不是绝对日期所以按所属视频的
录制日期过滤,再按 person_list_json 命中人名。person 可为真名或抽象标签。
"""
hit = _PERSON_HIT.format(col='e.person_list_json')
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"""SELECT e.ts, e.description, e.person_list_json, e.is_attention_event,
return _rows(conn.execute(
f"""SELECT e.ts, e.description, e.person_list_json, e.is_attention_event,
v.camera_name, v.filename, v.event_start_time, v.processed_at
FROM sync_events e
JOIN sync_videos v ON e.video_id = v.id
WHERE COALESCE(NULLIF(v.event_start_time,''), v.processed_at) LIKE %s
AND e.person_list_json IS NOT NULL
AND JSON_CONTAINS(e.person_list_json, JSON_QUOTE(%s), '$')
FROM events e JOIN videos v ON e.video_id = v.id
WHERE COALESCE(NULLIF(v.event_start_time,''), v.processed_at) LIKE ?
AND {hit}
ORDER BY COALESCE(NULLIF(v.event_start_time,''), v.processed_at) ASC, e.ts ASC""",
(f'{date_str}%', person))
return cur.fetchall()
(f'{date_str}%', person)))
finally:
conn.close()
# ============================================================
# 同步镜像sync_people
# people
# ============================================================
def upsert_sync_people(rows: List[Dict]) -> int:
"""批量 upsert Oracle 传来的 people 增量。"""
if not rows:
return 0
conn = get_conn()
n = 0
try:
cur = conn.cursor()
for r in rows:
cur.execute(
"""INSERT INTO sync_people
(id, label, canonical_name, first_seen, appearances,
source, features_json, display_uid, updated_at, synced_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE
label=VALUES(label),
canonical_name=VALUES(canonical_name),
first_seen=VALUES(first_seen),
appearances=VALUES(appearances),
source=VALUES(source),
features_json=VALUES(features_json),
display_uid=VALUES(display_uid),
updated_at=VALUES(updated_at),
synced_at=NOW()""",
(r.get('id'), r.get('label'), r.get('canonical_name'),
r.get('first_seen'), r.get('appearances') or 0,
r.get('source'), r.get('features_json'),
r.get('display_uid'), r.get('updated_at')))
n += 1
conn.commit()
return n
finally:
conn.close()
def get_sync_people() -> List[Dict]:
def get_people() -> List[Dict]:
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
return _rows(conn.execute(
"SELECT id, label, canonical_name, first_seen, appearances, source, "
"features_json, display_uid, updated_at "
"FROM sync_people ORDER BY id ASC")
return cur.fetchall()
"features_json, display_uid, updated_at FROM people ORDER BY id ASC"))
finally:
conn.close()
def upsert_sync_model_calls(rows: List[Dict]) -> int:
"""批量 upsert Oracle 传来的 model_calls 增量(幂等,重复覆盖)。"""
if not rows:
return 0
conn = get_conn()
n = 0
try:
cur = conn.cursor()
for r in rows:
cur.execute(
"""INSERT INTO sync_model_calls
(id, provider, model, video_id, filename, started_at,
duration_sec, success, error, created_at, synced_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE
provider=VALUES(provider),
model=VALUES(model),
video_id=VALUES(video_id),
filename=VALUES(filename),
started_at=VALUES(started_at),
duration_sec=VALUES(duration_sec),
success=VALUES(success),
error=VALUES(error),
created_at=VALUES(created_at),
synced_at=NOW()""",
(r.get('id'), r.get('provider'), r.get('model'),
r.get('video_id'), r.get('filename'), r.get('started_at'),
r.get('duration_sec') or 0, 1 if r.get('success') else 0,
(r.get('error') or '')[:500], r.get('created_at')))
n += 1
conn.commit()
return n
finally:
conn.close()
def upsert_sync_identity_map(rows: List[Dict]) -> int:
"""批量 upsert Oracle 传来的 person_identity_map 增量(幂等,重复覆盖)。
甲骨文不稳定,提取出的有效数据(含闭集人物识别结果/纠错记录)都要同步到
NAS 防丢失——这张表跟 sync_people/sync_model_calls 走一样的镜像模式。
"""
if not rows:
return 0
conn = get_conn()
n = 0
try:
cur = conn.cursor()
for r in rows:
cur.execute(
"""INSERT INTO sync_identity_map
(id, video_id, raw_uid, canonical_name, source, updated_at, synced_at)
VALUES (%s,%s,%s,%s,%s,%s, NOW())
ON DUPLICATE KEY UPDATE
video_id=VALUES(video_id),
raw_uid=VALUES(raw_uid),
canonical_name=VALUES(canonical_name),
source=VALUES(source),
updated_at=VALUES(updated_at),
synced_at=NOW()""",
(r.get('id'), r.get('video_id'), r.get('raw_uid'),
r.get('canonical_name'), r.get('source'), r.get('updated_at')))
n += 1
conn.commit()
return n
finally:
conn.close()
def get_sync_model_calls(limit: int = 200) -> List[Dict]:
"""最近模型调用记录(前端统计展示)。"""
def get_named_members() -> List[str]:
"""已命名成员的真名列表UI 下拉用)。"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"SELECT id, provider, model, video_id, filename, started_at, "
"duration_sec, success, error, created_at "
"FROM sync_model_calls ORDER BY id DESC LIMIT %s", (limit,))
return cur.fetchall()
finally:
conn.close()
def get_sync_model_calls_stats() -> Dict:
"""模型调用统计:按 provider+model 聚合成功/失败/平均耗时。"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"""SELECT provider, model,
SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) AS ok_cnt,
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS fail_cnt,
ROUND(AVG(duration_sec), 1) AS avg_duration,
MAX(created_at) AS last_call
FROM sync_model_calls
GROUP BY provider, model ORDER BY provider, model""")
return cur.fetchall()
finally:
conn.close()
def get_sync_named_members() -> List[str]:
"""已命名成员的真名列表(供 UI 下拉 / 快捷选择)。"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"SELECT DISTINCT canonical_name FROM sync_people "
return [r['canonical_name'] for r in _rows(conn.execute(
"SELECT DISTINCT canonical_name FROM people "
"WHERE canonical_name IS NOT NULL AND canonical_name != '' "
"ORDER BY canonical_name ASC")
return [r['canonical_name'] for r in cur.fetchall()]
"ORDER BY canonical_name ASC"))]
finally:
conn.close()
def get_sync_known_members_context() -> str:
"""获取人物清单文本,注入问答 Prompt让模型用真名指代。"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"SELECT label, canonical_name FROM sync_people ORDER BY id ASC")
rows = cur.fetchall()
if not rows:
return ""
def get_known_members_context() -> str:
"""人物清单文本,注入问答 Prompt让模型用真名指代。"""
rows = get_people()
parts = []
for r in rows:
name = r['canonical_name'] or r['label']
@@ -484,159 +237,120 @@ def get_sync_known_members_context() -> str:
else:
parts.append(f"- {name}")
return "\n".join(parts)
# ============================================================
# model_calls
# ============================================================
def get_model_calls(limit: int = 200) -> List[Dict]:
conn = get_conn()
try:
return _rows(conn.execute(
"SELECT id, provider, model, video_id, filename, started_at, "
"duration_sec, success, error, created_at "
"FROM model_calls ORDER BY id DESC LIMIT ?", (limit,)))
finally:
conn.close()
def get_model_calls_stats() -> List[Dict]:
"""按 provider+model 聚合成功/失败/平均耗时。"""
conn = get_conn()
try:
return _rows(conn.execute(
"""SELECT provider, model,
SUM(CASE WHEN success=1 THEN 1 ELSE 0 END) AS ok_cnt,
SUM(CASE WHEN success=0 THEN 1 ELSE 0 END) AS fail_cnt,
ROUND(AVG(duration_sec), 1) AS avg_duration,
MAX(created_at) AS last_call
FROM model_calls GROUP BY provider, model ORDER BY provider, model"""))
finally:
conn.close()
# ============================================================
# 同步游标
# 概览统计
# ============================================================
def get_sync_cursor() -> str:
def get_stats(date_str: str = None) -> Dict:
"""视频数 / 事件数 / 关注事件数 / 出现人物数(可按日期过滤)。"""
d = _DATE_EXPR.format(a='sv')
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT `value` FROM sync_cursor WHERE `key`='last_since'")
row = cur.fetchone()
return row[0] if row else ''
finally:
conn.close()
def set_sync_cursor(value: str):
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""INSERT INTO sync_cursor (`key`, `value`) VALUES ('last_since', %s)
ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)""",
(value,))
conn.commit()
finally:
conn.close()
# ============================================================
# 运动通知游标MotionNotifier 增量去重用,复用 sync_cursor 表)
# ============================================================
def get_motion_cursor() -> str:
"""返回上次推送到甲骨文的最大 SS 事件 id字符串无则返回空。"""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT `value` FROM sync_cursor WHERE `key`='motion_last_event_id'")
row = cur.fetchone()
return row[0] if row else ''
finally:
conn.close()
def set_motion_cursor(value: str):
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""INSERT INTO sync_cursor (`key`, `value`) VALUES ('motion_last_event_id', %s)
ON DUPLICATE KEY UPDATE `value`=VALUES(`value`)""",
(value,))
conn.commit()
finally:
conn.close()
# ============================================================
# 统计
# ============================================================
def get_attention_events(limit: int = 200) -> List[Dict]:
"""需关注事件列表(供统计图表页展示日期 + 涉及人物),按录制日期倒序。"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute(
"""SELECT COALESCE(NULLIF(v.event_start_time,''), v.processed_at) AS ev_date,
e.person_list_json
FROM sync_events e JOIN sync_videos v ON e.video_id=v.id
WHERE e.is_attention_event = 1
ORDER BY ev_date DESC LIMIT %s""",
(limit,))
return cur.fetchall()
finally:
conn.close()
def get_sync_stats(date_str: str = None) -> Dict:
"""概览统计:视频数 / 事件数 / 关注事件数 / 出现人物数(按 date 可选过滤)。
人物数通过对 sync_events.person_list_json LIKE 统计MariaDB 不支持 JSON 数组展开)。
"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
# 视频/事件/关注数(日期维度=录制时间 event_start_time回退 processed_at
_D = "COALESCE(NULLIF(sv.event_start_time,''), sv.processed_at)"
if date_str:
cur.execute(
"""SELECT
(SELECT COUNT(*) FROM sync_videos sv
like = f'{date_str}%'
stat = _row(conn.execute(
f"""SELECT
(SELECT COUNT(*) FROM videos sv
WHERE sv.status='done'
AND (LEFT(sv.filename, 7) = 'motion_'
OR EXISTS(SELECT 1 FROM sync_events se WHERE se.video_id=sv.id))
AND {d} LIKE %s) AS videos,
(SELECT COUNT(*) FROM sync_events se
JOIN sync_videos sv ON se.video_id=sv.id
WHERE {d} LIKE %s) AS events,
(SELECT COALESCE(SUM(se.is_attention_event),0) FROM sync_events se
JOIN sync_videos sv ON se.video_id=sv.id
WHERE {d} LIKE %s) AS attention""".format(d=_D),
(f'{date_str}%', f'{date_str}%', f'{date_str}%'))
AND (substr(sv.filename,1,7)='motion_'
OR EXISTS(SELECT 1 FROM events se WHERE se.video_id=sv.id))
AND {d} LIKE ?) AS videos,
(SELECT COUNT(*) FROM events se JOIN videos sv ON se.video_id=sv.id
WHERE {d} LIKE ?) AS events,
(SELECT COALESCE(SUM(se.is_attention_event),0) FROM events se
JOIN videos sv ON se.video_id=sv.id
WHERE {d} LIKE ?) AS attention""",
(like, like, like))) or {}
else:
cur.execute(
stat = _row(conn.execute(
"""SELECT
(SELECT COUNT(*) FROM sync_videos
WHERE status='done'
AND (LEFT(filename, 7) = 'motion_'
OR EXISTS(SELECT 1 FROM sync_events se WHERE se.video_id=sync_videos.id))) AS videos,
(SELECT COUNT(*) FROM sync_events) AS events,
(SELECT COALESCE(SUM(is_attention_event),0) FROM sync_events) AS attention""")
stat = cur.fetchone() or {}
(SELECT COUNT(*) FROM videos sv
WHERE sv.status='done'
AND (substr(sv.filename,1,7)='motion_'
OR EXISTS(SELECT 1 FROM events se WHERE se.video_id=sv.id))) AS videos,
(SELECT COUNT(*) FROM events) AS events,
(SELECT COALESCE(SUM(is_attention_event),0) FROM events) AS attention""")) or {}
# 人物数distinct label 命中 sync_events
cur.execute("SELECT id, label, canonical_name FROM sync_people")
people = cur.fetchall()
# 基于 person_list_json 命中计数:逐 label 统计命中事件数
person_hits = 0
for p in people:
# 出现人物数:逐 label/真名在事件里查有没有命中(沿用改写前的口径
hits = 0
for p in _rows(conn.execute("SELECT label, canonical_name FROM people")):
name = p['canonical_name'] or p['label']
cur.execute(
"SELECT COUNT(*) c FROM sync_events WHERE person_list_json LIKE %s",
(f'%{name}%',))
if cur.fetchone()['c'] > 0:
person_hits += 1
stat['people'] = person_hits
c = conn.execute("SELECT COUNT(*) AS c FROM events "
"WHERE person_list_json LIKE ?", (f'%{name}%',)).fetchone()
if c and c['c'] > 0:
hits += 1
stat['people'] = hits
return stat
finally:
conn.close()
# ============================================================
# chat_history保留:问答历史
# chat_history本模块唯一写的表2026-09-13 从 NAS MariaDB 迁入
# ============================================================
def _ensure_chat_schema(conn):
global _chat_schema_ready
if _chat_schema_ready:
return
conn.executescript("""
CREATE TABLE IF NOT EXISTS chat_history (
chat_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_question TEXT NOT NULL,
ai_answer TEXT NOT NULL,
context_summary TEXT,
queried_date TEXT,
queried_person TEXT,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_chat_created ON chat_history(created_at);
""")
conn.commit()
_chat_schema_ready = True
def insert_chat_history(user_question: str, ai_answer: str,
context_summary: str, queried_date: str,
queried_person: str) -> int:
"""插入对话记录"""
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
_ensure_chat_schema(conn)
cur = conn.execute(
"""INSERT INTO chat_history
(user_question, ai_answer, context_summary, queried_date, queried_person)
VALUES (%s, %s, %s, %s, %s)""",
(user_question, ai_answer, context_summary, queried_date, queried_person)
)
(user_question, ai_answer, context_summary, queried_date,
queried_person, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(user_question, ai_answer, context_summary, queried_date,
queried_person, _now()))
conn.commit()
return cur.lastrowid
finally:
@@ -644,24 +358,20 @@ def insert_chat_history(user_question: str, ai_answer: str,
def get_chat_history(limit=20, offset=0, date_filter=None, person_filter=None) -> List[Dict]:
"""获取对话历史(分页)"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
conditions = []
params = []
_ensure_chat_schema(conn)
conds, params = [], []
if date_filter:
conditions.append("queried_date = %s")
conds.append("queried_date = ?")
params.append(date_filter)
if person_filter:
conditions.append("queried_person = %s")
conds.append("queried_person = ?")
params.append(person_filter)
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
where = f"WHERE {' AND '.join(conds)}" if conds else ""
params.extend([limit, offset])
cur.execute(
f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT %s OFFSET %s",
params
)
return cur.fetchall()
return _rows(conn.execute(
f"SELECT * FROM chat_history {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
params))
finally:
conn.close()

View File

@@ -0,0 +1,102 @@
"""
Edge-Client - 访问同机 fam-edge 的薄客户端2026-09-13 迁云后新增)
迁云之前这些调用散在 `oracle_sync.py` 里NAS 跨公网访问甲骨文),随镜像层一起
删掉了。现在 fam-core 与 fam-edge 同机,全部走 127.0.0.1:不出公网、无 TLS、
无隧道token 仍然带着fam-edge 侧的鉴权没变,且它同时对公网监听 :5000
只保留三类调用:
- push_name_correct / push_video_delete —— 写操作仍由 fam-edge 处理,因为它
除了改库还要做人物合并、删磁盘素材,不是单纯一条 SQL
- get_activity —— 服务状态页
- fetch_image —— 事件帧图 / 人物头像(图源和裁剪都在 fam-edge
"""
import requests
from .config_loader import load_config
from .logger import setup_logger
logger = setup_logger('fam-core.edge_client')
_cfg = None
def _conf():
global _cfg
if _cfg is None:
c = load_config().get('edge', {})
_cfg = {
'base_url': (c.get('base_url') or 'http://127.0.0.1:5000').rstrip('/'),
'token': c.get('token') or '',
'timeout': int(c.get('timeout', 60)),
}
return _cfg
def base_url():
return _conf()['base_url']
def token():
return _conf()['token']
def _post(rel: str, payload: dict):
"""返回 (ok, err)。写操作统一用这个,失败时把原因带回给前端。"""
c = _conf()
body = dict(payload)
if c['token']:
body['token'] = c['token']
try:
resp = requests.post(f"{c['base_url']}{rel}", json=body, timeout=(5, c['timeout']))
except requests.RequestException as e:
logger.error(f"fam-edge {rel} 请求失败: {e}")
return False, str(e)
if resp.status_code != 200:
logger.warning(f"fam-edge {rel} 返回 {resp.status_code}: {resp.text[:160]}")
return False, f"HTTP {resp.status_code} {resp.text[:120]}"
return True, ''
def push_name_correct(label: str, canonical_name: str):
return _post('/api/oracle/people/correct',
{'label': label, 'canonical_name': canonical_name})
def push_identity_correct(video_id: int, current_name: str, new_name: str):
return _post('/api/oracle/identity/correct',
{'video_id': video_id, 'current_name': current_name, 'new_name': new_name})
def push_video_delete(video_id: int):
return _post('/api/oracle/video/delete', {'video_id': video_id})
def get_activity():
"""服务状态页用fam-edge 的队列 / 分割 / 模型活动快照。返回 (data, err)。"""
c = _conf()
try:
r = requests.get(f"{c['base_url']}/api/oracle/activity",
params={'token': c['token']}, timeout=(5, 15))
except requests.RequestException as e:
return None, f"连接 fam-edge 失败: {e}"
if r.status_code != 200:
return None, f"fam-edge activity HTTP {r.status_code}"
return r.json(), None
def fetch_image(rel: str, params: dict):
"""帧图 / 头像透传,失败返回 None调用方给 404"""
c = _conf()
p = dict(params)
if c['token']:
p['token'] = c['token']
try:
resp = requests.get(f"{c['base_url']}{rel}", params=p, timeout=(5, c['timeout']))
except requests.RequestException as e:
logger.error(f"fam-edge {rel} 请求失败: {e}")
return None
if resp.status_code == 200 and resp.content:
return resp.content
logger.warning(f"fam-edge {rel} 返回 {resp.status_code}")
return None

View File

@@ -1,14 +1,12 @@
"""
Img-Proxy - NAS 端图片代理(图源在 Oracle计算全部在 Oracle
Img-Proxy - 图片代理(图源与裁剪都在 fam-edge
浏览器不直连 Oracle避免公网暴露 5000 端口与 token 外泄),
而是访问 NAS fam-core 的 /api/proxy/*,由 NAS 出网到 Oracle 拉取 jpeg 回传
Oracle 侧已有磁盘缓存 / VLM 人物定位裁剪NAS 端仅透传,不做图像计算。
浏览器不直连 fam-edge避免 token 外泄),而是访问 /api/proxy/*,由本服务转一手。
迁云后这一跳是同机 127.0.0.1,纯透传,不做图像计算
"""
from flask import Blueprint, Response, request
from .oracle_sync import get_sync
from . import edge_client
from .logger import setup_logger
logger = setup_logger('fam-core.img_proxy')
@@ -17,21 +15,7 @@ img_bp = Blueprint('img_proxy', __name__)
def _fetch(rel: str, params: dict):
import requests
sync = get_sync() # 复用 oracle_sync 已解析好的 base_url/token/timeout避免两处配置各读一份
params = dict(params)
if sync.token:
params['token'] = sync.token
try:
resp = requests.get(f"{sync.base_url}{rel}", params=params,
timeout=(10, sync.timeout))
if resp.status_code == 200 and resp.content:
return resp.content
logger.warning(f"Oracle {rel} 返回 {resp.status_code}: {resp.text[:120]}")
return None
except Exception as e:
logger.error(f"Oracle {rel} 请求失败: {e}")
return None
return edge_client.fetch_image(rel, params)
@img_bp.route('/api/proxy/frame', methods=['GET'])

View File

@@ -14,7 +14,7 @@ from flask import Blueprint, request, jsonify
from ..logger import setup_logger
from .. import db_layer
from ..oracle_sync import get_sync
from .. import edge_client
logger = setup_logger('fam-core.member_manager')
@@ -24,7 +24,7 @@ member_bp = Blueprint('member_manager', __name__)
@member_bp.route('/api/member/unnamed', methods=['GET'])
def list_unnamed():
"""列出未命名人物canonical_name 为空)"""
members = db_layer.get_sync_people()
members = db_layer.get_people()
result = []
for m in members:
canonical = m.get('canonical_name')
@@ -40,7 +40,7 @@ def list_unnamed():
@member_bp.route('/api/member/list', methods=['GET'])
def list_members():
"""列出所有人物(按 canonical_name 或 label 展示)"""
members = db_layer.get_sync_people()
members = db_layer.get_people()
result = []
for m in members:
canonical = m.get('canonical_name')
@@ -58,7 +58,7 @@ def list_members():
@member_bp.route('/api/member/name', methods=['POST'])
def name_member():
"""命名人物(回推 Oracle + 立即拉回本地镜像
"""命名人物(交给 fam-edge 落库 + 合并人物
请求: {"label": "人物A", "canonical_name": "张三"}
"""
@@ -71,18 +71,13 @@ def name_member():
if not label or not canonical_name:
return jsonify({"error": "缺少必填字段: label, canonical_name"}), 400
logger.info(f"命名: {label} -> {canonical_name}(回推 Oracle")
ok, err = get_sync().push_name_correct(label, canonical_name)
logger.info(f"命名: {label} -> {canonical_name}(回推 fam-edge")
ok, err = edge_client.push_name_correct(label, canonical_name)
if not ok:
return jsonify({"error": f"回推 Oracle 失败: {err}"}), 502
return jsonify({"error": f"回推 fam-edge 失败: {err}"}), 502
# 立即拉回最新 people 镜像,前端无需等待下一个 30 分钟周期
try:
get_sync().trigger_now()
except Exception as e:
logger.warning(f"命名后即时拉回失败(下一个周期会自动同步): {e}")
members = db_layer.get_sync_people()
members = db_layer.get_people()
return jsonify({
"status": "ok",
"label": label,
@@ -114,24 +109,20 @@ def merge_member():
return jsonify({"error": "source 与 target 不能相同"}), 400
# 解析 target 的规范名
members = {m['label']: m for m in db_layer.get_sync_people()}
members = {m['label']: m for m in db_layer.get_people()}
target_row = members.get(target)
if target_row and target_row.get('canonical_name'):
canonical = target_row['canonical_name']
else:
canonical = target # target 未命名 -> 以 label 作为规范名
logger.info(f"合并: {source} -> {canonical}(回推 Oracle")
ok, err = get_sync().push_name_correct(source, canonical)
logger.info(f"合并: {source} -> {canonical}(回推 fam-edge")
ok, err = edge_client.push_name_correct(source, canonical)
if not ok:
return jsonify({"error": f"回推 Oracle 失败: {err}"}), 502
return jsonify({"error": f"回推 fam-edge 失败: {err}"}), 502
try:
get_sync().trigger_now()
except Exception as e:
logger.warning(f"合并后即时拉回失败(下一个周期会自动同步): {e}")
members = db_layer.get_sync_people()
members = db_layer.get_people()
return jsonify({
"status": "ok",
"source": source,
@@ -163,15 +154,11 @@ def identity_correct():
if not video_id or not current_name or not new_name:
return jsonify({"error": "缺少必填字段: video_id, current_name, new_name"}), 400
logger.info(f"人物纠错: video_id={video_id} {current_name} -> {new_name}(回推 Oracle")
ok, err = get_sync().push_identity_correct(video_id, current_name, new_name)
logger.info(f"人物纠错: video_id={video_id} {current_name} -> {new_name}(回推 fam-edge")
ok, err = edge_client.push_identity_correct(video_id, current_name, new_name)
if not ok:
return jsonify({"error": f"回推 Oracle 失败: {err}"}), 502
return jsonify({"error": f"回推 fam-edge 失败: {err}"}), 502
try:
get_sync().trigger_now()
except Exception as e:
logger.warning(f"纠错后即时拉回失败(下一个周期会自动同步): {e}")
return jsonify({
"status": "ok", "video_id": video_id,

View File

@@ -1,76 +0,0 @@
"""
运动监测 Webhook 接收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:[...]} / 裸数组,字段名已做兼容。
"""
from flask import Blueprint, request, jsonify
from .logger import setup_logger
from .motion_notifier.motion_notifier import get_motion_notifier
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

@@ -1,4 +0,0 @@
"""MotionNotifier 包NAS 端运动监测通知服务。"""
from .motion_notifier import MotionNotifier, get_motion_notifier
__all__ = ["MotionNotifier", "get_motion_notifier"]

View File

@@ -1,234 +0,0 @@
"""
Oracle-Sync - NAS 端唯一后台线程
职责:
1. 每 interval_sec默认 1800s = 30 分钟)从甲骨文 FAM-Edge 拉取增量:
GET {base_url}/api/oracle/sync?since=<cursor>&token=<token>
返回 {videos, events, people, server_time},写入本地 MariaDB 镜像表
(sync_videos / sync_events / sync_people),并推进 sync_cursor。
2. 接收命名校正回推: POST {base_url}/api/oracle/people/correct
{label, canonical_name, token} —— 手动命名manual 优先,不被 LLM 覆盖)。
数据流向(新架构 v2:
Google 硬盘 --rclone--> 甲骨文本地 --> 整视频分析 --> Oracle SQLite
--> [本线程每 30 分钟拉增量] --> NAS MariaDB 镜像 --> fam-ui 读取展示
NAS 不再处理任何视频CPU 占用显著降低。
"""
import time
import threading
import requests
from datetime import datetime
from .logger import setup_logger
from .config_loader import load_config
from . import db_layer
logger = setup_logger('fam-core.oracle_sync')
_SYNC_INSTANCE = None
def get_sync():
"""模块级单例app.py 启动时创建并 start其余模块经此获取"""
global _SYNC_INSTANCE
if _SYNC_INSTANCE is None:
_SYNC_INSTANCE = OracleSync()
return _SYNC_INSTANCE
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.token = cfg.get('token', '')
self.interval_sec = int(cfg.get('interval_sec', 1800))
self.timeout = int(cfg.get('timeout', 120))
self._running = False
self._thread = None
self._last_sync_at = None
self._last_error = None
self._last_count = None
# 拉取互斥锁trigger_now命名后即时拉回与后台 _run 并发时只允许一个执行
self._pull_lock = threading.Lock()
# ------------------------------------------------------------------
def _pull_once(self) -> bool:
"""执行一次增量拉取(加锁防并发双拉)。返回是否成功。"""
with self._pull_lock:
return self._pull_once_locked()
def _pull_once_locked(self) -> bool:
since = db_layer.get_sync_cursor() or ''
params = {'since': since, 'token': self.token}
try:
resp = requests.get(
f"{self.base_url}/api/oracle/sync",
params=params, timeout=(10, self.timeout))
except requests.RequestException as e:
self._last_error = f"请求失败: {e}"
logger.error(f"拉取同步失败: {e}")
return False
if resp.status_code == 401:
self._last_error = "token 校验失败"
logger.error("同步 token 校验失败 (401),请检查 oracle_sync.token 配置")
return False
if resp.status_code != 200:
self._last_error = f"HTTP {resp.status_code}"
logger.error(f"同步返回异常: {resp.status_code} {resp.text[:200]}")
return False
try:
data = resp.json()
except ValueError:
self._last_error = "非 JSON 响应"
logger.error("同步返回非 JSON 响应")
return False
videos = data.get('videos', []) or []
events = data.get('events', []) or []
people = data.get('people', []) or []
model_calls = data.get('model_calls', []) or []
identity_map = data.get('identity_map', []) or []
server_time = data.get('server_time', '') or ''
n_videos = db_layer.upsert_sync_videos(videos)
n_events = db_layer.upsert_sync_events(events)
n_people = db_layer.upsert_sync_people(people)
n_calls = db_layer.upsert_sync_model_calls(model_calls)
n_identity = db_layer.upsert_sync_identity_map(identity_map)
if server_time:
db_layer.set_sync_cursor(server_time)
self._last_sync_at = datetime.now()
self._last_error = None
self._last_count = (n_videos, n_events, n_people, n_calls, n_identity)
logger.info(
f"同步完成: videos+{n_videos} events+{n_events} people+{n_people} "
f"model_calls+{n_calls} identity_map+{n_identity} since={since!r} "
f"-> server_time={server_time}")
return True
# ------------------------------------------------------------------
def push_name_correct(self, label: str, canonical_name: str):
"""回推命名校正到 Oracle手动命名优先级最高不被 LLM 覆盖)。
返回 (success: bool, error: str)
"""
label = (label or '').strip()
canonical_name = (canonical_name or '').strip()
if not label or not canonical_name:
return False, "缺少 label / canonical_name"
try:
resp = requests.post(
f"{self.base_url}/api/oracle/people/correct",
json={"label": label, "canonical_name": canonical_name,
"token": self.token},
timeout=(10, 30))
except requests.RequestException as e:
logger.error(f"命名校正回推失败: {e}")
return False, str(e)
if resp.status_code == 200:
return True, ""
msg = f"HTTP {resp.status_code}: {resp.text[:200]}"
logger.error(f"命名校正回推失败: {msg}")
return False, msg
# ------------------------------------------------------------------
def push_identity_correct(self, video_id: int, current_name: str, new_name: str):
"""回推事件时间轴/人物管理"这个人识别错了"纠错到 Oracle。
跟 push_name_correct 的区别:这个按 (video_id, 当前展示名) 定位,只改这
一段视频里错认的那个人,不影响同名字符串在其他视频里的映射(人物 uid
只在单次视频分析内稳定,跨视频复用同一字符串完全可能是不同真人)。
返回 (success: bool, error: str)
"""
try:
video_id = int(video_id)
except (TypeError, ValueError):
return False, "video_id 必须是数字"
current_name = (current_name or '').strip()
new_name = (new_name or '').strip()
if not current_name or not new_name:
return False, "缺少 current_name / new_name"
try:
resp = requests.post(
f"{self.base_url}/api/oracle/identity/correct",
json={"video_id": video_id, "current_name": current_name,
"new_name": new_name, "token": self.token},
timeout=(10, 30))
except requests.RequestException as e:
logger.error(f"人物纠错回推失败: {e}")
return False, str(e)
if resp.status_code == 200:
return True, ""
msg = f"HTTP {resp.status_code}: {resp.text[:200]}"
logger.error(f"人物纠错回推失败: {msg}")
return False, msg
def push_video_delete(self, video_id: int):
"""回推事件时间轴"删除该视频"到 Oracle。返回 (success: bool, error: str)。"""
try:
video_id = int(video_id)
except (TypeError, ValueError):
return False, "video_id 必须是数字"
try:
resp = requests.post(
f"{self.base_url}/api/oracle/video/delete",
json={"video_id": video_id, "token": self.token},
timeout=(10, 30))
except requests.RequestException as e:
logger.error(f"视频删除回推失败: {e}")
return False, str(e)
if resp.status_code == 200:
return True, ""
msg = f"HTTP {resp.status_code}: {resp.text[:200]}"
logger.error(f"视频删除回推失败: {msg}")
return False, msg
# ------------------------------------------------------------------
def _run(self):
logger.info(f"OracleSync 线程启动,间隔 {self.interval_sec}s目标 {self.base_url}")
while self._running:
try:
self._pull_once()
except Exception as e:
self._last_error = str(e)
logger.error(f"同步异常: {e}", exc_info=True)
# 分段休眠,便于 stop 快速唤醒
for _ in range(self.interval_sec):
if not self._running:
break
time.sleep(1)
def start(self):
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True, name='oracle-sync')
self._thread.start()
def is_alive(self):
return self._thread is not None and self._thread.is_alive()
def stop(self):
self._running = False
if self._thread:
self._thread.join(timeout=5)
def trigger_now(self) -> bool:
"""立即触发一次同步(命名后即时拉回 / 手动)。"""
return self._pull_once()
def status(self) -> dict:
return {
"running": self.is_alive(),
"last_sync_at": self._last_sync_at.isoformat() if self._last_sync_at else None,
"last_error": self._last_error,
"last_count": self._last_count,
"cursor": db_layer.get_sync_cursor(),
"interval_sec": self.interval_sec,
}

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

@@ -7,9 +7,8 @@ UI-API - Vue 前端数据接口(新架构 v3Vue SPA 取代 Streamlit
<id>`(删除视频会话),因为语义上属于 videos 这个资源,比塞进 member_manager.py
更清晰;写法沿用项目里"先回推 Oracle成功后处理本地状态"的既有模式。
/api/ui/service-status 需要代理 Oracle 的 /api/oracle/activity浏览器不直连
Oracle避免 token 暴露),写法照抄 img_proxy.py 的模式:复用 oracle_sync.get_sync()
已解析好的 base_url/token不再单独存一份配置。
/api/ui/service-status 代理同机 fam-edge 的 /api/oracle/activity浏览器不直连
fam-edge避免 token 暴露),走 edge_client 统一读 base_url/token。
"""
import re
from datetime import datetime
@@ -19,7 +18,7 @@ from flask import Blueprint, request, jsonify
from .logger import setup_logger
from . import db_layer
from .oracle_sync import get_sync
from . import edge_client
logger = setup_logger('fam-core.ui_api')
@@ -50,7 +49,7 @@ def videos():
date_filter = request.args.get('date') or None
page = max(0, request.args.get('page', 0, type=int))
page_size = 15
rows = db_layer.get_sync_videos(limit=page_size, offset=page * page_size,
rows = db_layer.get_videos(limit=page_size, offset=page * page_size,
date_filter=date_filter)
return jsonify({"videos": _ser(rows), "page": page, "page_size": page_size}), 200
@@ -58,10 +57,10 @@ def videos():
@ui_bp.route('/api/ui/videos/<int:video_id>', methods=['GET'])
def video_detail(video_id):
"""单个视频会话详情 + 时间线事件列表(事件时间轴右侧)。"""
video = db_layer.get_sync_video(video_id)
video = db_layer.get_video(video_id)
if not video:
return jsonify({"error": "视频不存在"}), 404
events = db_layer.get_sync_events_for_video(video_id)
events = db_layer.get_events_for_video(video_id)
return jsonify({"video": _ser(video), "events": _ser(events)}), 200
@@ -69,16 +68,13 @@ def video_detail(video_id):
def video_delete(video_id):
"""删除视频会话(事件时间轴"删除"入口)。
先回推 Oracle 物理删除events + videos 行 + 磁盘文件),成功后再清理本地
MariaDB 镜像——增量同步get_sync_delta只做 upsert 感知不到删除,不能像
命名纠错那样靠 trigger_now() 拉增量顺带清理,必须显式调 delete_sync_video
Oracle 回推失败时不清理本地镜像,避免"Oracle 还留着、NAS 却以为删了"
数据不一致,让用户看错误提示后重试。
交给 fam-edge 处理:它删 events + videos 行,还要删磁盘上的运动片段文件。
迁云前这里还要再清一次 NAS 本地镜像(增量同步只 upsert感知不到删除
现在没有镜像了fam-edge 删完就是最终状态
"""
ok, err = get_sync().push_video_delete(video_id)
ok, err = edge_client.push_video_delete(video_id)
if not ok:
return jsonify({"error": f"删除失败: {err}"}), 502
db_layer.delete_sync_video(video_id)
return jsonify({"status": "ok", "video_id": video_id}), 200
@@ -86,7 +82,7 @@ def video_delete(video_id):
def stats():
"""统计卡:视频/事件/人物/关注数(可选按日期过滤)。"""
date_filter = request.args.get('date') or None
return jsonify(_ser(db_layer.get_sync_stats(date_filter))), 200
return jsonify(_ser(db_layer.get_stats(date_filter))), 200
def _clean_person(s: str) -> str:
@@ -100,7 +96,7 @@ def people():
对应旧 Streamlit 版本 app.py 里的分组逻辑,原样搬到服务端。
"""
rows = db_layer.get_sync_people()
rows = db_layer.get_people()
groups = {}
for p in rows:
key = p.get('canonical_name') or p['label']
@@ -128,7 +124,7 @@ def people():
def people_clips():
"""某人物出现过的运动片段列表(人物卡「运动片段」区块)。
按 label/canonical_name 匹配 sync_events.person_list_json → 关联视频
按 label/canonical_name 匹配 events.person_list_json → 关联视频
(运动片段优先)。返回片段 video_id/filename/event_start_time/duration/
summary/camera_name + first_ts缩略图定位+ clip_events片段内事件数
"""
@@ -136,7 +132,7 @@ def people_clips():
if not label:
return jsonify({"error": "缺少 label 参数"}), 400
limit = min(20, request.args.get('limit', 10, type=int) or 10)
clips = db_layer.get_sync_people_clips(label, limit)
clips = db_layer.get_people_clips(label, limit)
return jsonify({"label": label, "clips": _ser(clips)}), 200
@@ -163,41 +159,25 @@ def attention_events():
@ui_bp.route('/api/ui/named-members', methods=['GET'])
def named_members():
"""已命名成员真名列表AI 对话页快捷选择)。"""
return jsonify({"members": db_layer.get_sync_named_members()}), 200
return jsonify({"members": db_layer.get_named_members()}), 200
@ui_bp.route('/api/ui/model-stats', methods=['GET'])
def model_stats():
"""云端模型调用统计:按模型聚合 + 最近调用明细。"""
agg = db_layer.get_sync_model_calls_stats()
calls = db_layer.get_sync_model_calls(limit=100)
agg = db_layer.get_model_calls_stats()
calls = db_layer.get_model_calls(limit=100)
return jsonify({"aggregate": _ser(agg), "recent_calls": _ser(calls)}), 200
@ui_bp.route('/api/ui/service-status', methods=['GET'])
def service_status():
"""服务状态页:NAS 同步状态 + Oracle 实时活动代理token 不下发浏览器)。"""
sync = get_sync()
nas_status = sync.status()
"""服务状态页:fam-edge 的队列/分割/模型活动代理token 不下发浏览器)。
oracle_data = None
oracle_error = None
if sync.base_url and sync.token:
import requests
try:
r = requests.get(f"{sync.base_url}/api/oracle/activity",
params={'token': sync.token}, timeout=15)
if r.status_code == 200:
oracle_data = r.json()
else:
oracle_error = f"Oracle activity HTTP {r.status_code}"
except Exception as e:
oracle_error = f"连接 Oracle 失败: {e}"
else:
oracle_error = "未配置 oracle_sync.base_url/token"
return jsonify({
"nas_sync": nas_status,
"oracle": oracle_data,
"oracle_error": oracle_error,
}), 200
迁云前这里还有一块 "NAS 同步状态"(镜像拉取的游标/周期/上次条数),随镜像层
一起删了——现在前端读的就是 fam-edge 写的那份库,没有"同步"这个中间状态。
NAS 侧只剩运动事件推送,它的心跳在 fam-edge 的 service_activity 里,
已经包含在 activity 快照中。
"""
data, err = edge_client.get_activity()
return jsonify({"oracle": data, "oracle_error": err}), 200

View File

@@ -1,106 +0,0 @@
import os
import time
import pytest
from fam_core import auth
@pytest.fixture(autouse=True)
def _clean_env_and_sessions(monkeypatch):
"""每个用例前清掉环境变量和进程内 session 表,用例之间不互相污染。"""
monkeypatch.delenv('FAM_AUTH_USER', raising=False)
monkeypatch.delenv('FAM_AUTH_PASS', raising=False)
auth._sessions.clear()
auth._warned_unconfigured = False
yield
auth._sessions.clear()
def test_check_credential_fails_closed_when_unconfigured():
"""核心诉求: .env 没配置 FAM_AUTH_USER/FAM_AUTH_PASS 时必须拒绝所有登录,
不能退回任何硬编码默认账号密码(这两个变量跟 NAS SSH 密码是同一个值,公网
入口不能有"没配置就用已知密码兜底"这种行为)。"""
assert auth._check_credential('ericwyuan', 'iLoveJava5') is False
assert auth._check_credential('anything', 'anything') is False
assert auth._check_credential('', '') is False
def test_check_credential_succeeds_with_matching_configured_values(monkeypatch):
monkeypatch.setenv('FAM_AUTH_USER', 'testuser')
monkeypatch.setenv('FAM_AUTH_PASS', 'testpass')
assert auth._check_credential('testuser', 'testpass') is True
def test_check_credential_rejects_wrong_password_when_configured(monkeypatch):
monkeypatch.setenv('FAM_AUTH_USER', 'testuser')
monkeypatch.setenv('FAM_AUTH_PASS', 'testpass')
assert auth._check_credential('testuser', 'wrongpass') is False
assert auth._check_credential('wronguser', 'testpass') is False
def test_check_credential_fails_closed_when_only_one_var_set(monkeypatch):
"""只配了一半(比如账号忘配密码)也要 fail closed不能退化成"密码随便""""
monkeypatch.setenv('FAM_AUTH_USER', 'testuser')
assert auth._check_credential('testuser', '') is False
assert auth._check_credential('testuser', 'anything') is False
def test_session_ttl_is_two_hours():
assert auth._SESSION_TTL == 2 * 3600
def test_is_authed_true_within_ttl():
tok = 'sometoken'
auth._sessions[tok] = time.time() + 3600
class _FakeRequest:
cookies = {'fam_session': tok}
monkeypatch_request = auth.request
try:
auth.request = _FakeRequest()
assert auth.is_authed() is True
finally:
auth.request = monkeypatch_request
def test_is_authed_false_after_expiry():
tok = 'expiredtoken'
auth._sessions[tok] = time.time() - 1 # 已过期
class _FakeRequest:
cookies = {'fam_session': tok}
monkeypatch_request = auth.request
try:
auth.request = _FakeRequest()
assert auth.is_authed() is False
# 过期后应该从 session 表里清掉,不留垃圾
assert tok not in auth._sessions
finally:
auth.request = monkeypatch_request
def test_is_authed_false_without_cookie():
class _FakeRequest:
cookies = {}
monkeypatch_request = auth.request
try:
auth.request = _FakeRequest()
assert auth.is_authed() is False
finally:
auth.request = monkeypatch_request
def test_whitelist_exact_paths():
for path in ('/login', '/api/login', '/api/logout', '/api/auth/check',
'/health', '/favicon.ico', '/api/ss/webhook'):
assert auth._is_whitelisted(path) is True
def test_whitelist_assets_prefix():
assert auth._is_whitelisted('/assets/index-abc123.js') is True
def test_whitelist_rejects_protected_paths():
for path in ('/', '/api/ui/people', '/api/chat/ask', '/api/ss/status'):
assert auth._is_whitelisted(path) is False

View File

@@ -0,0 +1,155 @@
"""db_layer 直读 SQLite 的单测2026-09-13 迁云重写后新增)。
重点验证从 MySQL 翻译过来的几处方言json_each 取代 JSON_CONTAINS、
substr 取代 LEFT、以及历史脏数据person_list_json 不是合法 JSON不能
把整条查询搞崩——这在 MySQL 下 JSON_CONTAINS 会直接报错SQLite 下
json_each 同样会,所以查询里加了 json_valid 前置判断。
"""
import sqlite3
import pytest
from fam_core import db_layer
# fam-edge 建表语句的最小子集(列名与 oracle_db.py 保持一致)
_SCHEMA = """
CREATE TABLE videos (
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE, camera_name TEXT,
duration_sec REAL, event_start_time TEXT, status TEXT DEFAULT 'pending',
summary_json TEXT, events_json TEXT, people_json TEXT, compute_provider TEXT,
created_at TEXT, updated_at TEXT, processed_at TEXT);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER, ts TEXT, description TEXT,
person_list_json TEXT, person_appearances_json TEXT, is_attention_event INTEGER DEFAULT 0);
CREATE TABLE people (
id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT UNIQUE, canonical_name TEXT,
first_seen TEXT, appearances INTEGER DEFAULT 0, source TEXT, features_json TEXT,
display_uid TEXT, updated_at TEXT);
CREATE TABLE model_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT, model TEXT, video_id INTEGER,
filename TEXT, started_at TEXT, duration_sec REAL, success INTEGER, error TEXT,
created_at TEXT);
"""
@pytest.fixture
def db(tmp_path, monkeypatch):
path = tmp_path / "oracle.db"
conn = sqlite3.connect(path)
conn.executescript(_SCHEMA)
conn.executescript("""
INSERT INTO videos (id, filename, camera_name, event_start_time, status, processed_at)
VALUES (1, 'motion_20260913_080000.mp4', '客厅', '2026-09-13 08:00:00', 'done', '2026-09-13 08:10:00'),
(2, 'whole_20260912_090000.mp4', '客厅', '2026-09-12 09:00:00', 'done', '2026-09-12 09:10:00'),
(3, 'whole_20260911_090000.mp4', '客厅', '2026-09-11 09:00:00', 'done', '2026-09-11 09:10:00'),
(4, 'motion_20260910_070000.mp4', '客厅', '2026-09-10 07:00:00', 'pending', NULL);
-- video 2 有事件应展示video 3 没有事件且不是 motion_应被过滤掉
INSERT INTO events (video_id, ts, description, person_list_json, is_attention_event)
VALUES (1, '00:00:05', '汤圆在客厅玩耍', '["汤圆"]', 0),
(1, '00:00:20', '有人靠近门口', '["人物B"]', 1),
(2, '00:01:00', '汤圆和奶奶', '["汤圆","奶奶"]', 0),
(2, '00:02:00', '脏数据事件', '不是合法JSON', 0);
INSERT INTO people (id, label, canonical_name, appearances, source)
VALUES (1, '人物A', '汤圆', 12, 'manual'),
(2, '人物B', NULL, 3, 'llm'),
(3, '汤圆', '汤圆', 5, 'manual');
INSERT INTO model_calls (provider, model, success, duration_sec, created_at)
VALUES ('gemini', 'flash', 1, 10.0, '2026-09-13 08:10:00'),
('gemini', 'flash', 0, 20.0, '2026-09-13 08:20:00'),
('nvidia', 'vila', 1, 5.0, '2026-09-12 08:10:00');
""")
conn.commit()
conn.close()
monkeypatch.setattr(db_layer, '_db_path', str(path))
monkeypatch.setattr(db_layer, '_chat_schema_ready', False)
return path
# ---------------------------------------------------------------- videos
def test_get_videos_only_returns_content_sessions(db):
"""只展示运动片段或含事件的会话:分割 0 段的整段素材不该淹没时间轴。"""
ids = [v['id'] for v in db_layer.get_videos()]
assert ids == [1, 2] # 3 无事件且非 motion_4 未完成
assert db_layer.get_videos()[0]['event_count'] == 2
def test_get_videos_date_filter_and_paging(db):
assert [v['id'] for v in db_layer.get_videos(date_filter='2026-09-12')] == [2]
assert [v['id'] for v in db_layer.get_videos(limit=1, offset=1)] == [2]
def test_get_video_and_events(db):
assert db_layer.get_video(1)['filename'].startswith('motion_')
assert db_layer.get_video(999) is None
evs = db_layer.get_events_for_video(1)
assert [e['ts'] for e in evs] == ['00:00:05', '00:00:20']
def test_get_attention_events(db):
rows = db_layer.get_attention_events()
assert len(rows) == 1 and rows[0]['ev_date'].startswith('2026-09-13')
# ---------------------------------------------------------------- 人物命中JSON
def test_people_clips_matches_label_and_its_aliases(db):
"""'人物A' 的规范名是"汤圆",而事件里记的是"汤圆"——要能顺着别名找到。"""
clips = db_layer.get_people_clips('人物A')
assert {c['video_id'] for c in clips} == {1, 2}
assert clips[0]['video_id'] == 1 # 按录制时间倒序
assert clips[0]['first_ts'] == '00:00:05'
assert clips[0]['clip_events'] == 1
def test_person_hit_is_exact_not_substring(db):
"""json_each 是精确匹配:查"人物"不该命中"人物B""""
assert db_layer.get_people_clips('人物') == []
assert {c['video_id'] for c in db_layer.get_people_clips('人物B')} == {1}
def test_malformed_person_json_does_not_break_queries(db):
"""video 2 里混了一条非 JSON 的脏数据,查询必须照常返回而不是整条报错。"""
rows = db_layer.query_events_for_person_date('汤圆', '2026-09-12')
assert len(rows) == 1 and rows[0]['ts'] == '00:01:00'
def test_query_events_for_person_date(db):
assert db_layer.query_events_for_person_date('汤圆', '2026-09-13')[0]['description'] == '汤圆在客厅玩耍'
assert db_layer.query_events_for_person_date('汤圆', '2026-09-01') == []
# ---------------------------------------------------------------- people / model_calls
def test_people_and_named_members(db):
assert len(db_layer.get_people()) == 3
assert db_layer.get_named_members() == ['汤圆'] # distinct 且非空
ctx = db_layer.get_known_members_context()
assert '- 汤圆标识人物A' in ctx and '- 人物B' in ctx
def test_model_calls_stats(db):
stats = {s['model']: s for s in db_layer.get_model_calls_stats()}
assert stats['flash']['ok_cnt'] == 1 and stats['flash']['fail_cnt'] == 1
assert stats['flash']['avg_duration'] == 15.0
assert len(db_layer.get_model_calls(limit=2)) == 2
# ---------------------------------------------------------------- 统计
def test_stats_全量与按日(db):
total = db_layer.get_stats()
assert total['videos'] == 2 and total['events'] == 4 and total['attention'] == 1
day = db_layer.get_stats('2026-09-13')
assert day['videos'] == 1 and day['events'] == 2
# 人物数:汤圆(含 label 人物A/汤圆两行都归一到"汤圆"+ 人物B
assert total['people'] >= 2
# ---------------------------------------------------------------- chat_history
def test_chat_history_roundtrip_creates_table_on_demand(db):
"""chat_history 是本模块唯一写的表,建表是懒加载的(库文件属于 fam-edge"""
chat_id = db_layer.insert_chat_history('今天有人来吗', '08:00 有人靠近门口',
'上下文', '2026-09-13', '汤圆')
assert chat_id == 1
rows = db_layer.get_chat_history(limit=10)
assert rows[0]['user_question'] == '今天有人来吗'
assert rows[0]['created_at']
assert db_layer.get_chat_history(person_filter='不存在的人') == []
assert len(db_layer.get_chat_history(date_filter='2026-09-13')) == 1

View File

@@ -71,6 +71,19 @@ motion_segment:
min_duration_sec: 1 # 短于该时长的事件不分割
unfinished_grace_sec: 10 # start_time+duration 距当前 ≤ 该秒视为"已结束"容差
# 磁盘空间守护2026-08-28 新增Oracle 磁盘曾经被写满,触发 rclone 遇到
# IO 错误就拒绝删除的保护机制,形成"越满越删不掉"的死循环,导致新视频下载
# 和运动片段分割全部失败。这里加一道独立于 rclone 同步之外的兜底:不管上游
# 同步是否正常,剩余空间跌破 min_free_gb 就主动清理最旧的、已完成分割阶段
# 的整段素材gdrive_videos 里的原始录像,不碰 motion_clips 里的运动片段)。
disk_guard:
enabled: true
check_interval_sec: 300 # 5 分钟检查一次
min_free_gb: 10 # 剩余空间低于此值触发清理
target_free_gb: 15 # 清理到这个水位就停(留缓冲,避免刚清完又立刻再触发)
watch_path: "/opt/fam-edge" # 检查这个路径所在磁盘分区的剩余空间
max_delete_per_round: 50 # 单轮最多清理几个文件,防止候选异常多时一次删太多
# 闭集人物识别2026-08-22 新增,家里固定 4 人:爷爷/爸爸/媳妇/汤圆):
# 原来靠大模型自己编的"人物A/B/C"临时 uid + 文字特征描述跨视频合并,验证下来
# 不可靠(用户原话"现在的识别全是错的")。人脸向量方案也验证过,家庭监控这种

View File

@@ -2,6 +2,9 @@ flask>=2.0.0
gunicorn>=20.0.0
requests>=2.28.0
PyYAML>=6.0
# 统一登录:验 auth-hub 的 id_token 签名RS256+ 签发会话 cookieHS256
PyJWT>=2.8.0
cryptography>=42.0.0
# google-generativeai 和 openai 为可选依赖(代码用 requests 直接调 REST API
# 如需 SDK 方式调用,取消注释并在 Python 3.9+ 环境安装:
# google-generativeai>=0.5.0

View File

@@ -261,10 +261,27 @@ def activity():
segment['clips_files'] = 0
# 事件↔片段一致性对账(数量可验证)
segment['consistency'] = db.get_segment_consistency()
# 磁盘空间实时用量 + DiskGuard 最近一次清理动作2026-08-28 新增)
disk_info = {"free_gb": None, "last_activity": _last_activity('disk_guard')}
try:
import shutil as _shutil
from ..config_loader import load_config as _load_config
watch_path = _load_config().get('disk_guard', {}).get('watch_path', '/opt/fam-edge')
disk_info["free_gb"] = round(_shutil.disk_usage(watch_path).free / (1024 ** 3), 1)
except Exception as e:
logger.warning(f"磁盘空间查询异常: {e}")
# NAS 推送链路2026-09-13 起 NAS 上只剩 fam-notifier 这一个服务,
# 服务状态页的「NAS 运动推送」卡片读这里;心跳由它定期空 POST 刷新)
motion = {
"heartbeat_age_sec": db.get_motion_heartbeat_age_sec(),
"recent": db.get_recent_motion_events(5),
}
return jsonify({
"queue": q_status,
"db": db.get_queue_status(),
"motion": motion,
"segment": segment,
"disk": disk_info,
"rclone": _last_activity('rclone'),
"person": _last_activity('person'),
"model_calls": [dict(m) for m in model_calls],

View File

@@ -5,6 +5,9 @@ FAM-Edge 主应用 - Flask 单进程(新架构 v2.1
- API-Gateway同步拉取 / 命名校正 / 智能问答)
- VideoQueue生产-消费队列:同步落地新视频入队,独立消费者云端分析,模型超时 ×2
- PersonService人物汇总合并定时
- DiskGuard磁盘空间守护定时检查剩余空间过低时清理最旧的已完成素材
- Auth统一登录/login + OIDC 回调 + 给 Caddy forward_auth 用的会话校验,
2026-09-12 从 NAS fam-core 迁入,原因见 auth.py 开头)
"""
import os
import sys
@@ -15,14 +18,17 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from .config_loader import load_config
from .logger import setup_logger
from .api_gateway.api_gateway import api_bp
from .auth import auth_bp
from . import state
from .video_queue import VideoQueue
from .person_service import PersonService
from .disk_guard import DiskGuard
logger = setup_logger('fam-edge.app')
app = Flask(__name__)
app.register_blueprint(api_bp)
app.register_blueprint(auth_bp)
@app.route('/', methods=['GET'])
@@ -31,9 +37,10 @@ def root():
"mode": "drive-sync + whole-video analysis (producer-consumer queue)"}), 200
# 启动生产-消费队列 + 人物服务
# 启动生产-消费队列 + 人物服务 + 磁盘空间守护
_queue = None
_person = None
_disk_guard = None
try:
db = state.get_db()
_queue = VideoQueue(db)
@@ -44,6 +51,9 @@ try:
_person = PersonService(db)
_person.start()
logger.info("PersonService 已启动")
_disk_guard = DiskGuard(db)
_disk_guard.start()
except Exception as e:
logger.error(f"后台服务启动失败: {e}", exc_info=True)

View File

@@ -0,0 +1,285 @@
"""
Auth - 摄像头系统统一登录2026-09-12 从 NAS fam-core 迁到甲骨文 fam-edge
背景:登录流程原先跑在 NAS 的 fam-core 里NAS 一挂或 frp 隧道一断,
`smart-camera.zichuan.xyz/login` 直接 502——连登录页都进不去。登录是入口
不该依赖家里那台机器,所以整体搬到甲骨文:前端静态文件和 auth-hub 本来就在这台,
搬完之后 NAS 只剩纯数据接口。
跟旧实现fam-core/auth.py已删除的三处关键差异
1. **服务端调用 auth-hub 走本机**:换 token 和拉 JWKS 用 `AUTH_HUB_INTERNAL_BASE`
(默认 http://127.0.0.1:5300不再走公网 TLS。旧链路是 NAS 跨公网访问
https://auth.zichuan.xyz踩过两个坑`jwt.PyJWKClient` 用 urllib + 系统 CA
(不像 requests 自带 certifi群晖上很容易 CERTIFICATE_VERIFY_FAILED
以及两台机器时钟偏差会让 id_token 的 iat 看起来"来自未来"
但 `iss` 校验和浏览器跳转仍然用公网 `AUTH_HUB_ISSUER`——id_token 里的 iss
是 auth-hub 自己配的公网地址,浏览器也只能跳公网地址。
2. **登录态改成无状态签名 cookie**HS256 签发,密钥 `FAM_SESSION_SECRET`。
旧实现是进程内 token 表服务一重启所有人被踢下线fam-edge 是视频分析进程,
重启比 fam-core 更频繁,进程内会话在这里完全不成立。
3. **回调失败渲染错误页,不再 302 回 /login**:旧实现每个失败分支都跳 /login
而 auth-hub 侧只要还有会话,/authorize 会立刻再签发一个 code 跳回来,
两边对跳成死循环——浏览器只报"重定向次数过多",既看不到登录页也看不到原因。
拦截不在本模块做Caddy 用 forward_auth 打到 `/api/auth/verify`
校验通过才把 `/api/*` 反代到 NAS 的 fam-core。
"""
import os
import secrets
import time
from base64 import urlsafe_b64encode
from hashlib import sha256
from urllib.parse import urlencode
import jwt
import requests
from flask import Blueprint, Response, jsonify, make_response, redirect, request
# 导入即加载 /opt/fam-edge/.envconfig_loader 模块级 _load_env_file
# 下面所有 os.environ.get 才读得到 AUTH_HUB_* / FAM_SESSION_SECRET。
from . import config_loader # noqa: F401
from .logger import setup_logger
logger = setup_logger('fam-edge.auth')
auth_bp = Blueprint('auth', __name__)
COOKIE_NAME = 'fam_session'
_SESSION_TTL = 2 * 3600 # 登录态有效期 2 小时
_PENDING_TTL = 10 * 60 # /login -> /api/auth/callback 之间的等待上限
_LEEWAY = 60 # 验 id_token 时容忍的跨机器时钟偏差(秒)
_pending = {} # state -> {verifier, expires}进程内PKCE 用)
_warned_unconfigured = False # 只在第一次拒绝登录时打一条警告,别刷屏
_jwks_client = None # jwt.PyJWKClient 单例,内建 JWKS 缓存
def _config():
"""接入参数全部来自环境变量(/opt/fam-edge/.env"""
issuer = os.environ.get('AUTH_HUB_ISSUER', '').rstrip('/')
return {
'issuer': issuer,
# 服务端直连 auth-hub 的地址;没配就退回公网 issuer本地开发用
'internal_base': (os.environ.get('AUTH_HUB_INTERNAL_BASE', '') or issuer).rstrip('/'),
'client_id': os.environ.get('AUTH_HUB_CLIENT_ID', ''),
'client_secret': os.environ.get('AUTH_HUB_CLIENT_SECRET', ''),
'redirect_uri': os.environ.get('AUTH_HUB_REDIRECT_URI', ''),
'session_secret': os.environ.get('FAM_SESSION_SECRET', ''),
}
def _require_config():
"""五个变量缺任何一个都拒绝登录fail closed。返回配置 dict 或 None。
本服务监听 0.0.0.0,没有"配置不全就退回某种默认放行"这种兜底。
"""
global _warned_unconfigured
cfg = _config()
if not all([cfg['issuer'], cfg['client_id'], cfg['client_secret'],
cfg['redirect_uri'], cfg['session_secret']]):
if not _warned_unconfigured:
logger.error(
"AUTH_HUB_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI 或 "
"FAM_SESSION_SECRET 未配置齐全,拒绝所有登录——"
"请在 /opt/fam-edge/.env 里补齐后 systemctl restart fam-edge")
_warned_unconfigured = True
return None
return cfg
def _get_jwks_client(jwks_uri):
global _jwks_client
if _jwks_client is None or _jwks_client.uri != jwks_uri:
_jwks_client = jwt.PyJWKClient(jwks_uri)
return _jwks_client
def _cleanup_pending():
now = time.time()
for state in [s for s, v in _pending.items() if v['expires'] < now]:
_pending.pop(state, None)
def _is_https():
"""Caddy 终止 TLS 后回源是明文 HTTPrequest.is_secure 恒为 False
所以 cookie 的 Secure 标志要看 X-Forwarded-Proto。"""
return request.headers.get('X-Forwarded-Proto', '') == 'https' or request.is_secure
# ---------------------------------------------------------------------------
# 会话(无状态签名 cookie
# ---------------------------------------------------------------------------
def _issue_session(secret, sub, username):
now = int(time.time())
return jwt.encode({'sub': sub, 'username': username,
'iat': now, 'exp': now + _SESSION_TTL},
secret, algorithm='HS256')
def current_user():
"""返回 cookie 里的用户信息dict未登录/过期/签名不对返回 None。"""
cfg = _config()
if not cfg['session_secret']:
return None
token = request.cookies.get(COOKIE_NAME, '')
if not token:
return None
try:
# 这里刻意不给 leewaycookie 是本进程自签自验的,没有跨机器时钟偏差问题,
# 加了只会让每个会话白白多活 60 秒。_LEEWAY 只用于 auth-hub 签发的 id_token。
return jwt.decode(token, cfg['session_secret'], algorithms=['HS256'])
except jwt.PyJWTError:
return None
def is_authed():
return current_user() is not None
def _error_page(reason, status=502):
"""回调失败时给一个看得懂的页面。
这里刻意不做自动跳转auth-hub 有会话时会立刻再发一个 code 回来,
自动跳转等于把用户关进死循环。让用户自己点"重新登录",最多再失败一次。
"""
html = (
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
'<title>登录失败</title></head>'
'<body style="font-family:system-ui,-apple-system,sans-serif;background:#0b0e14;'
'color:#e6ebf2;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0">'
'<div style="max-width:34rem;padding:2rem">'
'<h1 style="font-size:1.25rem;margin:0 0 .75rem">登录没能完成</h1>'
'<p style="color:#9aa7b8;line-height:1.7;margin:0 0 1.5rem">原因:{reason}</p>'
'<a href="/login" style="display:inline-block;background:#5b8cff;color:#fff;'
'text-decoration:none;padding:.6rem 1.2rem;border-radius:.6rem">重新登录</a>'
'</div></body></html>'
).format(reason=reason)
return Response(html, status=status, mimetype='text/html; charset=utf-8')
# ---------------------------------------------------------------------------
# 端点
# ---------------------------------------------------------------------------
@auth_bp.route('/login', methods=['GET'])
def login():
"""不渲染登录表单,直接跳 auth-hub 走 Authorization Code + PKCE。"""
if is_authed():
return redirect('/')
cfg = _require_config()
if not cfg:
return _error_page('本服务的登录参数没配齐(管理员看 fam-edge 日志)', 503)
_cleanup_pending()
verifier = secrets.token_urlsafe(48)
challenge = urlsafe_b64encode(
sha256(verifier.encode('ascii')).digest()).rstrip(b'=').decode('ascii')
state = secrets.token_hex(24)
_pending[state] = {'verifier': verifier, 'expires': time.time() + _PENDING_TTL}
params = {
'response_type': 'code',
'client_id': cfg['client_id'],
'redirect_uri': cfg['redirect_uri'],
'scope': 'openid profile',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
}
# 浏览器要跳的是公网 issuer不是 internal_base
return redirect("{}/authorize?{}".format(cfg['issuer'], urlencode(params)))
@auth_bp.route('/api/auth/callback', methods=['GET'])
def callback():
cfg = _require_config()
if not cfg:
return _error_page('本服务的登录参数没配齐(管理员看 fam-edge 日志)', 503)
if request.args.get('error'):
logger.warning("auth-hub 拒绝授权: {}".format(request.args.get('error')))
return _error_page('auth-hub 拒绝了这次授权:{}'.format(request.args.get('error')), 403)
state = request.args.get('state', '')
code = request.args.get('code', '')
pending = _pending.pop(state, None)
if not pending or pending['expires'] < time.time() or not code:
logger.warning("回调 state 缺失/过期/重放,拒绝")
return _error_page('这次登录请求已过期或被重复使用,请重新登录', 400)
try:
resp = requests.post(
"{}/token".format(cfg['internal_base']),
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': cfg['redirect_uri'],
'client_id': cfg['client_id'],
'client_secret': cfg['client_secret'],
'code_verifier': pending['verifier'],
}, timeout=(5, 15))
except requests.RequestException as e:
logger.error("auth-hub /token 请求失败: {}".format(e))
return _error_page('连不上 auth-hub 的 /token{}'.format(e))
if resp.status_code != 200:
logger.warning("auth-hub /token 拒绝: {} {}".format(resp.status_code, resp.text[:200]))
return _error_page('auth-hub 拒绝换发 tokenHTTP {}'.format(resp.status_code))
id_token = (resp.json() or {}).get('id_token', '')
try:
jwks_client = _get_jwks_client(
"{}/.well-known/jwks.json".format(cfg['internal_base']))
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
claims = jwt.decode(id_token, signing_key.key, algorithms=['RS256'],
audience=cfg['client_id'], issuer=cfg['issuer'],
leeway=_LEEWAY)
except jwt.PyJWTError as e:
logger.warning("id_token 验签/校验失败: {}".format(e))
return _error_page('id_token 校验失败({}'.format(e))
username = claims.get('preferred_username', '')
token = _issue_session(cfg['session_secret'], claims.get('sub', ''), username)
resp2 = make_response(redirect('/'))
resp2.set_cookie(COOKIE_NAME, token, max_age=_SESSION_TTL, httponly=True,
samesite='Lax', secure=_is_https(), path='/')
logger.info("登录成功 username={}".format(username))
return resp2
@auth_bp.route('/api/logout', methods=['POST'])
def logout():
"""只清本地 cookie不动 auth-hub 上的登录态(那是全站 SSO 会话)。
cookie 是无状态签名的,服务端没法单独作废某一张——真要立刻全员下线,
换掉 FAM_SESSION_SECRET 重启即可。
"""
resp = make_response(jsonify({"ok": True}))
resp.delete_cookie(COOKIE_NAME, path='/')
return resp
@auth_bp.route('/api/auth/check', methods=['GET'])
def check():
user = current_user()
return jsonify({"authed": user is not None,
"username": (user or {}).get('username', '')})
@auth_bp.route('/api/auth/verify', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
def verify():
"""给 Caddy forward_auth 用2xx 放行401 拦截。
Caddy 会把原始请求的 cookie 一起带过来401 的响应体会原样回给浏览器,
前端 api.js 看到 401 就会自动跳 /login。
"""
user = current_user()
if not user:
return jsonify({"error": "未登录", "code": 401}), 401
resp = make_response('', 204)
resp.headers['X-Auth-User'] = user.get('username', '')
return resp

View File

@@ -0,0 +1,114 @@
"""
DiskGuard - 磁盘空间守护2026-08-28 新增)
背景Oracle 机器磁盘曾经被写满gdrive_videos 持续下载新素材、旧文件迟迟
没被清理),触发了 rclone 的一个安全机制——同步过程中一旦遇到 IO 错误(写
不进去)就整体拒绝执行删除,导致"越满越删不掉,越删不掉越满"的死循环,
最终连新视频都下载不了。这个模块是一道独立于 rclone 同步逻辑之外的兜底:
不管上游同步是否正常,只要本机磁盘剩余空间跌破阈值,就主动清理最旧的、已
经处理完成的整段素材文件,把空间抢回来。
清理对象:仅限"整段素材"gdrive_videos 里落地的原始录像文件filename
不是 motion_ 前缀)且 status='done'(已完成分割阶段,不会再被 Video-Queue
重新捡起——不碰运动片段motion_clips 里的文件,是独立的分析产物,事件
时间轴展示、人物头像裁剪都依赖它,删了会导致图片丢失)、不碰还在
pending/processing 中的素材(避免删掉还没来得及处理的数据)。
"""
import shutil
import threading
import time
import os
from .logger import setup_logger
from .config_loader import load_config
logger = setup_logger('fam-edge.disk_guard')
class DiskGuard:
def __init__(self, db):
self.db = db
cfg = load_config().get('disk_guard', {})
self.enabled = bool(cfg.get('enabled', True))
self.check_interval_sec = int(cfg.get('check_interval_sec', 300))
self.min_free_gb = float(cfg.get('min_free_gb', 10))
# 清理到这个水位就停:留出缓冲,避免刚清理完又立刻因为新文件写入
# 跌破阈值、频繁触发清理循环
self.target_free_gb = float(cfg.get('target_free_gb', 15))
self.watch_path = cfg.get('watch_path', '/opt/fam-edge')
# 单轮最多清理几个文件:防止候选异常多时一次性删太多,先清一部分
# 观察效果下一轮检查再继续check_interval_sec 后很快就会再跑一次)
self.max_delete_per_round = int(cfg.get('max_delete_per_round', 50))
self._running = False
self._thread = None
def start(self):
if not self.enabled:
logger.info("DiskGuard 未启用")
return
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True, name='disk-guard')
self._thread.start()
logger.info(
f"DiskGuard 已启动(阈值 {self.min_free_gb}GB"
f"目标水位 {self.target_free_gb}GB检查间隔 {self.check_interval_sec}s")
def stop(self):
self._running = False
if self._thread:
self._thread.join(timeout=5)
def is_alive(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def _free_gb(self) -> float:
return shutil.disk_usage(self.watch_path).free / (1024 ** 3)
def _run(self):
while self._running:
try:
self.check_once()
except Exception as e:
logger.error(f"DiskGuard 检查异常: {e}", exc_info=True)
for _ in range(self.check_interval_sec):
if not self._running:
return
time.sleep(1)
def check_once(self):
"""检查一次磁盘空间,不足则清理最旧的已完成素材直到恢复到目标水位。
供后台循环调用,也可单独调用做一次性检查(比如手动触发/测试)。
"""
free_gb = self._free_gb()
if free_gb >= self.min_free_gb:
return
logger.warning(f"磁盘剩余 {free_gb:.1f}GB 低于阈值 {self.min_free_gb}GB开始清理旧素材")
self.db.record_activity(
'disk_guard', 'low_space',
f"剩余 {free_gb:.1f}GB 低于阈值 {self.min_free_gb}GB开始清理")
deleted = 0
freed_bytes = 0
while deleted < self.max_delete_per_round and self._free_gb() < self.target_free_gb:
candidate = self.db.get_oldest_purgeable_material()
if not candidate:
logger.warning("磁盘空间仍然紧张,但已经没有可清理的素材了")
self.db.record_activity(
'disk_guard', 'no_candidate',
f"剩余 {self._free_gb():.1f}GB 仍不足,且没有可清理的素材")
break
video_id, local_path = candidate['id'], candidate.get('local_path')
size = os.path.getsize(local_path) if local_path and os.path.isfile(local_path) else 0
self.db.delete_video(video_id)
deleted += 1
freed_bytes += size
logger.info(f"DiskGuard 清理素材 video_id={video_id}(约 {size/1024/1024:.0f}MB")
if deleted:
free_gb = self._free_gb()
self.db.record_activity(
'disk_guard', 'cleaned',
f"清理 {deleted} 个素材,释放约 {freed_bytes/1024**3:.1f}GB"
f"当前剩余 {free_gb:.1f}GB")
logger.info(f"DiskGuard 本轮清理完成:{deleted} 个文件,当前剩余 {free_gb:.1f}GB")

View File

@@ -33,13 +33,42 @@ class OracleDB:
def __init__(self, db_path: str):
os.makedirs(os.path.dirname(db_path), exist_ok=True)
self.db_path = db_path
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA busy_timeout=10000")
self._local = threading.local() # 每线程一条连接,见下面的 _conn
self._all_conns = [] # 仅供 close() 收尾
self._conns_lock = threading.Lock()
self._write_lock = threading.Lock() # 复合写(如 DELETE+INSERT+commit串行化
self._init_schema()
@property
def _conn(self) -> sqlite3.Connection:
"""当前线程的连接2026-09-13 从"全进程共用一条"改成每线程一条)。
原来是 __init__ 里建一条 `check_same_thread=False` 的连接给所有线程共用:
VideoQueue / PersonService / DiskGuard 三个后台线程,加上 gunicorn 的请求
线程,并发读写同一个连接对象,事务状态互相踩踏。线上长期刷三类报错,
全是这一个根因:
- `cannot start a transaction within a transaction`:一个线程的事务还
开着另一个线程又要开——DiskGuard 的清理被打断 2837 次,磁盘守护基本
靠运气生效(表现为剩余空间在 2.7G 和 16G 之间来回荡)
- `no more rows available`commit 时游标已被别的线程重置87 次
- `database is locked`每小时上百次NAS 推来的运动事件被 500 打回
sqlite3 的连接本来就不是可并发共享的对象。改成各线程各拿一条之后WAL 下
多连接读不互斥,写由 SQLite 自己排队busy_timeout 兜底等 10 秒),而
`_write_lock` 继续保证"复合写"在本进程内串行,语义不变。
"""
conn = getattr(self._local, 'conn', None)
if conn is None:
conn = sqlite3.connect(self.db_path, timeout=10, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=10000")
self._local.conn = conn
with self._conns_lock:
self._all_conns.append(conn)
return conn
# ------------------------------------------------------------------
def _init_schema(self):
c = self._conn
@@ -567,6 +596,23 @@ class OracleDB:
logger.warning(f"删除视频文件失败 {local_path}: {e}")
return local_path or ''
def get_oldest_purgeable_material(self) -> Optional[Dict]:
"""磁盘空间紧张时的清理候选:最旧的、已完成分割阶段的整段素材
filename 不是 motion_ 前缀)。按 id 升序取第一个id 越小越早入库)。
只挑 status='done' 的——素材一旦完成分割就不会再被 Video-Queue 重新
捡起,删掉它的本地文件不影响任何功能(运动片段是独立文件,事件时间
轴/人物头像只依赖 motion_clips 里的片段,不依赖原始整段素材);绝不
碰 pending/processing 中的,避免删掉还没来得及处理的数据。
"""
row = self._conn.execute(
"SELECT id, local_path FROM videos "
"WHERE status='done' AND local_path IS NOT NULL AND local_path != '' "
"AND filename NOT LIKE 'motion_%' "
"ORDER BY id ASC LIMIT 1"
).fetchone()
return dict(row) if row else None
def mark_video_invalid(self, video_id: int, error: str = ''):
"""文件校验不通过(损坏/非视频等),标记 invalidproducer 不再重试。"""
now = _now_iso()
@@ -1061,4 +1107,12 @@ class OracleDB:
self._conn.commit()
def close(self):
self._conn.close()
"""关掉所有线程开过的连接(不只当前线程这一条)。"""
with self._conns_lock:
conns, self._all_conns = self._all_conns, []
for c in conns:
try:
c.close()
except sqlite3.Error:
pass
self._local = threading.local()

View File

@@ -1,14 +1,15 @@
"""
PersonIdentifier - 闭集人物识别(家里固定 4 个人:爷爷/爸爸/媳妇/汤圆)
PersonIdentifier - 闭集人物识别(家里固定 5 个人:爷爷/爸爸/媳妇/奶奶/汤圆)
背景2026-08-22: 原来靠大模型每次视频分析自己编的"人物A/B/C"临时 uid + 一段
性别/年龄/衣着文字描述做跨视频合并,原理上就不可靠——文字描述会因光线/角度/换衣服
对不上,反复出现张冠李戴(用户原话:"现在的识别全是错的")。人脸向量方案也验证
过,家庭监控这种大广角/远距离/糊画面下同人内部相似度经常比不同人还低,此路不通。
现在改成基于已知这个家庭只有 4 个固定成员的闭集规则:
- 汤圆(幼儿/儿童)、媳妇唯一成年女性Gemini 每次分析已经会标性别/年龄段
这两条命中率验证下来接近 100%,直接用,不需要额外模型调用。
现在改成基于已知这个家庭固定成员的闭集规则:
- 汤圆(幼儿/儿童)、媳妇/奶奶(成年女性按年龄段区分:老年=奶奶,其余=媳妇
2026-08-29 新增Gemini 每次分析已经会标性别/年龄段,命中率验证下来接近
100%,直接用,不需要额外模型调用。
- 爷爷、爸爸(两个成年男性,纯外观规则/人脸向量都区分不开):改用视觉大模型
"看图比对"——给几张已确认身份的参考图 + 待判断的截图,直接问模型这是谁。
实测 NVIDIA nemotron-omni 在留出测试集上 6/6 全对Gemini flash-lite 7/8

View File

@@ -203,7 +203,7 @@ class VideoProcessor:
self.motion_keep_audio = bool(seg.get('keep_audio', True))
self.motion_min_duration = float(seg.get('min_duration_sec', 1))
self.motion_grace_sec = int(seg.get('unfinished_grace_sec', 10))
# 闭集人物识别(家里固定 4 人):汤圆/媳妇 用性别年龄规则;爷爷/爸爸 用
# 闭集人物识别(家里固定 5 人):汤圆/媳妇/奶奶 用性别年龄规则;爷爷/爸爸 用
# person_identifier 视觉大模型比对,每片段每个 uid 只调一次
self.person_identifier = PersonIdentifier(self.config.get('person_identifier', {}))
adapters = build_adapters(self.config.get('models', []))
@@ -526,12 +526,19 @@ class VideoProcessor:
norm_events: List[Dict]) -> Dict[str, tuple]:
"""闭集人物识别:返回 {uid: (canonical_name, source)}。
汤圆(幼儿/儿童特征)、媳妇(唯一成年女性)、赤膊成年人(家里只有爸爸
会赤膊)靠 Gemini 已经产出的性别/年龄/衣着字段直接判断source='rule'
验证过命中率接近 100%,不需要额外模型调用。爷爷/爸爸两个成年男性穿戴
整齐时外观规则/人脸向量都区分不开(验证过),改用视觉大模型比对参考图
汤圆(幼儿/儿童特征)、媳妇/奶奶(成年女性按年龄段区分:老年=奶奶,
其余=媳妇)、赤膊成年人(家里只有爸爸会赤膊)靠 Gemini 已经产出的
性别/年龄/衣着字段直接判断source='rule'验证过命中率接近
100%,不需要额外模型调用。爷爷/爸爸两个成年男性穿戴整齐时外观规则/
人脸向量都区分不开(验证过),改用视觉大模型比对参考图
source='auto_id'),每个 uid 只取本片段内最大 bbox 的一次出现判断
一次,不逐事件重复调用。
2026-08-29 新增奶奶:家里从 4 人变成 5 人后,"媳妇=唯一成年女性"
这条规则的前提被打破了(现在有两个成年女性)。先用免费的年龄段
字段区分(老年=奶奶,参考 Gemini 对"爷爷"的判断也是老年)——如果
后续观察发现年龄段判断不稳定导致误判,再考虑改成跟爷爷/爸爸一样
的视觉比对方案。
"""
resolved: Dict[str, tuple] = {}
for uid, feats in uid_features.items():
@@ -541,7 +548,7 @@ class VideoProcessor:
if age_band in ('幼儿', '儿童'):
resolved[uid] = ('汤圆', 'rule')
elif gender == '':
resolved[uid] = ('媳妇', 'rule')
resolved[uid] = ('奶奶' if age_band == '老年' else '媳妇', 'rule')
elif gender == '':
if any(k in clothing for k in self._SHIRTLESS_KEYWORDS):
resolved[uid] = ('爸爸', 'rule')

View File

@@ -84,7 +84,15 @@ class VideoQueue:
if row is None:
# 新文件:先过 mtime 稳定窗口 + 可解码校验,通过才登记入队;失败标记 invalid
if self.file_validate:
if time.time() - os.path.getmtime(path) < self.stable_window_sec:
try:
mtime = os.path.getmtime(path)
except OSError:
# 列目录之后、读 mtime 之前DiskGuard 可能刚好把这个文件清掉了
# (两个后台线程的正常竞态)。跳过它就行——不 catch 的话整轮扫描
# 会被这一个文件中断,后面的新素材本轮都登记不上。
logger.info(f"文件已不在(可能刚被 DiskGuard 清理),跳过本轮: {fn}")
continue
if time.time() - mtime < self.stable_window_sec:
logger.info(f"文件仍在写入mtime 未稳定),跳过本轮: {fn}")
continue
ok, verr, vmeta = validate_video(path)

241
fam-edge/tests/test_auth.py Normal file
View File

@@ -0,0 +1,241 @@
"""统一登录2026-09-12 从 NAS fam-core 迁来)的单测。
除了把原 fam-core/tests/test_auth.py 的用例搬过来,额外盯死三件迁移时的关键行为:
回调失败不准再 302防死循环复发、服务端调 auth-hub 走内网地址但 iss 仍按公网校验、
会话 cookie 必须是无状态的(不依赖任何进程内状态)。
"""
import time
import jwt
import pytest
from flask import Flask
from fam_edge import auth
@pytest.fixture(autouse=True)
def _clean_env_and_state(monkeypatch):
for var in ('AUTH_HUB_ISSUER', 'AUTH_HUB_INTERNAL_BASE', 'AUTH_HUB_CLIENT_ID',
'AUTH_HUB_CLIENT_SECRET', 'AUTH_HUB_REDIRECT_URI', 'FAM_SESSION_SECRET'):
monkeypatch.delenv(var, raising=False)
auth._pending.clear()
auth._warned_unconfigured = False
auth._jwks_client = None
yield
auth._pending.clear()
def _set_env(monkeypatch, internal_base=None):
monkeypatch.setenv('AUTH_HUB_ISSUER', 'https://auth.example')
monkeypatch.setenv('AUTH_HUB_CLIENT_ID', 'fam-core')
monkeypatch.setenv('AUTH_HUB_CLIENT_SECRET', 'sekret')
monkeypatch.setenv('AUTH_HUB_REDIRECT_URI', 'https://cam.example/api/auth/callback')
monkeypatch.setenv('FAM_SESSION_SECRET', 'session-signing-secret')
if internal_base:
monkeypatch.setenv('AUTH_HUB_INTERNAL_BASE', internal_base)
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(auth.auth_bp)
return app.test_client()
def _cookie(secret='session-signing-secret', username='ericwyuan', ttl=3600):
now = int(time.time())
return jwt.encode({'sub': '1', 'username': username, 'iat': now, 'exp': now + ttl},
secret, algorithm='HS256')
# ---------------------------------------------------------------------------
# 配置齐全性fail closed
# ---------------------------------------------------------------------------
def test_require_config_fails_closed_when_unconfigured():
assert auth._require_config() is None
def test_require_config_fails_closed_without_session_secret(monkeypatch):
_set_env(monkeypatch)
monkeypatch.delenv('FAM_SESSION_SECRET')
assert auth._require_config() is None
def test_login_rejects_when_unconfigured(client):
resp = client.get('/login')
assert resp.status_code == 503
# 配置缺失也不能跳转,否则同样会跟 auth-hub 对跳
assert 'Location' not in resp.headers
# ---------------------------------------------------------------------------
# /login
# ---------------------------------------------------------------------------
def test_login_redirects_to_authorize_with_pkce(client, monkeypatch):
_set_env(monkeypatch)
resp = client.get('/login')
assert resp.status_code == 302
location = resp.headers['Location']
assert location.startswith('https://auth.example/authorize?')
assert 'code_challenge=' in location
assert 'code_challenge_method=S256' in location
assert 'client_id=fam-core' in location
assert len(auth._pending) == 1
def test_login_always_sends_browser_to_public_issuer(client, monkeypatch):
"""内网地址只给服务端自己用,浏览器必须跳公网——跳 127.0.0.1 用户当然打不开。"""
_set_env(monkeypatch, internal_base='http://127.0.0.1:5300')
resp = client.get('/login')
assert resp.headers['Location'].startswith('https://auth.example/authorize?')
def test_login_redirects_home_when_already_authed(client, monkeypatch):
_set_env(monkeypatch)
client.set_cookie('fam_session', _cookie())
resp = client.get('/login')
assert resp.status_code == 302
assert resp.headers['Location'] == '/'
# ---------------------------------------------------------------------------
# 回调:失败分支一律错误页,绝不再跳 /login旧实现死循环的根因
# ---------------------------------------------------------------------------
def test_callback_unknown_state_shows_error_page_without_redirecting(client, monkeypatch):
_set_env(monkeypatch)
resp = client.get('/api/auth/callback?state=nope&code=abc')
assert resp.status_code == 400
assert 'Location' not in resp.headers
assert 'fam_session' not in resp.headers.get('Set-Cookie', '')
def test_callback_idp_error_shows_error_page_without_redirecting(client, monkeypatch):
_set_env(monkeypatch)
resp = client.get('/api/auth/callback?error=access_denied&state=x')
assert resp.status_code == 403
assert 'Location' not in resp.headers
def test_callback_token_failure_shows_error_page_without_redirecting(client, monkeypatch):
_set_env(monkeypatch)
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
class _Resp:
status_code = 400
text = 'invalid_grant'
monkeypatch.setattr(auth.requests, 'post', lambda *a, **k: _Resp())
resp = client.get('/api/auth/callback?state=thestate&code=abc')
assert resp.status_code == 502
assert 'Location' not in resp.headers
assert 'fam_session' not in resp.headers.get('Set-Cookie', '')
# ---------------------------------------------------------------------------
# 回调:成功路径
# ---------------------------------------------------------------------------
def _stub_successful_exchange(monkeypatch, captured):
class _Resp:
status_code = 200
text = ''
def json(self):
return {'id_token': 'the-id-token', 'access_token': 'x'}
def _post(url, **kwargs):
captured['token_url'] = url
return _Resp()
monkeypatch.setattr(auth.requests, 'post', _post)
class _FakeKey:
key = 'unused'
class _FakeJwks:
uri = 'unused'
def get_signing_key_from_jwt(self, token):
return _FakeKey()
def _jwks(uri):
captured['jwks_uri'] = uri
return _FakeJwks()
monkeypatch.setattr(auth, '_get_jwks_client', _jwks)
real_decode = auth.jwt.decode
def _decode(token, key, **kw):
# id_token 走 RS256记下校验参数并返回固定 claims
# 会话 cookie 走 HS256交给真正的实现别把验签也 mock 掉
if kw.get('algorithms') == ['RS256']:
captured['decode_kwargs'] = kw
return {'sub': '1', 'preferred_username': 'ericwyuan'}
return real_decode(token, key, **kw)
monkeypatch.setattr(auth.jwt, 'decode', _decode)
def test_callback_success_sets_cookie_and_goes_home(client, monkeypatch):
_set_env(monkeypatch)
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
captured = {}
_stub_successful_exchange(monkeypatch, captured)
resp = client.get('/api/auth/callback?state=thestate&code=abc')
assert resp.status_code == 302
assert resp.headers['Location'] == '/'
assert 'fam_session=' in resp.headers['Set-Cookie']
assert 'HttpOnly' in resp.headers['Set-Cookie']
assert 'thestate' not in auth._pending # 用过的 state 必须立刻作废
def test_callback_talks_to_internal_base_but_validates_public_issuer(client, monkeypatch):
"""换 token / 拉 JWKS 走本机,避免公网 TLS 那条链路上的 CA 和时钟坑;
但 id_token 里的 iss 是 auth-hub 配置的公网地址,校验必须按公网来。"""
_set_env(monkeypatch, internal_base='http://127.0.0.1:5300')
auth._pending['thestate'] = {'verifier': 'v', 'expires': time.time() + 600}
captured = {}
_stub_successful_exchange(monkeypatch, captured)
client.get('/api/auth/callback?state=thestate&code=abc')
assert captured['token_url'] == 'http://127.0.0.1:5300/token'
assert captured['jwks_uri'] == 'http://127.0.0.1:5300/.well-known/jwks.json'
assert captured['decode_kwargs']['issuer'] == 'https://auth.example'
assert captured['decode_kwargs']['audience'] == 'fam-core'
assert captured['decode_kwargs']['leeway'] == auth._LEEWAY
# ---------------------------------------------------------------------------
# 会话 cookie / check / verify / logout
# ---------------------------------------------------------------------------
def test_session_is_stateless(client, monkeypatch):
"""cookie 自带签名,服务端不存任何东西——换个进程、重启服务照样认。"""
_set_env(monkeypatch)
assert auth._pending == {}
client.set_cookie('fam_session', _cookie())
assert client.get('/api/auth/verify').status_code == 204
def test_verify_rejects_missing_expired_and_forged_cookies(client, monkeypatch):
_set_env(monkeypatch)
assert client.get('/api/auth/verify').status_code == 401
client.set_cookie('fam_session', _cookie(ttl=-10))
assert client.get('/api/auth/verify').status_code == 401
client.set_cookie('fam_session', _cookie(secret='wrong-secret'))
resp = client.get('/api/auth/verify')
assert resp.status_code == 401
assert resp.get_json()['error'] == '未登录'
def test_check_reports_auth_state(client, monkeypatch):
_set_env(monkeypatch)
assert client.get('/api/auth/check').get_json() == {'authed': False, 'username': ''}
client.set_cookie('fam_session', _cookie(username='ericwyuan'))
assert client.get('/api/auth/check').get_json() == {'authed': True, 'username': 'ericwyuan'}
def test_logout_clears_cookie(client, monkeypatch):
_set_env(monkeypatch)
client.set_cookie('fam_session', _cookie())
resp = client.post('/api/logout')
assert resp.status_code == 200
assert 'fam_session=;' in resp.headers['Set-Cookie']

View File

@@ -0,0 +1,120 @@
import pytest
from fam_edge.disk_guard import DiskGuard
def _cfg(**overrides):
base = {
"enabled": True,
"min_free_gb": 10,
"target_free_gb": 15,
"watch_path": "/opt/fam-edge",
"max_delete_per_round": 50,
"check_interval_sec": 300,
}
base.update(overrides)
return base
class _FakeDB:
def __init__(self, candidates=None):
self._candidates = list(candidates or [])
self.deleted_ids = []
self.activities = []
def get_oldest_purgeable_material(self):
if not self._candidates:
return None
return self._candidates.pop(0)
def delete_video(self, video_id):
self.deleted_ids.append(video_id)
return None
def record_activity(self, service, action, detail=''):
self.activities.append((service, action, detail))
def _guard(monkeypatch, db, free_gb_sequence, **cfg_overrides):
"""free_gb_sequence: 每次调用 _free_gb() 依次返回的值列表(最后一个值
耗尽后保持不变),用来模拟"清理一个文件后空间逐步恢复"的过程。"""
monkeypatch.setattr(
"fam_edge.disk_guard.load_config",
lambda: {"disk_guard": _cfg(**cfg_overrides)})
guard = DiskGuard(db)
seq = list(free_gb_sequence)
def fake_free_gb():
if len(seq) > 1:
return seq.pop(0)
return seq[0]
monkeypatch.setattr(guard, "_free_gb", fake_free_gb)
return guard
def test_check_once_does_nothing_when_space_sufficient(monkeypatch):
db = _FakeDB()
guard = _guard(monkeypatch, db, [20.0])
guard.check_once()
assert db.deleted_ids == []
assert db.activities == []
def test_check_once_cleans_until_target_reached(monkeypatch):
"""核心诉求: 低于 min_free_gb 触发清理,一直清到 target_free_gb 为止,
不是清一个就停(否则马上又会跌破阈值,频繁触发)。"""
db = _FakeDB(candidates=[
{"id": 1, "local_path": "/tmp/a.mp4"},
{"id": 2, "local_path": "/tmp/b.mp4"},
{"id": 3, "local_path": "/tmp/c.mp4"},
])
# 初始 8GB< min_free_gb=10每删一个恢复到 8/12/16GB16 >= target=15 时停)
guard = _guard(monkeypatch, db, [8.0, 8.0, 12.0, 16.0])
guard.check_once()
assert db.deleted_ids == [1, 2]
actions = [a[1] for a in db.activities]
assert "low_space" in actions
assert "cleaned" in actions
def test_check_once_stops_when_no_candidate_left(monkeypatch):
"""核心诉求: 候选清空了但空间依然不足,不能死循环,要停下来并记录一条
"没有可清理素材"的警告,让人能在服务状态页看到这个异常情况——即便如此,
已经发生的清理动作本身也要记录(能看到确实清过、释放了多少),不因为
没完全达标就把 cleaned 记录吞掉。"""
db = _FakeDB(candidates=[{"id": 1, "local_path": "/tmp/a.mp4"}])
guard = _guard(monkeypatch, db, [8.0, 8.0, 9.0]) # 删完仅 1 个后仍然 <15GB
guard.check_once()
assert db.deleted_ids == [1]
actions = [a[1] for a in db.activities]
assert "no_candidate" in actions
assert "cleaned" in actions
def test_check_once_respects_max_delete_per_round(monkeypatch):
"""核心诉求: 单轮清理有上限,防止候选异常多时一次性删太多——下一轮检查
很快就会再触发,没必要在一轮里清空所有候选。"""
candidates = [{"id": i, "local_path": f"/tmp/{i}.mp4"} for i in range(1, 6)]
db = _FakeDB(candidates=candidates)
# 空间一直卡在 8GB 不涨(模拟每个文件都很小,删多少都到不了 target
guard = _guard(monkeypatch, db, [8.0], max_delete_per_round=3)
guard.check_once()
assert db.deleted_ids == [1, 2, 3]
def test_disabled_guard_does_not_start(monkeypatch):
db = _FakeDB()
guard = _guard(monkeypatch, db, [1.0], enabled=False)
guard.start()
assert guard.is_alive() is False

View File

@@ -407,6 +407,45 @@ def test_delete_video_nonexistent_returns_none(tmp_path):
assert db.delete_video(99999) is None
def test_get_oldest_purgeable_material_none_when_empty(tmp_path):
db = _db(tmp_path)
assert db.get_oldest_purgeable_material() is None
def test_get_oldest_purgeable_material_ignores_motion_clips(tmp_path):
"""核心诉求: 运动片段motion_ 前缀)是独立的分析产物,事件时间轴/人物
头像都依赖它,磁盘清理绝不能碰它,只能清理原始整段素材。"""
db = _db(tmp_path)
vid = db.ensure_video("motion_1_1000.mp4", "/tmp/motion_1_1000.mp4",
event_start_time="2026-08-22 10:00:00")
db.mark_video_processed(vid, "摘要", [], [], "gemini")
assert db.get_oldest_purgeable_material() is None
def test_get_oldest_purgeable_material_ignores_non_done_status(tmp_path):
"""核心诉求: 还在 pending/processing 的素材不能被清理,避免删掉还没
来得及处理的数据。"""
db = _db(tmp_path)
db.ensure_video("Generic_ONVIF-001-20260815-000000.mp4",
"/tmp/Generic_ONVIF-001-20260815-000000.mp4")
assert db.get_oldest_purgeable_material() is None
def test_get_oldest_purgeable_material_returns_oldest_done_material(tmp_path):
db = _db(tmp_path)
vid1 = db.ensure_video("Generic_ONVIF-001-20260815-000000.mp4",
"/tmp/Generic_ONVIF-001-20260815-000000.mp4")
db.mark_video_processed(vid1, "(整段素材已分割 0 段运动片段)", [], [], 'motion_segment')
vid2 = db.ensure_video("Generic_ONVIF-001-20260816-000000.mp4",
"/tmp/Generic_ONVIF-001-20260816-000000.mp4")
db.mark_video_processed(vid2, "(整段素材已分割 0 段运动片段)", [], [], 'motion_segment')
candidate = db.get_oldest_purgeable_material()
assert candidate["id"] == vid1
assert candidate["local_path"] == "/tmp/Generic_ONVIF-001-20260815-000000.mp4"
def test_delete_video_does_not_touch_ss_motion_events(tmp_path):
"""核心诉求: ss_motion_events 是运动侦测源事件,跟切出来的视频片段生命周期
独立,删视频不该连带删掉源事件(否则分割逻辑的幂等判断会被破坏)。"""
@@ -424,3 +463,63 @@ def test_delete_video_does_not_touch_ss_motion_events(tmp_path):
assert db.get_video_by_motion_event_id(555) is None
row = db._conn.execute("SELECT * FROM ss_motion_events WHERE event_id=?", (555,)).fetchone()
assert row is not None
# ---------------------------------------------------------------------------
# 并发2026-09-13修"全进程共用一条 sqlite 连接"的回归测试)
# ---------------------------------------------------------------------------
def test_concurrent_writes_from_many_threads(tmp_path):
"""多线程同时读写不能互相踩踏。
改成每线程一条连接之前,这里会稳定炸出三类错误之一:
`cannot start a transaction within a transaction` / `no more rows available`
/ `database is locked`——线上 DiskGuard 的清理就是被第一种打断了 2837 次。
"""
import threading
db = _db(tmp_path)
errors = []
rounds = 25
def writer(tid):
try:
for i in range(rounds):
db.record_activity('t%d' % tid, 'act%d' % i, 'detail')
db.set_cursor('cursor_t%d' % tid, str(i))
db.record_motion_events([{
'event_id': tid * 1000 + i, 'camera_id': 2, 'event_type': 10,
'start_time': 1789000000 + i, 'duration': 5,
'thumbnail_url': '',
}])
db.get_recent_activities(5)
db.get_motion_heartbeat_age_sec()
except Exception as e: # noqa: BLE001 —— 要把原始异常带出来看
errors.append(f"线程{tid}: {type(e).__name__}: {e}")
threads = [threading.Thread(target=writer, args=(t,)) for t in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=60)
assert not errors, "并发写出错:\n" + "\n".join(errors[:5])
assert db._conn.execute("SELECT COUNT(*) FROM ss_motion_events").fetchone()[0] == 8 * rounds
for tid in range(8):
assert db.get_cursor('cursor_t%d' % tid) == str(rounds - 1)
def test_close_releases_every_thread_connection(tmp_path):
"""close() 要收掉所有线程开过的连接,不只当前线程那一条。"""
import threading
db = _db(tmp_path)
db.record_activity('main', 'x', '')
def other():
db.record_activity('other', 'y', '')
t = threading.Thread(target=other)
t.start(); t.join()
assert len(db._all_conns) == 2 # 主线程 + 子线程各一条
db.close()
assert db._all_conns == []

View File

@@ -111,6 +111,34 @@ def test_shirtless_child_still_resolves_to_child_not_dad():
assert resolved == {"人物A": ("汤圆", "rule")}
# ----------------------------------------------------------------------
# 闭集人物识别:成年女性按年龄段区分媳妇/奶奶2026-08-29 新增,家里从 4 人
# 变成 5 人后,"媳妇=唯一成年女性"这条规则的前提被打破)
# ----------------------------------------------------------------------
def test_middle_aged_female_resolves_to_wife_without_vlm_call():
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "中年", "clothing": "粉色上衣"}}, [])
assert resolved == {"人物A": ("媳妇", "rule")}
def test_elderly_female_resolves_to_grandma_without_vlm_call():
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "老年", "clothing": "红色上衣"}}, [])
assert resolved == {"人物A": ("奶奶", "rule")}
def test_female_with_unknown_age_band_defaults_to_wife():
"""核心诉求: age_band 缺失/模型没给出明确判断时,不能因为不确定就放弃
识别——默认归到媳妇(原有行为),只有明确判断为"老年"才算奶奶。"""
vp = _bare_processor(_RaisingPersonIdentifier())
resolved = vp._resolve_closed_set_identities(
1, {"人物A": {"gender": "", "age_band": "unknown", "clothing": ""}}, [])
assert resolved == {"人物A": ("媳妇", "rule")}
def test_clothed_adult_male_still_falls_through_to_vlm(monkeypatch):
"""核心诉求: 没有赤膊关键词的正常穿戴场景,行为不变——照常走视觉大模型
比对(这里用假的 _best_crop_for_uid 避免真的需要 norm_events 数据)。"""

View File

@@ -0,0 +1,73 @@
"""VideoQueue 生产者扫描的单测。
目前只覆盖一个点:生产者列目录之后、读 mtime 之前DiskGuard 可能刚好把文件清掉
(两个后台线程的正常竞态)。线上表现为 `生产者扫描异常: [Errno 2] No such file or
directory`,代价是**整轮扫描中断**——排在后面的新素材本轮全都登记不上,要等下一轮。
"""
import os
import pytest
from fam_edge import video_queue as vq_mod
from fam_edge.video_queue import VideoQueue
class _FakeDB:
"""只实现生产者路径上用到的方法。"""
def __init__(self):
self.registered = []
self.activities = []
def get_video_by_filename(self, fn):
return None
def ensure_video(self, fn, path, camera_name=None):
self.registered.append(fn)
return len(self.registered)
def set_video_file_status(self, vid, valid, err, meta=None):
pass
def record_activity(self, service, action, detail=''):
self.activities.append((service, action))
@pytest.fixture
def queue_with_two_files(tmp_path, monkeypatch):
monkeypatch.setattr(vq_mod, 'load_config', lambda: {
'gdrive_sync': {'local_dir': str(tmp_path), 'camera_name': '客厅'},
'video_processing': {'file_validate': True, 'stable_window_sec': 60},
})
(tmp_path / "a_被删掉的.mp4").write_bytes(b"x")
(tmp_path / "b_正常的.mp4").write_bytes(b"y")
db = _FakeDB()
q = VideoQueue(db)
monkeypatch.setattr(q, '_enqueue', lambda vid: None)
monkeypatch.setattr(vq_mod, 'validate_video', lambda p: (True, '', {'fps': 25}))
return q, db
def test_produce_skips_file_deleted_mid_scan_and_keeps_going(queue_with_two_files, monkeypatch):
"""被删掉的那个跳过,后面的照常登记——不能整轮中断。"""
q, db = queue_with_two_files
real_getmtime = os.path.getmtime
def fake_getmtime(path):
if 'a_被删掉的' in path:
raise FileNotFoundError(2, 'No such file or directory', path)
return real_getmtime(path) - 3600 # 早于稳定窗口,视为写入完成
monkeypatch.setattr(vq_mod.os.path, 'getmtime', fake_getmtime)
q._produce_once() # 不抛异常
assert db.registered == ["b_正常的.mp4"] # 被删的跳过,后面的没受影响
def test_produce_still_skips_files_being_written(queue_with_two_files, monkeypatch):
"""mtime 太新rclone 还在写)仍然要跳过,别把半成品入队。"""
q, db = queue_with_two_files
monkeypatch.setattr(vq_mod.os.path, 'getmtime', lambda p: __import__('time').time())
q._produce_once()
assert db.registered == []

View File

@@ -0,0 +1,27 @@
# fam-notifier 配置NAS 上唯一保留的服务)
# 复制为 config.yaml 后填实际值;${VAR} 会从环境变量解析start_notifier.sh 会 source .env
motion_notifier:
enabled: true
poll_enabled: true # 轮询主路径(默认开启)
dsm_host: "127.0.0.1" # Surveillance Station 就在本机NAS走回环即可
dsm_port: 5000
dsm_account: "${DSM_ACCOUNT}"
dsm_password: "${DSM_PASSWORD}"
camera_ids: [2] # 轮询关注的摄像头Generic_ONVIF-001
# 推送目标:甲骨文 fam-edge。这是本服务唯一的出口单向。
oracle_base_url: "http://129.146.26.249:5000"
oracle_token: "${ORACLE_SYNC_TOKEN}"
timeout_sec: 10 # 单次 SS 请求超时
# 心跳:跟轮询 SS 无关,只是定期空 POST 一下甲骨文的 /api/ss/motion证明
# NAS->甲骨文这条推送链路本身还活着enabled=true 就跑,不受 poll_enabled 影响)。
# 甲骨文侧 dsm_motion_prefilter.max_heartbeat_age_sec默认 900s据此判断"无运动"
# 结论是否可信——这个心跳间隔要明显小于那个阈值,否则会被误判成链路已死。
heartbeat_interval_sec: 300
# 轮询参数
poll_interval_sec: 60 # 轮询间隔
poll_window_hours: 2 # 每轮回看窗口(小时),覆盖轮询间隔内的新事件
batch_size: 100 # 单批推送上限

View File

@@ -0,0 +1,3 @@
# NAS 上唯一要装的依赖:一个 HTTP 客户端 + YAML 配置解析
requests>=2.31.0
PyYAML>=6.0

View File

@@ -0,0 +1,72 @@
#!/bin/sh
# fam-notifier 开机自启DSM 没有 systemd/usr/local/etc/rc.d/ 是唯一的守护手段)
#
# 部署sudo cp 到 /usr/local/etc/rc.d/S99fam-notifier.sh && sudo chmod 755 同路径
#
# 为什么非有不可:这个进程是摄像头系统在 NAS 上唯一保留的东西(轮询
# Surveillance Station把运动事件单向推给甲骨文。它没起来的话事件就悄无声息
# 地断流——2026-09-04 到 09-12 断档整整十天,就是因为当时它还在 fam-core 里、
# fam-core 挂了没人重启,而且不打开网站根本发现不了。
#
# 以 ericwyuan 而不是 root 运行:保持 logs/ 和 data/cursor.json 的属主跟手动启动
# 时一致,免得 root 建出来的文件之后普通用户改不动。
RUN_USER=ericwyuan
APP_DIR=/volume1/web/sentinel-home-ai/fam-notifier
START="$APP_DIR/scripts/start_notifier.sh"
LOG="$APP_DIR/logs/start.log"
PIDFILE="$APP_DIR/fam-notifier.pid"
# DSM 上没有 pgrep用 grep 字符类找 pid[f] 写法避免匹配到 grep 自己)
find_pid() {
ps aux | grep "[f]am_notifier" | awk '{print $2}' | head -1
}
start() {
RUNNING=$(find_pid)
if [ -n "$RUNNING" ]; then
echo "fam-notifier 已在运行 (PID $RUNNING)"; echo "$RUNNING" > "$PIDFILE"; return 0
fi
# 等待数据卷 volume1 挂载就绪(最多 90 秒),避免开机时脚本早于卷挂载而静默失败
n=0
while [ ! -f "$START" ] && [ $n -lt 90 ]; do
sleep 1; n=$((n+1))
done
if [ ! -f "$START" ]; then
echo "等待 $START 超时(数据卷可能未挂载),启动放弃"; return 1
fi
# su 自身的 stdio 也要断开:否则通过 SSH 执行本脚本时,子进程继承了连接的管道,
# 命令跑完了 SSH 会话却迟迟不退出(开机时无所谓,手动敲的时候很烦)
su "$RUN_USER" -c "cd '$APP_DIR' && setsid nohup sh scripts/start_notifier.sh >> '$LOG' 2>&1 < /dev/null &" > /dev/null 2>&1 < /dev/null
sleep 4
PID=$(find_pid)
if [ -n "$PID" ]; then
echo "$PID" > "$PIDFILE"
echo "fam-notifier 已启动 (PID $PID)"
else
echo "启动失败,请查看 $LOG$APP_DIR/logs/fam-notifier.log"; return 1
fi
}
stop() {
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null; rm -f "$PIDFILE"; fi
for p in $(ps aux | grep "[f]am_notifier" | awk '{print $2}'); do kill "$p" 2>/dev/null; done
echo "fam-notifier 已停止"
}
status() {
PID=$(find_pid)
if [ -n "$PID" ]; then
echo "运行中 (PID $PID)"
tail -3 "$APP_DIR/logs/fam-notifier.log" 2>/dev/null
else
echo "未运行"; return 1
fi
}
case "$1" in
start) start ;;
stop) stop ;;
restart) stop; sleep 2; start ;;
status) status ;;
*) echo "用法: $0 {start|stop|restart|status}"; exit 1 ;;
esac

View File

@@ -0,0 +1,36 @@
#!/bin/bash
# fam-notifier 启动脚本NAS 端)
# 用法: bash scripts/start_notifier.sh
#
# NAS 上没有 systemd挂了不会自启——重启方式见 docs/DEPLOY.md §1.2。
set -e
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CONFIG_FILE="$APP_DIR/config/config.yaml"
if [ ! -f "$CONFIG_FILE" ]; then
echo "错误: 配置文件不存在: $CONFIG_FILE"
echo "请复制 config/config.yaml.example 为 config.yaml 并填入实际值"
exit 1
fi
cd "$APP_DIR"
# 加载 DSM_ACCOUNT / DSM_PASSWORD / ORACLE_SYNC_TOKEN。
# set -a 包裹:.env 里是裸赋值,不 export 的话子进程读不到2026-09-01 踩过)。
ENV_FILE="$APP_DIR/../.env"
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE"
set +a
fi
if [ -d "venv" ]; then
source venv/bin/activate
fi
# 包在 src/ 下,不加这行 `python -m fam_notifier` 找不到模块
export PYTHONPATH="$APP_DIR/src${PYTHONPATH:+:$PYTHONPATH}"
echo "启动 fam-notifier轮询 Surveillance Station -> 推送甲骨文)..."
exec python -m fam_notifier

View File

@@ -0,0 +1,34 @@
"""
fam-notifier 入口:`python -m fam_notifier`
没有 Flask没有对外端口——这个进程只做一件事轮询 Surveillance Station
把运动事件单向推给甲骨文的 fam-edge。状态查看去网站的「服务状态」页
心跳和事件都记在甲骨文那边service_activity / ss_motion_events
"""
import sys
import time
from .logger import setup_logger
from .notifier import MotionNotifier
logger = setup_logger('fam-notifier')
def main():
notifier = MotionNotifier()
if not notifier.enabled:
logger.error("motion_notifier.enabled=false没什么可做的退出")
return 1
notifier.start()
logger.info("fam-notifier 已启动Ctrl+C 退出)")
try:
while notifier.is_alive():
time.sleep(5)
except KeyboardInterrupt:
logger.info("收到中断,停止轮询")
notifier.stop()
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,32 @@
"""
配置加载器 - 从 config.yaml 读取配置
"""
import os
import re
import yaml
def _resolve_env_vars(value):
"""递归解析字符串中的 ${ENV_VAR} 引用"""
if isinstance(value, str):
def replace_env(match):
env_name = match.group(1)
return os.environ.get(env_name, match.group(0))
return re.sub(r'\$\{(\w+)\}', replace_env, value)
elif isinstance(value, dict):
return {k: _resolve_env_vars(v) for k, v in value.items()}
elif isinstance(value, list):
return [_resolve_env_vars(item) for item in value]
return value
def load_config(config_path=None):
"""加载 YAML 配置文件,自动解析 ${ENV_VAR} 引用"""
if config_path is None:
config_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'config', 'config.yaml'
)
with open(config_path, 'r', encoding='utf-8') as f:
raw = yaml.safe_load(f)
return _resolve_env_vars(raw)

View File

@@ -0,0 +1,34 @@
"""
日志工具 - 统一格式stdout + 文件双写)
"""
import logging
import os
import sys
# 日志目录fam-notifier/logs/(相对 src 的上一级),失败则退化为仅 stdout
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'logs')
def setup_logger(name='fam-notifier', level=logging.INFO):
"""配置并返回 loggerstdout + 文件双写)"""
logger = logging.getLogger(name)
if logger.handlers:
return logger
logger.setLevel(level)
formatter = logging.Formatter(
'%(asctime)s [%(name)s] %(levelname)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(formatter)
logger.addHandler(handler)
# 文件输出daemon 模式下 stdout 不可见,文件是唯一可追溯日志)
try:
os.makedirs(_LOG_DIR, exist_ok=True)
file_handler = logging.FileHandler(
os.path.join(_LOG_DIR, 'fam-notifier.log'), encoding='utf-8')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
except OSError:
pass # 目录不可写时退化为仅 stdout
return logger

View File

@@ -1,5 +1,11 @@
"""
MotionNotifier - NAS 端运动监测通知服务轮询主路径2026-08-22 定型
MotionNotifier - NAS 端运动监测通知服务轮询主路径2026-08-22 定型
2026-09-13 fam-core 拆出成为 NAS 上唯一保留的服务
拆出来的原因摄像头插在 NAS Surveillance Station 就是 NAS 本身这部分
搬不走而其余所有东西数据库查询接口AI 问答登录都已经迁到甲骨文
拆完之后 NAS 侧没有 Flask没有 MariaDB没有对外端口只有这一个进程单向往
甲骨文推事件挂了重启即可不影响网站
职责:
NAS 本机**轮询**群晖 Surveillance Station 的运动侦测事件
@@ -12,37 +18,56 @@ MotionNotifier - NAS 端运动监测通知服务轮询主路径2026-08-22
- 推送后由甲骨文本地落库 ss_motion_events video_processor 本地预过滤使用
可靠性设计:
- 游标MariaDB sync_cursor.motion_last_event_id持久化重启优先续用 DB 游标
- 游标持久化在本地 JSON 文件拆出前存 NAS MariaDB sync_cursor
现在 NAS 上已经没有数据库了重启优先续用游标
停机期间的事件由窗口回看补推仅首次部署时才初始化为 SS 当前最大 id
- 推送失败的批次不推进游标下一轮窗口回看重试不丢事件
- 心跳线程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 json
import os
import re
import time
import hashlib
import threading
from datetime import datetime, timezone
import requests
from ..logger import setup_logger
from ..config_loader import load_config
from .. import db_layer
from .logger import setup_logger
from .config_loader import load_config
logger = setup_logger('fam-core.motion_notifier')
logger = setup_logger('fam-notifier')
# 游标文件:拆出前这是 MariaDB 里的一行,现在 NAS 上没有数据库了。
# 丢了也不致命——窗口回看会把最近的事件补推一遍,甲骨文侧按 event_id 幂等落库。
_CURSOR_PATH = os.environ.get('FAM_NOTIFIER_CURSOR') or os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'data', 'cursor.json')
def _read_cursor() -> str:
try:
with open(_CURSOR_PATH, encoding='utf-8') as f:
return str(json.load(f).get('motion_last_event_id') or '')
except (OSError, ValueError):
return ''
def _write_cursor(value: str):
try:
os.makedirs(os.path.dirname(_CURSOR_PATH), exist_ok=True)
with open(_CURSOR_PATH, 'w', encoding='utf-8') as f:
json.dump({'motion_last_event_id': str(value)}, f)
except OSError as e:
logger.warning(f"游标写入失败(下轮窗口回看会补推,不丢事件): {e}")
_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 +84,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 +99,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 +179,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,
}
# ------------------------------------------------------------------
# 推送到甲骨文
# ------------------------------------------------------------------
@@ -374,15 +265,15 @@ class MotionNotifier:
def _init_cursor(self):
"""启动时初始化 last_event_id。
优先使用 DB 保存的游标服务停机/重启期间产生的新事件会在重启后
优先使用文件里保存的游标服务停机/重启期间产生的新事件会在重启后
被下一轮窗口回看捞到并补推不丢事件
仅当 DB 有效游标首次部署 SS 当前最大事件 id 作为起点
仅当没有有效游标首次部署 SS 当前最大事件 id 作为起点
避免把历史事件全部回灌一遍
"""
saved = db_layer.get_motion_cursor()
saved = _read_cursor()
if saved and int(saved) > 0:
self._last_event_id = int(saved)
logger.info(f"运动通知游标初始化(DB: last_event_id={self._last_event_id}")
logger.info(f"运动通知游标初始化(本地文件: last_event_id={self._last_event_id}")
return
now = int(datetime.now(timezone.utc).timestamp())
events = self._fetch_events(now - 3600, now) # 最近 1h 用于定位最大 id
@@ -434,7 +325,7 @@ class MotionNotifier:
if int(e.get('duration') or 0) <= 0:
self._zero_dur_ids.add(int(e.get('id')))
self._last_event_id = cursor
db_layer.set_motion_cursor(str(self._last_event_id))
_write_cursor(str(self._last_event_id))
def _run(self):
logger.info(f"MotionNotifier 启动,轮询间隔 {self.poll_interval_sec}s"
@@ -499,8 +390,6 @@ class MotionNotifier:
"running": self.is_alive(),
"enabled": self.enabled,
"poll_enabled": self.poll_enabled,
"camera_map": {**self.camera_name_to_id, **self._ss_name_to_id},
"camera_loaded": self._camera_loaded,
"last_event_id": self._last_event_id,
"zero_dur_pending": len(self._zero_dur_ids),
"last_poll_at": self._last_poll_at.isoformat() if self._last_poll_at else None,

View File

@@ -0,0 +1,7 @@
import os
import sys
# 让测试能直接 `from fam_notifier.xxx import yyy`,无需先 pip install -e .
_SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'src')
if _SRC not in sys.path:
sys.path.insert(0, _SRC)

View File

@@ -1,6 +1,6 @@
import time
from fam_core.motion_notifier.motion_notifier import MotionNotifier
from fam_notifier.notifier import MotionNotifier
def _cfg(**overrides):
@@ -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,
@@ -23,7 +22,7 @@ def _cfg(**overrides):
def _make_notifier(monkeypatch, **cfg_overrides):
monkeypatch.setattr(
"fam_core.motion_notifier.motion_notifier.load_config",
"fam_notifier.notifier.load_config",
lambda: _cfg(**cfg_overrides))
return MotionNotifier()
@@ -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 就该跑)
# ----------------------------------------------------------------------
@@ -113,7 +48,7 @@ def test_send_heartbeat_success_updates_state(monkeypatch):
def fake_post(url, json=None, timeout=None):
calls.append((url, json))
return _FakeResp(200, {"status": "ok", "received": 0, "stored": 0})
monkeypatch.setattr("fam_core.motion_notifier.motion_notifier.requests.post", fake_post)
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
ok = n.send_heartbeat()
assert ok is True
assert n._last_heartbeat_at is not None
@@ -128,7 +63,7 @@ def test_send_heartbeat_http_error_records_failure(monkeypatch):
def fake_post(url, json=None, timeout=None):
return _FakeResp(500, {}, text="boom")
monkeypatch.setattr("fam_core.motion_notifier.motion_notifier.requests.post", fake_post)
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
ok = n.send_heartbeat()
assert ok is False
assert n._last_heartbeat_error == "HTTP 500"
@@ -140,7 +75,7 @@ def test_send_heartbeat_network_exception_records_failure(monkeypatch):
def fake_post(url, json=None, timeout=None):
raise _requests.RequestException("connection refused")
monkeypatch.setattr("fam_core.motion_notifier.motion_notifier.requests.post", fake_post)
monkeypatch.setattr("fam_notifier.notifier.requests.post", fake_post)
ok = n.send_heartbeat()
assert ok is False
assert "connection refused" in n._last_heartbeat_error
@@ -152,7 +87,7 @@ def test_start_runs_heartbeat_thread_even_when_poll_disabled(monkeypatch):
has_motion_in_range_local() 会一直 fail-open省配额的效果就没了"""
n = _make_notifier(monkeypatch, poll_enabled=False, heartbeat_interval_sec=3600)
monkeypatch.setattr(
"fam_core.motion_notifier.motion_notifier.requests.post",
"fam_notifier.notifier.requests.post",
lambda url, json=None, timeout=None: _FakeResp(200, {"stored": 0}))
try:
n.start()

View File

@@ -1,53 +1,68 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api, fmtDateTime } from './api.js'
import { api } from './api.js'
import { navItems } from './router.js'
const route = useRoute()
const sync = ref(null)
const authed = ref(false)
async function refreshStatus() {
async function checkAuth() {
try {
const data = await api.status()
sync.value = data.sync
const r = await api.authCheck()
authed.value = !!r.authed
} catch {
sync.value = null
authed.value = false
}
}
let timer = null
onMounted(() => {
refreshStatus()
timer = setInterval(refreshStatus, 30000)
})
onUnmounted(() => clearInterval(timer))
async function doLogout() {
try {
await fetch('/api/logout', { method: 'POST' })
} catch {
// 忽略网络错误,下面强制跳转即可
}
// 清掉 cookie 后回根路径,未登录态会让后端 401 -> 自动跳 /login 重新登录
window.location.href = '/'
}
onMounted(checkAuth)
</script>
<template>
<div class="flex min-h-screen flex-col lg:flex-row">
<!-- 桌面端左侧栏品牌 + 同步状态lg 以下隐藏改用下面的移动端顶栏 -->
<!-- 桌面端左侧栏品牌 + 登录入口lg 以下隐藏改用下面的移动端顶栏 -->
<aside class="hidden w-64 shrink-0 border-r border-border bg-[#090c12] px-4 py-6 lg:block">
<div class="mb-6">
<div class="text-[17px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
<div class="mt-1 text-[11px] text-text-mute">SENTINEL HOME AI · 管理后台</div>
</div>
<div v-if="sync" class="rounded-xl border border-border bg-panel-2 px-4 py-3 text-xs leading-loose text-text-dim">
<b class="text-[#f7f9fc]">同步状态</b><br />
状态 <span :class="sync.running ? 'font-semibold text-ok' : 'font-semibold text-danger'">{{ sync.running ? '同步中' : '未运行' }}</span><br />
最近 <b class="text-[#ccd5e1]">{{ sync.last_sync_at ? fmtDateTime(sync.last_sync_at) : '—' }}</b><br />
本次增量 {{ sync.last_count ? `视频+${sync.last_count[0]} / 事件+${sync.last_count[1]} / 人物+${sync.last_count[2]}` : '—' }}<br />
游标 {{ sync.cursor ? fmtDateTime(sync.cursor) : '(全量)' }}
<div v-if="sync.last_error" class="font-semibold text-danger"> {{ sync.last_error }}</div>
<div class="mt-4">
<a v-if="!authed" href="/login"
class="block rounded-xl bg-gradient-to-br from-accent to-accent-2 px-4 py-2.5 text-center text-sm font-semibold text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.45)] transition-opacity hover:opacity-90">
🔑 登录
</a>
<button v-else @click="doLogout"
class="block w-full rounded-xl border border-border bg-panel-2 px-4 py-2.5 text-center text-sm font-medium text-text-dim transition-colors hover:text-text">
👋 退出登录
</button>
</div>
</aside>
<!-- 移动端顶栏品牌 + 简要同步指示灯lg 以上隐藏用左侧栏代替 -->
<!-- 移动端顶栏品牌 + 登录/退出lg 以上隐藏用左侧栏代替 -->
<header class="flex items-center justify-between border-b border-border bg-[#090c12] px-4 py-3 lg:hidden">
<div class="text-[15px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
<span v-if="sync" class="flex items-center gap-1.5 text-xs font-medium" :class="sync.running ? 'text-ok' : 'text-danger'">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ sync.running ? '同步中' : '未运行' }}
</span>
<div class="flex items-center gap-2">
<a v-if="!authed" href="/login"
class="rounded-lg bg-gradient-to-br from-accent to-accent-2 px-3 py-1.5 text-xs font-semibold text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.45)]">
🔑 登录
</a>
<button v-else @click="doLogout"
class="rounded-lg border border-border bg-panel-2 px-3 py-1.5 text-xs font-medium text-text-dim">
退出
</button>
</div>
</header>
<div class="min-w-0 flex-1 px-4 py-4 sm:px-8 sm:py-6">

View File

@@ -1,5 +1,9 @@
// API 薄封装:生产环境同源相对路径;开发环境走 vite.config.js 的 /api 代理。
// 全局:未登录时首个 401 直接跳转到 /login 走 OIDC/PKCE 登录流程。
// 用一个模块级开关保证整次会话只触发一次跳转,避免多个并发 401 重复导航。
let _redirectingToLogin = false
async function request(path, options = {}) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
@@ -12,6 +16,14 @@ async function request(path, options = {}) {
// 非 JSON 响应(如 404 空 body保持 data=null
}
if (!res.ok) {
// 401 未登录:交给后端 /login302 到 auth-hub发起统一登录。
// /api/auth/check 等白名单接口不会返回 401所以这里只会命中真正的鉴权失败。
if (res.status === 401 && !_redirectingToLogin) {
_redirectingToLogin = true
window.location.href = '/login'
// 返回永挂起的 promise阻止调用方继续渲染"未登录"错误态(页面即将跳转)
return new Promise(() => {})
}
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`
throw new Error(msg)
}
@@ -51,8 +63,7 @@ export const api = {
mergeMember: (source, target) =>
request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }),
status: () => request('/api/status'),
syncTrigger: () => request('/api/sync/trigger', { method: 'POST' }),
authCheck: () => request('/api/auth/check'),
identityCorrect: (video_id, current_name, new_name) =>
request('/api/member/identity-correct', {

View File

@@ -11,8 +11,6 @@ import Spinner from '../components/Spinner.vue'
const data = ref(null)
const oracleError = ref('')
const loading = ref(false)
const syncing = ref(false)
const syncMsg = ref('')
async function load() {
loading.value = true
@@ -26,22 +24,6 @@ async function load() {
}
}
async function syncNow() {
syncing.value = true
syncMsg.value = ''
try {
const r = await api.syncTrigger()
const c = r.last_count || []
syncMsg.value = c.length
? `同步完成:视频+${c[0] ?? 0} 事件+${c[1] ?? 0} 人物+${c[2] ?? 0} 模型调用+${c[3] ?? 0} 身份映射+${c[4] ?? 0}`
: '同步完成'
await load()
} catch (e) {
syncMsg.value = `同步失败:${e.message}`
} finally {
syncing.value = false
}
}
onMounted(load)
@@ -59,7 +41,9 @@ const rclone = computed(() => oracle.value.rclone)
const person = computed(() => oracle.value.person)
const segment = computed(() => oracle.value.segment)
const segCons = computed(() => segment.value?.consistency || {})
const nasSync = computed(() => data.value?.nas_sync)
const motion = computed(() => oracle.value.motion || {})
// 心跳超过 15 分钟视为推送链路失联(与甲骨文侧 max_heartbeat_age_sec 默认值一致)
const motionOk = computed(() => (motion.value.heartbeat_age_sec ?? 1e9) < 900)
const modelCalls = computed(() => oracle.value.model_calls || [])
const activities = computed(() => oracle.value.activities || [])
@@ -77,16 +61,13 @@ const SVC_BADGE = {
<button :disabled="loading" @click="load" class="rounded-lg border border-border bg-panel-2 px-3.5 py-1.5 text-sm text-text-dim hover:border-accent/40 hover:text-white disabled:opacity-50">
{{ loading && data ? '刷新中…' : '🔄 刷新' }}
</button>
<button :disabled="syncing" @click="syncNow" class="rounded-lg border border-accent/40 bg-accent/10 px-3.5 py-1.5 text-sm text-accent hover:bg-accent/20 disabled:opacity-50">
{{ syncing ? '同步中…' : '⇄ 立即同步' }}
</button>
<span class="text-xs text-text-mute">{{ syncMsg || '点击刷新立即更新;立即同步会马上从甲骨文拉一次增量' }}</span>
<span class="text-xs text-text-mute">数据直接来自甲骨文无需同步</span>
</div>
<Spinner v-if="loading && !data" text="加载服务状态…" />
<template v-else>
<p v-if="oracleError" class="mb-4 text-sm text-warn">{{ oracleError }}</p>
<EmptyState v-if="!data?.oracle && !data?.nas_sync" text="暂时无法获取服务状态" />
<EmptyState v-if="!data?.oracle" text="暂时无法获取服务状态" />
<template v-else>
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">各服务当前状态</div>
@@ -112,10 +93,12 @@ const SVC_BADGE = {
{{ person ? person.action : '暂无记录' }}
<template #sub>{{ person ? `${(person.ts || '').slice(0, 19)} · ${(person.detail || '').slice(0, 46)}` : '—' }}</template>
</ServiceCard>
<ServiceCard icon="📡" name="NAS 同步" :value-color="nasSync ? '#fbbf24' : undefined">
{{ nasSync ? `游标 ${(nasSync.cursor || '').slice(0, 19)}` : '不可达' }}
<ServiceCard icon="📡" name="NAS 运动推送" :value-color="motionOk ? '#4ade80' : '#fbbf24'">
{{ motion.heartbeat_age_sec == null ? '从未收到心跳'
: motionOk ? `正常 · ${Math.round(motion.heartbeat_age_sec)}s 前`
: `失联 ${Math.round(motion.heartbeat_age_sec / 60)} 分钟` }}
<template #sub>
{{ nasSync ? `最近 ${(nasSync.last_sync_at || '').slice(0, 19)} · 增量 V${nasSync.last_count?.[0] ?? 0} E${nasSync.last_count?.[1] ?? 0} P${nasSync.last_count?.[2] ?? 0} M${nasSync.last_count?.[3] ?? 0}` : '' }}
{{ motion.recent?.length ? `最近事件 #${motion.recent[0].event_id} · ${(motion.recent[0].received_at || '').slice(0, 19)}` : '暂无运动事件' }}
</template>
</ServiceCard>
<ServiceCard icon="🧠" name="云端模型" :value-color="modelCalls.length ? '#4ade80' : undefined">

View File

@@ -1,13 +1,12 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { api, fmtDateOnly, fmtDateTime } from '../api.js'
import { api, fmtDateOnly } from '../api.js'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
import Spinner from '../components/Spinner.vue'
const modelChart = ref([]) // [{label, value}]
const attention = ref([])
const syncStatus = ref(null)
const loadError = ref('')
const loading = ref(true)
@@ -18,10 +17,9 @@ async function load() {
loadError.value = ''
loading.value = true
try {
const [ms, att, statusData] = await Promise.all([
const [ms, att] = await Promise.all([
api.modelStats(),
api.attentionEvents(),
api.status(),
])
const byProvider = {}
for (const row of ms.aggregate) {
@@ -30,7 +28,6 @@ async function load() {
}
modelChart.value = Object.entries(byProvider).map(([label, value]) => ({ label, value }))
attention.value = att.events
syncStatus.value = statusData.sync
} catch (e) {
loadError.value = e.message
} finally {
@@ -42,7 +39,7 @@ onMounted(load)
</script>
<template>
<PageHeader icon="📈" title="统计图表" sub="模型来源 / 关注事件 / 同步状态" />
<PageHeader icon="📈" title="统计图表" sub="模型来源 / 关注事件" />
<Spinner v-if="loading" text="加载统计数据…" />
<template v-else>
@@ -79,15 +76,5 @@ onMounted(load)
</table>
</div>
<div class="mb-3 mt-6 text-[15px] font-bold text-[#f7f9fc]">同步状态</div>
<div v-if="syncStatus" class="rounded-xl border border-border bg-panel-2 px-4 py-3 text-xs leading-loose text-text-dim">
运行状态 <span :class="syncStatus.running ? 'font-semibold text-ok' : 'font-semibold text-danger'">{{ syncStatus.running ? '同步中' : '未运行' }}</span><br />
最近同步 <b class="text-[#ccd5e1]">{{ syncStatus.last_sync_at ? fmtDateTime(syncStatus.last_sync_at) : '—' }}</b><br />
本次增量 {{ syncStatus.last_count ? `视频+${syncStatus.last_count[0]} / 事件+${syncStatus.last_count[1]} / 人物+${syncStatus.last_count[2]}` : '—' }}<br />
游标 <b class="text-[#ccd5e1]">{{ syncStatus.cursor ? fmtDateTime(syncStatus.cursor) : '(全量)' }}</b><br />
周期 {{ syncStatus.interval_sec }}s
<div v-if="syncStatus.last_error" class="font-semibold text-danger"> {{ syncStatus.last_error }}</div>
</div>
<EmptyState v-else text="无法获取同步状态" />
</template>
</template>

View File

@@ -3,7 +3,8 @@ import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// 开发环境代理 /api 到 NAS 上的 fam-core方便本地直接联调真实数据。
// 生产环境由 fam-core 的 Flask 同源提供,不需要这个代理。
// 生产环境由云服务器 Caddy :80 托管静态产物 + 反代 /api/*(经 frp 隧道回源
// 到这里同一个 NAS fam-core :8000不需要这个代理跟开发环境走的是同一个后端。
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {

View File

@@ -128,8 +128,13 @@ CREATE TABLE IF NOT EXISTS family_members (
-- ============================================================
-- 7.1 视频会话表Oracle videos 镜像)
-- v2 (2026-09-03)id 改为 NAS 本地自增主键(不再直接等于 Oracle id因 Oracle 库
-- 重建/重排会复用 id导致 filename UNIQUE 二次冲突 1062Oracle 的 videos.id 仅
-- 落 oracle_id 列溯源。业务唯一键仍为 filename。子表 video_id 引用仍用 Oracle id
-- (新视频插入时 id=oracle_id旧视频原地更新保留原 id外键不失效
CREATE TABLE IF NOT EXISTS sync_videos (
id INT PRIMARY KEY COMMENT 'Oracle videos.id',
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'NAS 本地自增主键',
oracle_id INT COMMENT 'Oracle videos.id仅溯源参考',
drive_file_id VARCHAR(255) COMMENT 'Google 硬盘文件 ID',
filename VARCHAR(500) NOT NULL UNIQUE COMMENT '视频文件名(唯一)',
camera_name VARCHAR(50) COMMENT '摄像头名称/位置',
@@ -164,8 +169,11 @@ CREATE TABLE IF NOT EXISTS sync_events (
) ENGINE=InnoDB COMMENT='甲骨文事件镜像表';
-- 7.3 人物规范表Oracle people 镜像)
-- v2 (2026-09-03):同 7.1id 改为 NAS 本地自增主键Oracle people.id 落 oracle_id
-- 列溯源;业务唯一键仍为 label。people.id 无外键引用,仅作展示排序。
CREATE TABLE IF NOT EXISTS sync_people (
id INT PRIMARY KEY COMMENT 'Oracle people.id',
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'NAS 本地自增主键',
oracle_id INT COMMENT 'Oracle people.id仅溯源参考',
label VARCHAR(100) NOT NULL UNIQUE COMMENT '抽象标识,如"人物A"',
canonical_name VARCHAR(100) COMMENT '规范名(用户命名或 LLM 合并NULL 表示未命名',
first_seen VARCHAR(32) COMMENT '首次出现时间',
@@ -188,8 +196,12 @@ CREATE TABLE IF NOT EXISTS sync_cursor (
-- 7.5 人物对应关系表Oracle person_identity_map 镜像2026-08-22 新增)
-- 闭集人物识别:某视频里 Gemini 给的原始 uid 与解析出的规范名(爷爷/爸爸/媳妇/
-- 汤圆)之间的映射,供事件时间轴"纠错"按钮定位、追溯识别来源
-- v2 (2026-08-29)id 改为 NAS 本地自增主键,业务键 (video_id, raw_uid) 唯一;
-- Oracle 的 person_identity_map.id 只落 oracle_id 列溯源。原以 Oracle id 为主键,
-- 一旦 Oracle 库重建/复用 id 会引发 uq_video_raw_uid 二次冲突 (1062)。
CREATE TABLE IF NOT EXISTS sync_identity_map (
id INT PRIMARY KEY COMMENT 'Oracle person_identity_map.id',
id INT AUTO_INCREMENT PRIMARY KEY COMMENT 'NAS 本地自增主键',
oracle_id INT COMMENT 'Oracle person_identity_map.id仅溯源参考',
video_id INT NOT NULL COMMENT '关联 sync_videos.id',
raw_uid VARCHAR(100) COMMENT '该视频里 Gemini 给的原始 uid',
canonical_name VARCHAR(100) COMMENT '当前生效的规范名',

View File

@@ -3,6 +3,7 @@
# 用法: ./scripts/start_core.sh
set -e
# APP_DIR = <repo>/fam-core
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)/fam-core"
CONFIG_FILE="$APP_DIR/config/config.yaml"
@@ -14,10 +15,16 @@ fi
cd "$APP_DIR"
# 加载项目级环境变量ORACLE_SYNC_TOKEN 等,与甲骨文端保持一致)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ -f "$SCRIPT_DIR/../.env" ]; then
source "$SCRIPT_DIR/../.env"
# 加载项目级环境变量ORACLE_SYNC_TOKEN / AUTH_HUB_* 等,与甲骨文端保持一致)
# 注意:.env 里 AUTH_HUB_* 是“裸赋值”(无 export 前缀),必须用 set -a 包裹 source
# 否则这些变量只存在于 shell 内、不会 export 给 gunicorn 子进程,导致 fam-core
# 启动后读不到 AUTH_HUB_ISSUER 等、登录被 fail-closed 拒绝(见 auth.py
# 另外 .env 路径基于 $APP_DIR 推导,避免“先 cd 再算 SCRIPT_DIR”的相对路径错位。
ENV_FILE="$APP_DIR/../.env"
if [ -f "$ENV_FILE" ]; then
set -a
source "$ENV_FILE"
set +a
fi
# 检查虚拟环境