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>
This commit is contained in:
ericwyuan
2026-09-13 08:03:32 +08:00
parent dc284f4bf5
commit 1964e976f4
33 changed files with 1178 additions and 1187 deletions

View File

@@ -1,11 +1,10 @@
<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 checkAuth() {
@@ -17,14 +16,6 @@ async function checkAuth() {
}
}
async function refreshStatus() {
try {
const data = await api.status()
sync.value = data.sync
} catch {
sync.value = null
}
}
async function doLogout() {
try {
@@ -36,31 +27,17 @@ async function doLogout() {
window.location.href = '/'
}
let timer = null
onMounted(() => {
checkAuth()
refreshStatus()
timer = setInterval(refreshStatus, 30000)
})
onUnmounted(() => clearInterval(timer))
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>
<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">
@@ -73,7 +50,7 @@ onUnmounted(() => clearInterval(timer))
</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>
<div class="flex items-center gap-2">
@@ -85,9 +62,6 @@ onUnmounted(() => clearInterval(timer))
class="rounded-lg border border-border bg-panel-2 px-3 py-1.5 text-xs font-medium text-text-dim">
退出
</button>
<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>
</header>

View File

@@ -63,9 +63,7 @@ export const api = {
mergeMember: (source, target) =>
request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }),
status: () => request('/api/status'),
authCheck: () => request('/api/auth/check'),
syncTrigger: () => request('/api/sync/trigger', { method: 'POST' }),
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>