feat: 准实时同步(变更 3 秒推送 + SSE 秒级拉取)并修复云端认证

This commit is contained in:
root
2026-10-16 11:45:20 +08:00
parent 052158d96d
commit ba07899dbe
4 changed files with 168 additions and 38 deletions
+7 -24
View File
@@ -11,8 +11,7 @@ import VaultView from './views/VaultView.vue'
import SettingsView from './views/SettingsView.vue'
import LoginView from './views/LoginView.vue'
import { getSession, logoutUser, openUserDatabase, type Session } from './db/auth'
import { getSetting, } from './db/repo'
import { isSyncConfigured, runSync } from './sync/engine'
import { autoSyncNow, enableRealtimeSync, stopRealtime } from './sync/engine'
import { clockIn, getAttendance, getWorkRule, getWeeklyNote, searchLogItems, searchMemos, smartClockOut, type WorkRule } from './db/repo'
import { addDays, hhmm, todayStr, weekStart } from './lib/date'
import { useToasts } from './lib/toast'
@@ -38,6 +37,8 @@ async function onLogin(s: Session) {
}
}
async function doLogout() {
stopRealtime()
if (syncTimer !== null) { window.clearInterval(syncTimer); syncTimer = null }
await logoutUser()
session.value = null
route.value = 'calendar'
@@ -45,31 +46,13 @@ async function doLogout() {
showToast('已退出登录')
}
// ---- 自动同步:登录后与每 5 分钟,静默执行 ----
// ---- 实时同步:本地变更 3 秒推送 + SSE 秒级拉取 + 60 秒兜底 ----
let syncTimer: number | null = null
let lastSyncToast = 0
async function maybeAutoSync() {
if (!session.value) return
try {
if (!(await isSyncConfigured())) return
const result = await runSync({
baseUrl: await getSetting('sync_base_url', ''),
username: await getSetting('sync_username', ''),
password: await getSetting('sync_password', '')
})
if (result.pushed || result.pulled || result.removed) showToast('云同步完成:推送 ' + result.pushed + ' · 拉取 ' + result.pulled)
} catch (e) {
const now = Date.now()
if (now - lastSyncToast > 10 * 60 * 1000) {
lastSyncToast = now
showToast(e instanceof Error ? e.message : '自动同步失败')
}
}
}
function startSyncTimer() {
void enableRealtimeSync()
if (syncTimer !== null) window.clearInterval(syncTimer)
syncTimer = window.setInterval(() => { maybeAutoSync() }, 5 * 60 * 1000)
maybeAutoSync()
syncTimer = window.setInterval(() => { void autoSyncNow() }, 60 * 1000)
void autoSyncNow()
}
// ---- 全局搜索 ----
+116 -13
View File
@@ -1,4 +1,5 @@
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 软删除墓碑
// 实时性:本地变更 3 秒内推送;服务端变更通过 SSE 秒级通知其他端;60 秒轮询兜底
import { db } from '../db/schema'
import { getSetting, setSetting } from '../db/repo'
import { nowLocalISO } from '../lib/date'
@@ -6,7 +7,7 @@ import { nowLocalISO } from '../lib/date'
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
// 本地敏感或临时性的设置不参与同步
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'last_project', 'last_category'])
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'sync_token', 'last_project', 'last_category'])
export const SYNC_TABLES = TABLES
@@ -95,8 +96,105 @@ export async function registerCloudAccount(baseUrl: string, username: string, pa
if (!resp.ok) throw new Error('云端注册失败:' + resp.status)
}
// 执行一次完整同步
// ---------- 云端令牌(登录换取,本地缓存,过期自动重登) ----------
async function loginCloud(base: string, config: SyncConfig): Promise<string> {
const resp = await fetch(base + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: config.username.trim(), password: config.password })
})
if (!resp.ok) throw new Error('云端账号或密码不正确')
const j = await resp.json()
if (!j || !j.token) throw new Error('云端登录异常')
return String(j.token)
}
async function ensureToken(base: string, config: SyncConfig): Promise<string> {
let token = await getSetting('sync_token', '')
if (token) return token
token = await loginCloud(base, config)
await setSetting('sync_token', token)
return token
}
// ---------- 并发保护与实时触发 ----------
let syncRunning = false
let pendingSync = false
let changesSuppressed = false
let changeDebounce: number | null = null
let sse: EventSource | null = null
let sseKey = ''
let realtimeAttached = false
export async function autoSyncNow(): Promise<void> {
if (syncRunning) {
pendingSync = true
return
}
try {
if (!(await isSyncConfigured())) return
const values = await Promise.all([
getSetting('sync_base_url', ''),
getSetting('sync_username', ''),
getSetting('sync_password', '')
])
await runSync({ baseUrl: values[0], username: values[1], password: values[2] })
} catch { /* 自动同步静默失败,等待下次触发 */ }
}
// 实时触发:本地任何数据变更 3 秒后自动推送
export function enableRealtimeSync(): void {
if (realtimeAttached) return
realtimeAttached = true
;(db as unknown as AnyRow).on('changes', () => {
if (changesSuppressed || syncRunning) return
if (changeDebounce) window.clearTimeout(changeDebounce)
changeDebounce = window.setTimeout(() => { void autoSyncNow() }, 3000)
})
}
export function stopRealtime(): void {
if (sse) { sse.close(); sse = null; sseKey = '' }
if (changeDebounce) { window.clearTimeout(changeDebounce); changeDebounce = null }
}
// 服务端 SSE 通知:其他端有变更时立即拉取
function ensureSSE(base: string, token: string): void {
const key = base + '|' + token
if (sse && sseKey === key) return
if (sse) { sse.close(); sse = null }
try {
sse = new EventSource(base + '/api/events?token=' + encodeURIComponent(token))
sseKey = key
sse.onmessage = () => { void autoSyncNow() }
} catch { sse = null }
}
// ---------- 执行同步 ----------
export async function runSync(config: SyncConfig): Promise<SyncResult> {
if (syncRunning) {
pendingSync = true
return { pushed: 0, pulled: 0, removed: 0, at: '' }
}
syncRunning = true
changesSuppressed = true
try {
const result = await doRunSync(config)
if (pendingSync) {
pendingSync = false
window.setTimeout(() => { void autoSyncNow() }, 800)
}
return result
} finally {
syncRunning = false
changesSuppressed = false
}
}
async function doRunSync(config: SyncConfig): Promise<SyncResult> {
const base = config.baseUrl.trim().replace(/\/+$/, '')
if (!base) throw new Error('请先配置同步服务地址')
await ensureSyncFields()
@@ -134,21 +232,23 @@ export async function runSync(config: SyncConfig): Promise<SyncResult> {
pushTables[t] = pushTables[t] || []
}
// 2. 与服务端交换
let resp: Response
try {
// 2. 认证并与服务端交换(token 失效自动重登一次)
const token = await ensureToken(base, config)
let resp = await fetch(base + '/api/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
body: JSON.stringify({ device: 'client', tables: pushTables, tombstones })
})
if (resp.status === 401) {
await setSetting('sync_token', '')
const token2 = await loginCloud(base, config)
await setSetting('sync_token', token2)
resp = await fetch(base + '/api/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + config.username.trim() + ':' + config.password
},
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token2 },
body: JSON.stringify({ device: 'client', tables: pushTables, tombstones })
})
} catch {
throw new Error('无法访问同步服务:请检查地址、端口与网络')
}
if (resp.status === 401) throw new Error('云端账号或密码不正确')
if (!resp.ok) throw new Error('同步服务返回 ' + resp.status)
const data = await resp.json()
const serverTables: Record<string, AnyRow[]> = data.tables || {}
@@ -166,6 +266,8 @@ export async function runSync(config: SyncConfig): Promise<SyncResult> {
for (const row of localRows) byUuid.set(row.uuid, row)
const serverRows: AnyRow[] = serverTables[t] || []
const serverByUuid = new Map<string, AnyRow>()
for (const row of serverRows) serverByUuid.set(row.uuid, row)
const dead = new Set(serverTombstones.filter(x => x.table === t).map(x => x.uuid))
for (const [u, row] of byUuid) {
@@ -196,9 +298,10 @@ export async function runSync(config: SyncConfig): Promise<SyncResult> {
}
}
// 4. 重建镜像
// 4. 重建镜像 + 建立 SSE 实时通道
await db.sync_mirror.clear()
if (finalMirror.length) await db.sync_mirror.bulkAdd(finalMirror.map(m => ({ ...m })))
ensureSSE(base, token)
const at = nowLocalISO()
await setSetting('sync_last_time', at)
return { pushed, pulled, removed, at }