fix: 同步引擎重写——外键重映射、自然键去重、墓碑传播;服务端冲突比较修正(双设备测试 12/12)
This commit is contained in:
-26
File diff suppressed because one or more lines are too long
+2
-2
@@ -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]);
|
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-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};
|
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};
|
||||||
+26
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||||
<title>实习工作日志</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="./assets/index-B9eVtom_.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 软删除墓碑
|
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 自然键去重 + 外键重映射 + 软删除墓碑
|
||||||
// 实时性:本地变更 3 秒内推送;服务端变更通过 SSE 秒级通知其他端;60 秒轮询兜底
|
// 实时性:本地变更 3 秒内推送;服务端变更通过 SSE 秒级通知其他端;60 秒轮询兜底
|
||||||
import Dexie, { liveQuery } from 'dexie'
|
import Dexie, { liveQuery } from 'dexie'
|
||||||
import { db } from '../db/schema'
|
import { db } from '../db/schema'
|
||||||
@@ -7,13 +7,20 @@ import { getCloudCreds } from '../db/auth'
|
|||||||
import { nowLocalISO } from '../lib/date'
|
import { nowLocalISO } from '../lib/date'
|
||||||
import { notifyAttendanceChanged } from '../lib/bus'
|
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 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'])
|
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
|
export const SYNC_TABLES = TABLES
|
||||||
|
|
||||||
interface AnyRow { [key: string]: any }
|
interface AnyRow { [key: string]: any }
|
||||||
@@ -64,6 +71,7 @@ export interface SyncResult {
|
|||||||
pushed: number
|
pushed: number
|
||||||
pulled: number
|
pulled: number
|
||||||
removed: number
|
removed: number
|
||||||
|
errors: number
|
||||||
at: string
|
at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,43 +81,6 @@ export interface SyncConfig {
|
|||||||
password: string
|
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> {
|
export async function isSyncConfigured(): Promise<boolean> {
|
||||||
return getCloudCreds() != null
|
return getCloudCreds() != null
|
||||||
}
|
}
|
||||||
@@ -219,15 +190,91 @@ function ensureSSE(base: string, token: string): void {
|
|||||||
} catch { sse = null }
|
} 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> {
|
export async function runSync(config: SyncConfig): Promise<SyncResult> {
|
||||||
if (syncRunning) {
|
if (syncRunning) {
|
||||||
pendingSync = true
|
pendingSync = true
|
||||||
return { pushed: 0, pulled: 0, removed: 0, at: '' }
|
return { pushed: 0, pulled: 0, removed: 0, errors: 0, at: '' }
|
||||||
}
|
}
|
||||||
syncRunning = true
|
syncRunning = true
|
||||||
changesSuppressed = true
|
changesSuppressed = true
|
||||||
|
applyErrors = 0
|
||||||
|
lastApplyError = ''
|
||||||
try {
|
try {
|
||||||
const result = await doRunSync(config)
|
const result = await doRunSync(config)
|
||||||
if (pendingSync) {
|
if (pendingSync) {
|
||||||
@@ -245,8 +292,9 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
|||||||
const base = config.baseUrl.trim().replace(/\/+$/, '')
|
const base = config.baseUrl.trim().replace(/\/+$/, '')
|
||||||
if (!base) throw new Error('请先配置同步服务地址')
|
if (!base) throw new Error('请先配置同步服务地址')
|
||||||
await ensureSyncFields()
|
await ensureSyncFields()
|
||||||
|
const refs = await buildRefMaps()
|
||||||
|
|
||||||
// 1. 载入镜像,计算本地差异
|
// 1. 载入镜像,计算本地差异(推送内容按规范化形式比对)
|
||||||
const mirrorRows = await db.sync_mirror.toArray()
|
const mirrorRows = await db.sync_mirror.toArray()
|
||||||
const mirror = new Map<string, { table: string; uuid: string; hash: string }>()
|
const mirror = new Map<string, { table: string; uuid: string; hash: string }>()
|
||||||
for (const m of mirrorRows) mirror.set(m.table + '|' + m.uuid, m)
|
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 row of rows) current.set(row.uuid, row)
|
||||||
|
|
||||||
for (const [u, row] of current) {
|
for (const [u, row] of current) {
|
||||||
const clean = stripId(row)
|
const clean = normalizeRow(t, row, refs)
|
||||||
const h = rowHash(clean)
|
const h = rowHash(clean)
|
||||||
const m = mirror.get(t + '|' + u)
|
const m = mirror.get(t + '|' + u)
|
||||||
if (!m || m.hash !== h) {
|
if (!m || m.hash !== h) {
|
||||||
@@ -279,6 +327,12 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
|||||||
pushTables[t] = pushTables[t] || []
|
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 失效自动重登一次)
|
// 2. 认证并与服务端交换(token 失效自动重登一次)
|
||||||
const token = await ensureToken(base, config)
|
const token = await ensureToken(base, config)
|
||||||
const doPush = (tk: string) => fetch(base + '/api/sync', {
|
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 || []
|
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
|
||||||
|
|
||||||
// 3. 应用服务端权威数据
|
// 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 pulled = 0
|
||||||
let removed = 0
|
let removed = 0
|
||||||
const finalMirror: { table: string; uuid: string; hash: string }[] = []
|
|
||||||
|
|
||||||
for (const t of TABLES) {
|
for (const t of TABLES) {
|
||||||
const table = db.table(t)
|
const table = db.table(t)
|
||||||
const localRows: AnyRow[] = await table.toArray()
|
const localRows: AnyRow[] = await table.toArray()
|
||||||
const byUuid = new Map<string, AnyRow>()
|
const byUuid = new Map<string, AnyRow>()
|
||||||
for (const row of localRows) byUuid.set(row.uuid, row)
|
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 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))
|
await table.delete(pkOf(t, row))
|
||||||
removed++
|
removed++
|
||||||
|
} catch (err) {
|
||||||
|
applyErrors++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const srow of serverRows) {
|
|
||||||
if (dead.has(srow.uuid)) continue
|
// 外键重映射表:work_log 与 project 处理完成后构建
|
||||||
const local = byUuid.get(srow.uuid)
|
if (t === 'work_log') {
|
||||||
if (!local) {
|
const fresh: AnyRow[] = await table.toArray()
|
||||||
await table.add({ ...srow })
|
localWorkLogIdByUuid = new Map(fresh.map(r => [r.uuid as string, r.id as number]))
|
||||||
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++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const finalRows: AnyRow[] = await table.toArray()
|
if (t === 'project') {
|
||||||
for (const row of finalRows) {
|
const fresh: AnyRow[] = await table.toArray()
|
||||||
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
|
localProjectIdByUuid = new Map(fresh.map(r => [r.uuid as string, r.id as number]))
|
||||||
finalMirror.push({ table: t, uuid: row.uuid, hash: rowHash(stripId(row)) })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
void lastApplyError
|
||||||
// 4. 重建镜像 + 建立 SSE 实时通道 + 广播考勤变更
|
void applyConflicts
|
||||||
await db.sync_mirror.clear()
|
return { pulled, removed, errors: applyErrors }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-3
@@ -14,6 +14,15 @@ const ITER = 150000
|
|||||||
|
|
||||||
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
|
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 })
|
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||||
|
|
||||||
// ---------- 存储辅助 ----------
|
// ---------- 存储辅助 ----------
|
||||||
@@ -221,21 +230,56 @@ async function handle(req, res) {
|
|||||||
if (!userData.tables) userData.tables = {}
|
if (!userData.tables) userData.tables = {}
|
||||||
if (!userData.tombstones) userData.tombstones = {}
|
if (!userData.tombstones) userData.tombstones = {}
|
||||||
|
|
||||||
// 应用推送的变更(新者胜)
|
// 应用推送的变更(新者胜 + 自然键去重)
|
||||||
const incomingTables = body.tables || {}
|
const incomingTables = body.tables || {}
|
||||||
let changed = 0
|
let changed = 0
|
||||||
|
const now = new Date().toISOString()
|
||||||
for (const t of TABLES) {
|
for (const t of TABLES) {
|
||||||
const rows = incomingTables[t] || []
|
const rows = incomingTables[t] || []
|
||||||
userData.tables[t] = userData.tables[t] || {}
|
userData.tables[t] = userData.tables[t] || {}
|
||||||
|
const nk = NATURAL_KEYS[t] || null
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row || !row.uuid) continue
|
if (!row || !row.uuid) continue
|
||||||
const clean = {}
|
const clean = {}
|
||||||
for (const k of Object.keys(row)) if (k !== 'id') clean[k] = row[k]
|
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]
|
const existing = userData.tables[t][row.uuid]
|
||||||
if (!existing || String(clean.updated_at || '') >= String(existing.updated_at || '')) {
|
if (conflictUuid) {
|
||||||
userData.tables[t][row.uuid] = clean
|
// 与自然键冲突行比较新者(而非与同 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++
|
changed++
|
||||||
}
|
}
|
||||||
|
userData.tables[t][row.uuid] = clean
|
||||||
|
changed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 应用墓碑
|
// 应用墓碑
|
||||||
|
|||||||
Reference in New Issue
Block a user