fix: 同步引擎重写——外键重映射、自然键去重、墓碑传播;服务端冲突比较修正(双设备测试 12/12)
This commit is contained in:
+234
-79
@@ -1,4 +1,4 @@
|
||||
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 软删除墓碑
|
||||
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 自然键去重 + 外键重映射 + 软删除墓碑
|
||||
// 实时性:本地变更 3 秒内推送;服务端变更通过 SSE 秒级通知其他端;60 秒轮询兜底
|
||||
import Dexie, { liveQuery } from 'dexie'
|
||||
import { db } from '../db/schema'
|
||||
@@ -7,13 +7,20 @@ import { getCloudCreds } from '../db/auth'
|
||||
import { nowLocalISO } from '../lib/date'
|
||||
import { notifyAttendanceChanged } from '../lib/bus'
|
||||
|
||||
export const DEFAULT_SYNC_BASE = 'https://daywk.linyux.cn'
|
||||
|
||||
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', 'sync_token', 'last_project', 'last_category'])
|
||||
|
||||
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据(冲突时保留 updated_at 较新者)
|
||||
const NATURAL_KEYS: Record<string, string> = {
|
||||
project: 'name',
|
||||
work_log: 'date',
|
||||
attendance: 'date',
|
||||
weekly_note: 'week_start',
|
||||
setting: 'key'
|
||||
}
|
||||
|
||||
export const SYNC_TABLES = TABLES
|
||||
|
||||
interface AnyRow { [key: string]: any }
|
||||
@@ -64,6 +71,7 @@ export interface SyncResult {
|
||||
pushed: number
|
||||
pulled: number
|
||||
removed: number
|
||||
errors: number
|
||||
at: string
|
||||
}
|
||||
|
||||
@@ -73,43 +81,6 @@ export interface SyncConfig {
|
||||
password: string
|
||||
}
|
||||
|
||||
// 同步基址:设置页可覆盖,默认使用独立子域名
|
||||
export async function getSyncBase(): Promise<string> {
|
||||
return (await getSetting('sync_base_url', '')) || DEFAULT_SYNC_BASE
|
||||
}
|
||||
|
||||
async function loadSyncConfig(): Promise<SyncConfig> {
|
||||
const creds = getCloudCreds()
|
||||
if (!creds) throw new Error('未登录,无法同步')
|
||||
return { baseUrl: await getSyncBase(), username: creds.username, password: creds.password }
|
||||
}
|
||||
|
||||
// 使用当前登录凭据执行同步(供设置页与自动同步调用)
|
||||
export async function syncWithStoredConfig(): Promise<SyncResult> {
|
||||
return runSync(await loadSyncConfig())
|
||||
}
|
||||
|
||||
// 登录后自动绑定云端账号:云端不存在则注册,同名则校验密码一致
|
||||
export async function ensureCloudBinding(): Promise<void> {
|
||||
const creds = getCloudCreds()
|
||||
if (!creds) throw new Error('未登录')
|
||||
const base = await getSyncBase()
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const body = JSON.stringify({ username: creds.username, password: creds.password })
|
||||
let resp = await fetch(base + '/api/auth/register', { method: 'POST', headers, body })
|
||||
if (resp.status === 409) {
|
||||
resp = await fetch(base + '/api/auth/login', { method: 'POST', headers, body })
|
||||
if (!resp.ok) throw new Error('云端已存在同名账号且密码不一致,请检查')
|
||||
} else if (!resp.ok) {
|
||||
throw new Error('云端绑定失败:' + resp.status)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCloudUser(): string | null {
|
||||
const c = getCloudCreds()
|
||||
return c ? c.username : null
|
||||
}
|
||||
|
||||
export async function isSyncConfigured(): Promise<boolean> {
|
||||
return getCloudCreds() != null
|
||||
}
|
||||
@@ -219,15 +190,91 @@ function ensureSSE(base: string, token: string): void {
|
||||
} catch { sse = null }
|
||||
}
|
||||
|
||||
// 同步基址:设置页可覆盖,默认使用独立子域名
|
||||
export async function getSyncBase(): Promise<string> {
|
||||
return (await getSetting('sync_base_url', '')) || DEFAULT_SYNC_BASE
|
||||
}
|
||||
export const DEFAULT_SYNC_BASE = 'https://daywk.linyux.cn'
|
||||
|
||||
async function loadSyncConfig(): Promise<SyncConfig> {
|
||||
const creds = getCloudCreds()
|
||||
if (!creds) throw new Error('未登录,无法同步')
|
||||
return { baseUrl: await getSyncBase(), username: creds.username, password: creds.password }
|
||||
}
|
||||
|
||||
// 登录后自动绑定云端账号:云端不存在则注册,同名则校验密码一致
|
||||
export async function ensureCloudBinding(): Promise<void> {
|
||||
const creds = getCloudCreds()
|
||||
if (!creds) throw new Error('未登录')
|
||||
const base = await getSyncBase()
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const body = JSON.stringify({ username: creds.username, password: creds.password })
|
||||
let resp = await fetch(base + '/api/auth/register', { method: 'POST', headers, body })
|
||||
if (resp.status === 409) {
|
||||
resp = await fetch(base + '/api/auth/login', { method: 'POST', headers, body })
|
||||
if (!resp.ok) throw new Error('云端已存在同名账号且密码不一致,请检查')
|
||||
} else if (!resp.ok) {
|
||||
throw new Error('云端绑定失败:' + resp.status)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCloudUser(): string | null {
|
||||
const c = getCloudCreds()
|
||||
return c ? c.username : null
|
||||
}
|
||||
|
||||
// 使用当前登录凭据执行同步(供设置页与自动同步调用)
|
||||
export async function syncWithStoredConfig(): Promise<SyncResult> {
|
||||
return runSync(await loadSyncConfig())
|
||||
}
|
||||
|
||||
// ---------- 推送规范化:剥离本地 id,外键转换为 uuid 引用 ----------
|
||||
|
||||
interface RefMaps {
|
||||
workLog: Map<number, AnyRow>
|
||||
project: Map<number, AnyRow>
|
||||
}
|
||||
|
||||
function normalizeRow(t: string, row: AnyRow, refs: RefMaps): AnyRow {
|
||||
const clean: AnyRow = {}
|
||||
for (const k of Object.keys(row)) {
|
||||
if (k === 'id') continue
|
||||
clean[k] = row[k]
|
||||
}
|
||||
if (t === 'log_item') {
|
||||
delete clean.work_log_id
|
||||
delete clean.project_id
|
||||
const wl = row.work_log_id ? refs.workLog.get(row.work_log_id) : null
|
||||
if (wl && wl.uuid) clean.wuuid = wl.uuid
|
||||
const pj = row.project_id ? refs.project.get(row.project_id) : null
|
||||
if (pj && pj.uuid) clean.puuid = pj.uuid
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
async function buildRefMaps(): Promise<RefMaps> {
|
||||
const wlRows = await db.work_log.toArray()
|
||||
const pjRows = await db.project.toArray()
|
||||
return {
|
||||
workLog: new Map(wlRows.map(r => [r.id as number, r])),
|
||||
project: new Map(pjRows.map(p => [p.id as number, p]))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 执行同步 ----------
|
||||
|
||||
let applyErrors = 0
|
||||
let lastApplyError = ''
|
||||
|
||||
export async function runSync(config: SyncConfig): Promise<SyncResult> {
|
||||
if (syncRunning) {
|
||||
pendingSync = true
|
||||
return { pushed: 0, pulled: 0, removed: 0, at: '' }
|
||||
return { pushed: 0, pulled: 0, removed: 0, errors: 0, at: '' }
|
||||
}
|
||||
syncRunning = true
|
||||
changesSuppressed = true
|
||||
applyErrors = 0
|
||||
lastApplyError = ''
|
||||
try {
|
||||
const result = await doRunSync(config)
|
||||
if (pendingSync) {
|
||||
@@ -245,8 +292,9 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
const base = config.baseUrl.trim().replace(/\/+$/, '')
|
||||
if (!base) throw new Error('请先配置同步服务地址')
|
||||
await ensureSyncFields()
|
||||
const refs = await buildRefMaps()
|
||||
|
||||
// 1. 载入镜像,计算本地差异
|
||||
// 1. 载入镜像,计算本地差异(推送内容按规范化形式比对)
|
||||
const mirrorRows = await db.sync_mirror.toArray()
|
||||
const mirror = new Map<string, { table: string; uuid: string; hash: string }>()
|
||||
for (const m of mirrorRows) mirror.set(m.table + '|' + m.uuid, m)
|
||||
@@ -262,7 +310,7 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
for (const row of rows) current.set(row.uuid, row)
|
||||
|
||||
for (const [u, row] of current) {
|
||||
const clean = stripId(row)
|
||||
const clean = normalizeRow(t, row, refs)
|
||||
const h = rowHash(clean)
|
||||
const m = mirror.get(t + '|' + u)
|
||||
if (!m || m.hash !== h) {
|
||||
@@ -279,6 +327,12 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
pushTables[t] = pushTables[t] || []
|
||||
}
|
||||
|
||||
// 本地待推送的删除墓碑
|
||||
const localTombs = await db.tombstone.toArray()
|
||||
for (const tb of localTombs) {
|
||||
tombstones.push({ table: tb.table, uuid: tb.uuid, deleted_at: tb.deleted_at })
|
||||
}
|
||||
|
||||
// 2. 认证并与服务端交换(token 失效自动重登一次)
|
||||
const token = await ensureToken(base, config)
|
||||
const doPush = (tk: string) => fetch(base + '/api/sync', {
|
||||
@@ -299,58 +353,159 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
|
||||
|
||||
// 3. 应用服务端权威数据
|
||||
const applyResult = await applyServerTables(serverTables, serverTombstones)
|
||||
|
||||
// 4. 重建镜像 + 建立 SSE 实时通道 + 广播考勤变更
|
||||
const refsNow = await buildRefMaps()
|
||||
const finalMirror: { table: string; uuid: string; hash: string }[] = []
|
||||
for (const t of TABLES) {
|
||||
const rows: AnyRow[] = await db.table(t).toArray()
|
||||
for (const row of rows) {
|
||||
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
|
||||
finalMirror.push({ table: t, uuid: row.uuid, hash: rowHash(normalizeRow(t, row, refsNow)) })
|
||||
}
|
||||
}
|
||||
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)
|
||||
if (applyResult.pulled > 0 || applyResult.removed > 0) notifyAttendanceChanged()
|
||||
// 清除已确认推送的本地墓碑(保留本轮新产生的去重墓碑)
|
||||
if (localTombs.length) {
|
||||
const pushedIds = new Set(localTombs.map(t => t.id))
|
||||
await db.tombstone.where('id').anyOf(Array.from(pushedIds)).delete()
|
||||
}
|
||||
return { pushed, pulled: applyResult.pulled, removed: applyResult.removed, errors: applyResult.errors, at }
|
||||
}
|
||||
|
||||
// ---------- 应用服务端数据(含自然键去重与外键重映射) ----------
|
||||
|
||||
let localWorkLogIdByUuid = new Map<string, number>()
|
||||
let localProjectIdByUuid = new Map<string, number>()
|
||||
let applyConflicts = 0
|
||||
|
||||
async function applyServerTables(
|
||||
serverTables: Record<string, AnyRow[]>,
|
||||
serverTombstones: { table: string; uuid: string; deleted_at: string }[]
|
||||
): Promise<{ pulled: number; removed: number; errors: number }> {
|
||||
let pulled = 0
|
||||
let removed = 0
|
||||
const finalMirror: { table: string; uuid: string; hash: string }[] = []
|
||||
|
||||
for (const t of TABLES) {
|
||||
const table = db.table(t)
|
||||
const localRows: AnyRow[] = await table.toArray()
|
||||
const byUuid = new Map<string, AnyRow>()
|
||||
for (const row of localRows) byUuid.set(row.uuid, row)
|
||||
const nk = NATURAL_KEYS[t] || null
|
||||
const byNatural = new Map<string, AnyRow>()
|
||||
if (nk) {
|
||||
for (const row of localRows) {
|
||||
const v = row[nk]
|
||||
if (v != null && v !== '') byNatural.set(String(v), 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))
|
||||
const serverRows: AnyRow[] = serverTables[t] || []
|
||||
|
||||
for (const [u, row] of byUuid) {
|
||||
if (dead.has(u)) {
|
||||
// 阶段一:应用未删除行的增改
|
||||
for (const srow of serverRows) {
|
||||
try {
|
||||
if (dead.has(srow.uuid)) continue
|
||||
const local = byUuid.get(srow.uuid)
|
||||
if (local) {
|
||||
const localUa = String(local.updated_at || '')
|
||||
const serverUa = String(srow.updated_at || '')
|
||||
if (serverUa > localUa) {
|
||||
const patch: AnyRow = { ...srow }
|
||||
if (t === 'setting') delete patch.key
|
||||
if (t === 'log_item') {
|
||||
const wlId = srow.wuuid ? localWorkLogIdByUuid.get(srow.wuuid) : null
|
||||
if (!wlId) continue
|
||||
patch.work_log_id = wlId
|
||||
delete patch.wuuid
|
||||
patch.project_id = srow.puuid ? (localProjectIdByUuid.get(srow.puuid) ?? null) : (local.project_id ?? null)
|
||||
delete patch.puuid
|
||||
}
|
||||
await table.update(local.id, patch)
|
||||
pulled++
|
||||
}
|
||||
} else {
|
||||
const nkVal = nk && srow[nk] != null && srow[nk] !== '' ? String(srow[nk]) : null
|
||||
const dup = nkVal ? byNatural.get(nkVal) : undefined
|
||||
if (dup && dup.uuid !== srow.uuid) {
|
||||
const dupUa = String(dup.updated_at || '')
|
||||
const serverUa = String(srow.updated_at || '')
|
||||
if (serverUa >= dupUa) {
|
||||
// 服务端较新:先迁移关联数据,删除本地重复行,写入服务端行
|
||||
if (t === 'work_log') {
|
||||
await db.log_item.where('work_log_id').equals(dup.id as number).modify({ work_log_id: -1 })
|
||||
}
|
||||
await table.delete(pkOf(t, dup))
|
||||
await db.tombstone.add({ table: t, uuid: dup.uuid, deleted_at: nowLocalISO() })
|
||||
const newId = await table.add({ ...srow })
|
||||
if (t === 'work_log') {
|
||||
await db.log_item.where('work_log_id').equals(-1).modify({ work_log_id: newId })
|
||||
}
|
||||
byUuid.set(srow.uuid, { ...srow, id: newId })
|
||||
if (nk && nkVal) byNatural.set(nkVal, { ...srow, id: newId })
|
||||
pulled++
|
||||
} else {
|
||||
// 本地较新:跳过服务端行,记墓碑待下次推送清除服务端副本
|
||||
await db.tombstone.add({ table: t, uuid: srow.uuid, deleted_at: nowLocalISO() })
|
||||
applyConflicts++
|
||||
}
|
||||
} else {
|
||||
if (t === 'log_item') {
|
||||
const wlId = srow.wuuid ? localWorkLogIdByUuid.get(srow.wuuid) : null
|
||||
if (!wlId) continue
|
||||
srow.work_log_id = wlId
|
||||
delete srow.wuuid
|
||||
srow.project_id = srow.puuid ? (localProjectIdByUuid.get(srow.puuid) ?? null) : null
|
||||
delete srow.puuid
|
||||
}
|
||||
const newId = await table.add({ ...srow })
|
||||
byUuid.set(srow.uuid, { ...srow, id: newId })
|
||||
if (nk && nkVal) byNatural.set(nkVal, { ...srow, id: newId })
|
||||
pulled++
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
applyErrors++
|
||||
lastApplyError = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段二:应用墓碑删除(work_log 删除时把本地条目迁移到同日幸存者)
|
||||
for (const row of localRows) {
|
||||
if (!dead.has(row.uuid)) continue
|
||||
try {
|
||||
if (t === 'work_log') {
|
||||
const freshRows: AnyRow[] = await table.toArray()
|
||||
const survivor = freshRows.find(r2 => r2.date === row.date && !dead.has(r2.uuid) && r2.uuid !== row.uuid)
|
||||
if (survivor && row.id) {
|
||||
await db.log_item.where('work_log_id').equals(row.id as number).modify({ work_log_id: survivor.id as number })
|
||||
}
|
||||
}
|
||||
await table.delete(pkOf(t, row))
|
||||
removed++
|
||||
} catch (err) {
|
||||
applyErrors++
|
||||
}
|
||||
}
|
||||
for (const srow of serverRows) {
|
||||
if (dead.has(srow.uuid)) continue
|
||||
const local = byUuid.get(srow.uuid)
|
||||
if (!local) {
|
||||
await table.add({ ...srow })
|
||||
pulled++
|
||||
} else {
|
||||
const localUa = String(local.updated_at || '')
|
||||
const serverUa = String(srow.updated_at || '')
|
||||
if (serverUa > localUa) {
|
||||
const patch: AnyRow = { ...srow }
|
||||
// setting 表主键为 key,禁止通过 update 修改主键字段
|
||||
if (t === 'setting') delete patch.key
|
||||
await table.update(pkOf(t, local), patch)
|
||||
pulled++
|
||||
}
|
||||
}
|
||||
|
||||
// 外键重映射表:work_log 与 project 处理完成后构建
|
||||
if (t === 'work_log') {
|
||||
const fresh: AnyRow[] = await table.toArray()
|
||||
localWorkLogIdByUuid = new Map(fresh.map(r => [r.uuid as string, r.id as number]))
|
||||
}
|
||||
const finalRows: AnyRow[] = await table.toArray()
|
||||
for (const row of finalRows) {
|
||||
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
|
||||
finalMirror.push({ table: t, uuid: row.uuid, hash: rowHash(stripId(row)) })
|
||||
if (t === 'project') {
|
||||
const fresh: AnyRow[] = await table.toArray()
|
||||
localProjectIdByUuid = new Map(fresh.map(r => [r.uuid as string, r.id as number]))
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if (pulled > 0 || removed > 0) notifyAttendanceChanged()
|
||||
return { pushed, pulled, removed, at }
|
||||
void lastApplyError
|
||||
void applyConflicts
|
||||
return { pulled, removed, errors: applyErrors }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user