fix: 同步引擎重写——外键重映射、自然键去重、墓碑传播;服务端冲突比较修正(双设备测试 12/12)

This commit is contained in:
root
2026-10-16 14:54:29 +08:00
parent 3f7b072ac6
commit dba22765a6
9 changed files with 68796 additions and 112 deletions
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-Cr7cLzMr.js","./index-D4g5kDSe.js","./index-B9eVtom_.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-D4g5kDSe.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-Cr7cLzMr.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-C9gz15N3.js","./index-o1hiWX9Y.js","./index-B9eVtom_.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-o1hiWX9Y.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-C9gz15N3.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -8,7 +8,7 @@
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
<title>实习工作日志</title>
<script type="module" crossorigin src="./assets/index-D4g5kDSe.js"></script>
<script type="module" crossorigin src="./assets/index-o1hiWX9Y.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B9eVtom_.css">
</head>
<body>
File diff suppressed because one or more lines are too long
+114
View File
@@ -0,0 +1,114 @@
import 'fake-indexeddb/auto'
import { bindDatabase, WorklogDB } from '../src/db/schema'
const BASE = 'http://127.0.0.1:3780'
let pass = 0
let fail = 0
function check(name: string, ok: boolean, detail?: string): void {
if (ok) { pass++; console.log(' PASS ' + name) }
else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
}
async function main() {
// 云端注册(清空服务端数据后全新注册)
await fetch(BASE + '/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'twodev', password: 'dev123456' })
}).catch(() => { /* 已存在则忽略 */ })
const engine = await import('../src/sync/engine')
// ===== 设备 A:创建项目、工作日志、日志条目、设置 =====
bindDatabase(new WorklogDB('dev_a'))
const a = (await import('../src/db/schema')).db
await a.project.bulkAdd([{ name: '项目A', archived: 0, created_at: '2025-01-01T00:00:00' }])
await a.work_log.bulkAdd([{ date: '2025-01-02', created_at: '2025-01-02T00:00:00' }])
await a.setting.bulkAdd([{ key: 'work_start', value: '09:00' }])
await a.table('log_item').bulkAdd([{
work_log_id: 1, content: 'A 设备的日志', project_id: 1, category: 'dev',
minutes: 60, is_milestone: 1, needs_followup: 0, takeaway: null,
remark: null, sort_order: 0, created_at: '2025-01-02T10:00:00'
}])
const rA = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
console.log('设备A 推送:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
// ===== 设备 B:全新空库,拉取设备 A 数据 =====
bindDatabase(new WorklogDB('dev_b'))
const b = (await import('../src/db/schema')).db
const rB1 = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
console.log('设备B 拉取:', JSON.stringify({ p: rB1.pushed, e: rB1.errors }))
const bProjects = await b.project.toArray()
const bWorkLogs = await b.work_log.toArray()
const bItems = await b.log_item.toArray()
const bSettings = await b.setting.toArray()
check('B 拉到项目', bProjects.length === 1 && bProjects[0].name === '项目A')
check('B 拉到工作日志', bWorkLogs.length === 1 && bWorkLogs[0].date === '2025-01-02')
check('B 拉到日志条目', bItems.length === 1 && bItems[0].content === 'A 设备的日志')
check('B 外键重映射(log_item.work_log_id 指向本地 work_log)', bItems[0].work_log_id === bWorkLogs[0].id)
check('B 外键重映射(log_item.project_id 指向本地 project)', bItems[0].project_id === bProjects[0].id)
check('B 拉到设置', bSettings.some(s => s.key === 'work_start' && s.value === '09:00'))
// ===== 设备 B:打卡(创建当日考勤与工作日志)+ 新日志条目,推送 =====
await b.attendance.bulkAdd([{ date: '2025-01-02', clock_in_at: '2025-01-02T09:00:00', clock_out_at: null, kind: 'normal', note: null, created_at: '2025-01-02T09:00:00' }])
await b.table('log_item').add({
work_log_id: bWorkLogs[0].id, content: 'B 设备的日志', project_id: null, category: 'dev',
minutes: null, is_milestone: 0, needs_followup: 1, takeaway: null,
remark: null, sort_order: 0, created_at: '2025-01-02T11:00:00'
})
const rB2 = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
console.log('设备B 二次推送:', JSON.stringify({ p: rB2.pushed, e: rB2.errors }))
// ===== 设备 A:拉取 B 的数据(考勤 + 新日志条目,含自然键去重场景)=====
bindDatabase(new WorklogDB('dev_a'))
const a2 = (await import('../src/db/schema')).db
const rA2 = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
console.log('设备A 拉取:', JSON.stringify({ p: rA2.pushed, e: rA2.errors }))
const aAtt = await a2.attendance.toArray()
const aItems = await a2.log_item.toArray()
const aWorkLogs = await a2.work_log.toArray()
check('A 拉到 B 的考勤', aAtt.length === 1 && aAtt[0].date === '2025-01-02')
check('A 拉到 B 的日志条目', aItems.some(i => i.content === 'B 设备的日志'))
check('A 无重复工作日志(自然键去重)', aWorkLogs.length === 1)
check('A 的日志条目仍然可见(未因去重丢失)', aItems.length >= 2)
// ===== 冲突场景:两端为同一天各自创建工作日志后合并 =====
bindDatabase(new WorklogDB('dev_b'))
const b2 = (await import('../src/db/schema')).db
await b2.work_log.bulkPut([{ date: '2025-01-03', created_at: '2025-01-03T00:00:00', uuid: 'b-day3', updated_at: '2025-01-03T08:00:00' }])
await b2.table('log_item').add({
work_log_id: 2, content: 'B 的 01-03 记录', project_id: null, category: 'dev',
minutes: null, is_milestone: 0, needs_followup: 0, takeaway: null,
remark: null, sort_order: 0, created_at: '2025-01-03T08:00:00', uuid: 'item-b-day3', updated_at: '2025-01-03T08:00:00'
})
await b2.table('log_item').add({
work_log_id: 999, content: 'B 的 01-03 记录(重映射目标)', project_id: null, category: 'dev',
minutes: null, is_milestone: 0, needs_followup: 0, takeaway: null,
remark: null, sort_order: 0, created_at: '2025-01-03T08:30:00', uuid: 'item-b-day3b', updated_at: '2025-01-03T08:30:00', wuuid: 'b-day3'
})
await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
bindDatabase(new WorklogDB('dev_a'))
const a3 = (await import('../src/db/schema')).db
await a3.work_log.bulkAdd([{ date: '2025-01-03', created_at: '2025-01-03T07:00:00', uuid: 'a-day3', updated_at: '2025-01-03T07:00:00' }])
await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
const a3Logs = await a3.work_log.toArray()
const day3 = a3Logs.filter(l => l.date === '2025-01-03')
check('冲突合并后同日仅一条工作日志', day3.length === 1)
console.log(' DEBUG day3:', JSON.stringify(day3))
const a3Items = await a3.log_item.toArray()
const day3Items = a3Items.filter(i => i.content.indexOf('01-03') >= 0)
console.log(' DEBUG day3Items:', JSON.stringify(day3Items))
check('冲突合并后 B 的 01-03 条目保留且外键正确', day3Items.length === 2 && day3Items.every(i => i.work_log_id === day3[0].id))
console.log('==== 结果: PASS ' + pass + ' / FAIL ' + fail + ' ====')
process.exit(fail > 0 ? 1 : 0)
}
main().catch(e => {
console.error('FAILED:', (e && (e.stack || e.message)) || e)
process.exit(1)
})
+234 -79
View File
@@ -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 }
}
+47 -3
View File
@@ -14,6 +14,15 @@ const ITER = 150000
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据
const NATURAL_KEYS = {
project: 'name',
work_log: 'date',
attendance: 'date',
weekly_note: 'week_start',
setting: 'key'
}
fs.mkdirSync(DATA_DIR, { recursive: true })
// ---------- 存储辅助 ----------
@@ -221,21 +230,56 @@ async function handle(req, res) {
if (!userData.tables) userData.tables = {}
if (!userData.tombstones) userData.tombstones = {}
// 应用推送的变更(新者胜)
// 应用推送的变更(新者胜 + 自然键去重)
const incomingTables = body.tables || {}
let changed = 0
const now = new Date().toISOString()
for (const t of TABLES) {
const rows = incomingTables[t] || []
userData.tables[t] = userData.tables[t] || {}
const nk = NATURAL_KEYS[t] || null
for (const row of rows) {
if (!row || !row.uuid) continue
const clean = {}
for (const k of Object.keys(row)) if (k !== 'id') clean[k] = row[k]
// 自然键冲突:另一 uuid 的记录已占用同一自然键
let conflictUuid = null
if (nk) {
const nkVal = String(clean[nk] || '')
if (nkVal) {
for (const u2 of Object.keys(userData.tables[t])) {
if (u2 === row.uuid) continue
if (String(userData.tables[t][u2][nk] || '') === nkVal) { conflictUuid = u2; break }
}
}
}
const existing = userData.tables[t][row.uuid]
if (!existing || String(clean.updated_at || '') >= String(existing.updated_at || '')) {
userData.tables[t][row.uuid] = clean
if (conflictUuid) {
// 与自然键冲突行比较新者(而非与同 uuid 旧行)
const conflictRow = userData.tables[t][conflictUuid]
const incomingNewer = String(clean.updated_at || '') >= String(conflictRow.updated_at || '')
if (!incomingNewer) {
// 本记录较旧:加墓碑让推送端删除它
userData.tombstones[t] = userData.tombstones[t] || {}
userData.tombstones[t][row.uuid] = now
changed++
continue
}
// 本记录较新:删除冲突副本并加墓碑通知其他端
if (t === 'work_log') {
// 冲突的工作日志被删时,其日志条目改挂到幸存者
for (const iu of Object.keys(userData.tables.log_item || {})) {
const it = userData.tables.log_item[iu]
if (String(it.wuuid || '') === conflictUuid) { it.wuuid = row.uuid; changed++ }
}
}
delete userData.tables[t][conflictUuid]
userData.tombstones[t] = userData.tombstones[t] || {}
userData.tombstones[t][conflictUuid] = now
changed++
}
userData.tables[t][row.uuid] = clean
changed++
}
}
// 应用墓碑