diff --git a/src/utils/cacheManager.js b/src/utils/cacheManager.js new file mode 100644 index 0000000..6a4c968 --- /dev/null +++ b/src/utils/cacheManager.js @@ -0,0 +1,270 @@ +/** + * Cache Manager — 带 TTL 的缓存管理器 + * + * 封装 IndexedDB 缓存读写 + 过期逻辑。 + * 缓存条目格式: { data, meta, cacheTimestamp, cacheTTL } + * 存储在 ClassworksDB 的 kv store 中,key 前缀为 _cache: + */ + +import { openDB } from "idb"; +import { createEmptyMeta } from "./crdtEngine"; +import { getSetting } from "./settings"; + +// Cache key prefix (与旧代码保持一致) +const CACHE_PREFIX = "_cache:"; + +// 数据库信息 +const DB_NAME = "ClassworksDB"; + +// 默认 TTL: 7 天 +const DEFAULT_TTL = 7 * 24 * 60 * 60 * 1000; + +/** + * TTL 配置 — 按 key 模式匹配 + * 顺序匹配,第一个命中的生效 + * 通配符 * 匹配任意字符 + */ +const TTL_CONFIG = [ + { pattern: "*", ttl: DEFAULT_TTL }, +]; + +// --- 内部辅助 --- + +/** + * 初始化数据库连接 + */ +async function getDB() { + return openDB(DB_NAME, undefined, { + upgrade(db) { + if (!db.objectStoreNames.contains("kv")) { + db.createObjectStore("kv"); + } + if (!db.objectStoreNames.contains("system")) { + db.createObjectStore("system"); + } + if (!db.objectStoreNames.contains("syncQueue")) { + db.createObjectStore("syncQueue"); + } + }, + }); +} + +/** + * 简单 glob 匹配 (* 匹配任意字符) + * @param {string} pattern + * @param {string} str + * @returns {boolean} + */ +function globMatch(pattern, str) { + const regexStr = "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$"; + return new RegExp(regexStr).test(str); +} + +/** + * 判断缓存条目是否为旧格式 (无 meta 字段) + * @param {*} entry + * @returns {boolean} + */ +function isLegacyEntry(entry) { + return entry && typeof entry === "object" && !("meta" in entry) && !("cacheTimestamp" in entry); +} + +/** + * 判断缓存条目是否为新格式 + * @param {*} entry + * @returns {boolean} + */ +function isNewFormatEntry(entry) { + return entry && typeof entry === "object" && "meta" in entry && "cacheTimestamp" in entry; +} + +// --- 导出 API --- + +/** + * 根据 key 匹配 TTL 配置 + * @param {string} key — 数据 key (不含 _cache: 前缀) + * @returns {number} TTL 毫秒数 + */ +export function getTTLForKey(key) { + for (const { pattern, ttl } of TTL_CONFIG) { + if (globMatch(pattern, key)) { + return ttl; + } + } + return DEFAULT_TTL; +} + +/** + * 读取缓存条目 + * - 过期则自动删除并返回 null + * - 旧格式自动迁移到新格式 + * + * @param {string} key — 数据 key (不含前缀) + * @returns {Promise<{ data: *, meta: Object }|null>} + */ +export async function getCacheEntry(key) { + try { + const db = await getDB(); + const cacheKey = CACHE_PREFIX + key; + const raw = await db.get("kv", cacheKey); + + if (!raw) return null; + + // 尝试解析 JSON + let entry; + try { + entry = typeof raw === "string" ? JSON.parse(raw) : raw; + } catch { + return null; + } + + // 旧格式迁移 + if (isLegacyEntry(entry)) { + const deviceId = getSetting("device.uuid") || "unknown"; + const meta = createEmptyMeta(deviceId); + meta.ts = Date.now(); + meta.lastSyncedData = entry; + + const migrated = { + data: entry, + meta, + cacheTimestamp: Date.now(), + cacheTTL: getTTLForKey(key), + }; + + // 写回迁移后的格式 + await db.put("kv", JSON.stringify(migrated), cacheKey); + return { data: migrated.data, meta: migrated.meta }; + } + + // 新格式 — 检查是否过期 + if (isNewFormatEntry(entry)) { + const age = Date.now() - entry.cacheTimestamp; + if (age > entry.cacheTTL) { + // 过期,删除 + await db.delete("kv", cacheKey); + return null; + } + return { data: entry.data, meta: entry.meta }; + } + + return null; + } catch (error) { + console.warn("cacheManager.getCacheEntry 失败:", error); + return null; + } +} + +/** + * 写入缓存条目 + * @param {string} key — 数据 key (不含前缀) + * @param {*} data — 用户数据 + * @param {Object} meta — CRDT metadata + * @returns {Promise} + */ +export async function setCacheEntry(key, data, meta) { + try { + const db = await getDB(); + const cacheKey = CACHE_PREFIX + key; + const entry = { + data, + meta, + cacheTimestamp: Date.now(), + cacheTTL: getTTLForKey(key), + }; + await db.put("kv", JSON.stringify(entry), cacheKey); + return true; + } catch (error) { + console.warn("cacheManager.setCacheEntry 失败:", error); + return false; + } +} + +/** + * 删除缓存条目 + * @param {string} key — 数据 key (不含前缀) + * @returns {Promise} + */ +export async function deleteCacheEntry(key) { + try { + const db = await getDB(); + await db.delete("kv", CACHE_PREFIX + key); + return true; + } catch (error) { + console.warn("cacheManager.deleteCacheEntry 失败:", error); + return false; + } +} + +/** + * 检查缓存是否未过期 + * @param {string} key — 数据 key (不含前缀) + * @returns {Promise} + */ +export async function isCacheFresh(key) { + try { + const db = await getDB(); + const raw = await db.get("kv", CACHE_PREFIX + key); + if (!raw) return false; + + let entry; + try { + entry = typeof raw === "string" ? JSON.parse(raw) : raw; + } catch { + return false; + } + + if (!isNewFormatEntry(entry)) return false; + + return Date.now() - entry.cacheTimestamp <= entry.cacheTTL; + } catch { + return false; + } +} + +/** + * 清理所有过期的 _cache: 条目 + * 在启动时和同步完成后调用 + * @returns {Promise} 删除的条目数 + */ +export async function cleanupExpiredEntries() { + let cleaned = 0; + try { + const db = await getDB(); + const tx = db.transaction("kv", "readwrite"); + const store = tx.objectStore("kv"); + const allKeys = await store.getAllKeys(); + + for (const storeKey of allKeys) { + if (!storeKey.startsWith(CACHE_PREFIX)) continue; + + const raw = await store.get(storeKey); + if (!raw) continue; + + let entry; + try { + entry = typeof raw === "string" ? JSON.parse(raw) : raw; + } catch { + continue; + } + + if (isNewFormatEntry(entry)) { + const age = Date.now() - entry.cacheTimestamp; + if (age > entry.cacheTTL) { + await store.delete(storeKey); + cleaned++; + } + } + } + + await tx.done; + } catch (error) { + console.warn("cacheManager.cleanupExpiredEntries 失败:", error); + } + return cleaned; +} + +/** + * 获取缓存前缀 (供外部使用) + */ +export { CACHE_PREFIX }; diff --git a/src/utils/crdtEngine.js b/src/utils/crdtEngine.js new file mode 100644 index 0000000..0f7ce82 --- /dev/null +++ b/src/utils/crdtEngine.js @@ -0,0 +1,305 @@ +/** + * CRDT Engine — 纯函数冲突解决模块 + * + * 基于向量时钟的无冲突复制数据类型 (CRDT) 实现。 + * 所有函数无副作用,不依赖 IndexedDB 或网络。 + * + * 合并策略: + * - 对象: 按字段 LWW (Last-Writer-Wins),使用 _fieldTs 字段级时间戳 + * - 数组: 按 identity 合并,union 语义 + * - 原始类型: LWW,时间戳相同时 deviceId 字典序决定 + */ + +// --- 向量时钟操作 --- + +/** + * 创建空的 metadata 对象 + * @param {string} deviceId — 本设备标识符 + * @returns {Object} 初始 metadata + */ +export function createEmptyMeta(deviceId) { + return { + vc: { [deviceId]: 0 }, + ts: 0, + deviceId, + _fieldTs: {}, + lastSyncedData: null, + lastSyncedTs: 0, + lastSyncedVc: { [deviceId]: 0 }, + }; +} + +/** + * 递增设备时钟,返回新的 metadata (不可变) + * @param {Object} meta — 当前 metadata + * @param {string} deviceId — 本设备标识符 + * @returns {Object} 递增后的新 metadata + */ +export function bumpClock(meta, deviceId) { + const newVc = { ...meta.vc }; + newVc[deviceId] = (newVc[deviceId] || 0) + 1; + return { + ...meta, + vc: newVc, + ts: Date.now(), + deviceId, + }; +} + +/** + * 合并两个向量时钟,取各分量的 max + * @param {Object} vcA + * @param {Object} vcB + * @returns {Object} 合并后的向量时钟 + */ +export function mergeClocks(vcA, vcB) { + const result = { ...vcA }; + for (const [node, count] of Object.entries(vcB)) { + result[node] = Math.max(result[node] || 0, count); + } + return result; +} + +/** + * 比较两个版本的向量时钟 + * @param {Object} metaA + * @param {Object} metaB + * @returns {"A_NEWER"|"B_NEWER"|"CONCURRENT"|"EQUAL"} + */ +export function compareVersions(metaA, metaB) { + const vcA = metaA.vc || {}; + const vcB = metaB.vc || {}; + + // 收集所有节点 + const allNodes = new Set([...Object.keys(vcA), ...Object.keys(vcB)]); + + let aGreater = false; + let bGreater = false; + + for (const node of allNodes) { + const a = vcA[node] || 0; + const b = vcB[node] || 0; + if (a > b) aGreater = true; + if (b > a) bGreater = true; + if (aGreater && bGreater) return "CONCURRENT"; + } + + if (!aGreater && !bGreater) return "EQUAL"; + if (aGreater) return "A_NEWER"; + return "B_NEWER"; +} + +// --- 数据合并 --- + +/** + * 判断是否为纯对象 (非数组、非 null) + */ +function isPlainObject(val) { + return val !== null && typeof val === "object" && !Array.isArray(val); +} + +/** + * DJB2 哈希,用于轻量数据比较 + * @param {*} data — JSON 可序列化数据 + * @returns {string} 十六进制哈希字符串 + */ +export function computeDataHash(data) { + const str = typeof data === "string" ? data : JSON.stringify(data); + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(16); +} + +/** + * 合并字段级时间戳 + * @param {Object} fieldTsA + * @param {Object} fieldTsB + * @returns {Object} + */ +function mergeFieldTimestamps(fieldTsA, fieldTsB) { + if (!fieldTsA && !fieldTsB) return {}; + if (!fieldTsA) return { ...fieldTsB }; + if (!fieldTsB) return { ...fieldTsA }; + + const result = { ...fieldTsA }; + for (const [path, infoB] of Object.entries(fieldTsB)) { + const infoA = result[path]; + if (!infoA || infoB.ts > infoA.ts || (infoB.ts === infoA.ts && infoB.deviceId < infoA.deviceId)) { + result[path] = infoB; + } + } + return result; +} + +/** + * 检测数组的 identity 函数 + * 优先级: id > name > key > JSON.stringify + */ +function detectIdentityFn(arr) { + if (!arr || arr.length === 0) return null; + const first = arr[0]; + if (isPlainObject(first)) { + if ("id" in first) return (item) => (isPlainObject(item) ? item.id : JSON.stringify(item)); + if ("name" in first) return (item) => (isPlainObject(item) ? item.name : JSON.stringify(item)); + if ("key" in first) return (item) => (isPlainObject(item) ? item.key : JSON.stringify(item)); + } + return null; +} + +/** + * 合并两个数组 — union 语义,按 identity 去重 + * @param {Array} local + * @param {Object} localMeta + * @param {Array} remote + * @returns {Array} 合并后的数组 + */ +function mergeArrays(local, localMeta, remote) { + const identityFn = + detectIdentityFn(local) || + detectIdentityFn(remote) || + ((item) => JSON.stringify(item)); + + const localMap = new Map(); + const remoteMap = new Map(); + + local.forEach((item) => { + const id = identityFn(item); + if (!localMap.has(id)) localMap.set(id, item); + }); + remote.forEach((item) => { + const id = identityFn(item); + if (!remoteMap.has(id)) remoteMap.set(id, item); + }); + + const result = []; + const seen = new Set(); + + // 保持本地顺序,本地有的以本地为准 + for (const [id, item] of localMap) { + if (seen.has(id)) continue; + seen.add(id); + result.push(item); + } + + // 追加远程独有的 + for (const [id, item] of remoteMap) { + if (!seen.has(id)) { + seen.add(id); + result.push(item); + } + } + + return result; +} + +/** + * 合并两个对象 — 按字段 LWW + * @param {Object} local + * @param {Object} localMeta + * @param {Object} remote + * @param {Object} remoteMeta + * @returns {Object} + */ +function mergeObjects(local, localMeta, remote, remoteMeta) { + const result = {}; + const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]); + + for (const key of allKeys) { + const localHas = key in local; + const remoteHas = key in remote; + + if (localHas && !remoteHas) { + result[key] = local[key]; + } else if (!localHas && remoteHas) { + result[key] = remote[key]; + } else { + // 两边都有 — 用字段级时间戳决定 + const localFieldInfo = localMeta._fieldTs?.[key] || { + ts: localMeta.ts, + deviceId: localMeta.deviceId, + }; + const remoteFieldInfo = remoteMeta._fieldTs?.[key] || { + ts: remoteMeta.ts, + deviceId: remoteMeta.deviceId, + }; + + if (localFieldInfo.ts > remoteFieldInfo.ts) { + result[key] = local[key]; + } else if (remoteFieldInfo.ts > localFieldInfo.ts) { + result[key] = remote[key]; + } else { + // 时间戳相同 — deviceId 字典序决定 + result[key] = + localFieldInfo.deviceId <= remoteFieldInfo.deviceId + ? local[key] + : remote[key]; + } + } + } + + return result; +} + +/** + * CRDT 合并入口 — 根据数据类型选择合并策略 + * + * @param {*} localData — 本地数据 + * @param {Object} localMeta — 本地 metadata (含 vc, ts, deviceId, _fieldTs) + * @param {*} remoteData — 远程数据 + * @param {Object} remoteMeta — 远程 metadata + * @returns {{ data: *, meta: Object }} 合并后的数据和 metadata + */ +export function mergeValues(localData, localMeta, remoteData, remoteMeta) { + const mergedVc = mergeClocks(localMeta.vc || {}, remoteMeta.vc || {}); + const mergedTs = Math.max(localMeta.ts || 0, remoteMeta.ts || 0); + const mergedFieldTs = mergeFieldTimestamps( + localMeta._fieldTs, + remoteMeta._fieldTs, + ); + + let mergedData; + + if (isPlainObject(localData) && isPlainObject(remoteData)) { + mergedData = mergeObjects( + localData, + localMeta, + remoteData, + remoteMeta, + ); + } else if (Array.isArray(localData) && Array.isArray(remoteData)) { + mergedData = mergeArrays(localData, localMeta, remoteData); + } else if ( + typeof localData === typeof remoteData && + typeof localData !== "object" + ) { + // 原始类型: LWW + if ((localMeta.ts || 0) > (remoteMeta.ts || 0)) { + mergedData = localData; + } else if ((remoteMeta.ts || 0) > (localMeta.ts || 0)) { + mergedData = remoteData; + } else { + mergedData = + (localMeta.deviceId || "") <= (remoteMeta.deviceId || "") + ? localData + : remoteData; + } + } else { + // 类型不同 — LWW + mergedData = (localMeta.ts || 0) >= (remoteMeta.ts || 0) ? localData : remoteData; + } + + return { + data: mergedData, + meta: { + vc: mergedVc, + ts: mergedTs, + deviceId: mergedTs === (localMeta.ts || 0) ? localMeta.deviceId : remoteMeta.deviceId, + _fieldTs: mergedFieldTs, + lastSyncedData: remoteMeta.lastSyncedData ?? localMeta.lastSyncedData ?? null, + lastSyncedTs: Math.max(localMeta.lastSyncedTs || 0, remoteMeta.lastSyncedTs || 0), + lastSyncedVc: mergedVc, + }, + }; +} diff --git a/src/utils/dataProvider.js b/src/utils/dataProvider.js index 49da0c9..4abdc38 100644 --- a/src/utils/dataProvider.js +++ b/src/utils/dataProvider.js @@ -1,18 +1,31 @@ -import {kvLocalProvider} from "./providers/kvLocalProvider"; -import {kvServerProvider} from "./providers/kvServerProvider"; -import {getSetting, setSetting} from "./settings"; -import {getEffectiveServerUrl} from "./serverRotation"; +import { kvLocalProvider } from "./providers/kvLocalProvider"; +import { kvServerProvider } from "./providers/kvServerProvider"; +import { getSetting, setSetting } from "./settings"; +import { getEffectiveServerUrl } from "./serverRotation"; +import { + createEmptyMeta, + bumpClock, + mergeValues, +} from "./crdtEngine"; +import { + getCacheEntry, + setCacheEntry, + CACHE_PREFIX, +} from "./cacheManager"; +import { + initSmartSync, + destroySmartSync, + flushAll, + triggerSyncAfterSuccess, +} from "./smartSyncManager"; export const formatResponse = (data) => data; export const formatError = (message, code = "UNKNOWN_ERROR") => ({ success: false, - error: {code, message}, + error: { code, message }, }); -// Cache key prefix to avoid collision with kv-local mode data -const CACHE_PREFIX = "_cache:"; - function isServerError(result) { return result && result.success === false; } @@ -21,50 +34,32 @@ function isNetworkError(result) { return isServerError(result) && result.error?.code === "NETWORK_ERROR"; } -// --- Sync manager: flushes queued writes when back online --- - -let _onlineHandler = null; -let _flushing = false; - -async function flushSyncQueue() { - if (_flushing) return; - _flushing = true; - try { - const queueResult = await kvLocalProvider.getSyncQueue(); - if (queueResult.success === false || !Array.isArray(queueResult) || queueResult.length === 0) { - return; - } - for (const entry of queueResult) { - try { - const result = await kvServerProvider.saveData(entry.key, entry.data); - if (result.success !== false) { - await kvLocalProvider.removeFromSyncQueue(entry.key); - } - } catch { - // If a single item fails, stop — will retry on next online event - break; - } - } - } finally { - _flushing = false; - } +/** + * 获取设备 ID,用于 CRDT 向量时钟节点标识 + */ +function getDeviceId() { + return getSetting("device.uuid") || "unknown"; } +/** + * 规范化服务器返回的数据 + * 某些端点 (如 Bearer token 认证) 返回 {value: [...]} 包装格式, + * 统一解包为原始数据,保证缓存比较的一致性。 + * @param {*} data — 服务器返回的原始数据 + * @returns {*} 规范化后的数据 + */ +function normalizeServerData(data) { + if (data && typeof data === "object" && !Array.isArray(data) && "value" in data) { + return data.value; + } + return data; +} + +// --- Sync manager: 向后兼容的导出 --- export const syncManager = { - init() { - if (_onlineHandler) return; - _onlineHandler = () => flushSyncQueue(); - window.addEventListener("online", _onlineHandler); - // Attempt flush on startup in case items were queued before last exit - if (navigator.onLine) flushSyncQueue(); - }, - destroy() { - if (_onlineHandler) { - window.removeEventListener("online", _onlineHandler); - _onlineHandler = null; - } - }, - flushNow: flushSyncQueue, + init: initSmartSync, + destroy: destroySmartSync, + flushNow: flushAll, }; // Helper: check if we should use the server provider @@ -81,21 +76,95 @@ export default { return kvLocalProvider.loadData(key); } - // Server mode: network-first with cache fallback - const result = await kvServerProvider.loadData(key); + // Server mode: network-first with CRDT-aware cache + const rawResult = await kvServerProvider.loadData(key); + // 规范化: 某些端点返回 {value: [...]} 包装格式,统一解包 + const result = normalizeServerData(rawResult); if (!isNetworkError(result)) { - // Success or non-network error (e.g. NOT_FOUND) — cache on success + // 服务器返回成功或非网络错误 (如 NOT_FOUND) if (result.success !== false) { - kvLocalProvider.saveData(CACHE_PREFIX + key, result); + // 有效数据 — 与本地缓存进行 CRDT 比较 + const cacheEntry = await getCacheEntry(key); + const deviceId = getDeviceId(); + + if (cacheEntry) { + const cachedData = normalizeServerData(cacheEntry.data); + const localDataStr = JSON.stringify(cachedData); + const serverDataStr = JSON.stringify(result); + const lastSyncedStr = JSON.stringify(normalizeServerData(cacheEntry.meta.lastSyncedData) ?? null); + + if (serverDataStr === localDataStr) { + // 数据完全相同 — 无冲突,直接返回本地 + return cachedData; + } + + if (serverDataStr !== lastSyncedStr) { + // 服务器数据与上次同步快照不同 — 另一台设备写入了新数据 + const localVc = cacheEntry.meta.vc || {}; + const lastSyncedVc = cacheEntry.meta.lastSyncedVc || {}; + const hasLocalChanges = (localVc[deviceId] || 0) > (lastSyncedVc[deviceId] || 0); + + if (hasLocalChanges) { + // 本地也有未同步的更改 — CRDT 合并 + const serverMeta = createEmptyMeta("server"); + serverMeta.ts = Date.now(); + + const merged = mergeValues( + cachedData, + cacheEntry.meta, + result, + serverMeta, + ); + + await setCacheEntry(key, merged.data, merged.meta); + // 推送合并结果到服务器 (fire-and-forget) + kvServerProvider.saveData(key, merged.data); + return merged.data; + } else { + // 本地无未同步更改 — 采用服务器版本 + const meta = createEmptyMeta(deviceId); + meta.ts = Date.now(); + meta.lastSyncedData = result; + meta.lastSyncedTs = Date.now(); + meta.lastSyncedVc = { ...meta.vc }; + await setCacheEntry(key, result, meta); + return result; + } + } else { + // 服务器数据 === 上次同步快照,但 ≠ 本地数据 + // 说明本地有未推送的更改,返回本地数据 + return cachedData; + } + } else { + // 无本地缓存 — 首次获取 + const meta = createEmptyMeta(deviceId); + meta.ts = Date.now(); + meta.lastSyncedData = result; + meta.lastSyncedTs = Date.now(); + meta.lastSyncedVc = { ...meta.vc }; + await setCacheEntry(key, result, meta); + return result; + } } return result; } - // Network error — try local cache - const cached = await kvLocalProvider.loadData(CACHE_PREFIX + key); - if (cached.success !== false) { - return {...cached, fromCache: true}; + // 网络错误 — 从缓存兜底 + const cached = await getCacheEntry(key); + if (cached) { + const data = normalizeServerData(cached.data); + // 直接在数据对象上添加 fromCache 标记 (保持数组类型不变) + if (typeof data === "object" && data !== null) { + data.fromCache = true; + } + return data; + } + + // 兼容旧格式缓存 + const legacyCached = await kvLocalProvider.loadData(CACHE_PREFIX + key); + if (legacyCached.success !== false) { + return { ...legacyCached, fromCache: true }; } return result; @@ -106,20 +175,45 @@ export default { return kvLocalProvider.saveData(key, data); } - // Server mode: write-through — persist locally first - await kvLocalProvider.saveData(CACHE_PREFIX + key, data); + const deviceId = getDeviceId(); + + // 读取现有缓存条目获取当前向量时钟 + const existingEntry = await getCacheEntry(key); + let meta; + + if (existingEntry) { + meta = bumpClock(existingEntry.meta, deviceId); + } else { + meta = bumpClock(createEmptyMeta(deviceId), deviceId); + } + + // Write-through: 先写入本地缓存 (含 CRDT metadata) + await setCacheEntry(key, data, meta); const result = await kvServerProvider.saveData(key, data); if (result.success !== false) { - // Server save succeeded — remove from sync queue if present + // 服务器保存成功 — 更新 lastSynced 快照 + meta.lastSyncedData = data; + meta.lastSyncedTs = Date.now(); + meta.lastSyncedVc = { ...meta.vc }; + await setCacheEntry(key, data, meta); await kvLocalProvider.removeFromSyncQueue(key); + + // 智能同步: 刷新其他队列中的更改 + triggerSyncAfterSuccess(); + return result; } - // Server save failed — queue for later sync - await kvLocalProvider.addToSyncQueue({key, data, timestamp: Date.now()}); - return {success: true, queuedForSync: true}; + // 服务器保存失败 — 加入同步队列 (含 CRDT metadata) + await kvLocalProvider.addToSyncQueue({ + key, + data, + timestamp: Date.now(), + meta, + }); + return { success: true, queuedForSync: true }; }, loadKeys: async (options = {}) => { @@ -140,7 +234,7 @@ export default { async getKeyCloudUrl(key, options = {}) { const { migrateFromLocal = true, - autoConfigureCloud = true + autoConfigureCloud = true, } = options; try { @@ -217,27 +311,24 @@ export default { // 获取认证token const authtoken = getSetting("server.kvToken"); // 构建云端访问URL - let url = `${serverUrl}/kv/${key}?token=${authtoken}`; - + const url = `${serverUrl}/kv/${key}?token=${authtoken}`; return { success: true, url, migrated, - configured + configured, }; - } catch (error) { - console.error('获取键云端地址时出错:', error); + console.error("获取键云端地址时出错:", error); return formatError( error.message || "获取键云端地址失败", - "CLOUD_URL_ERROR" + "CLOUD_URL_ERROR", ); } }, }; - export const ErrorCodes = { NOT_FOUND: "数据不存在", NETWORK_ERROR: "网络连接失败", diff --git a/src/utils/providers/kvLocalProvider.js b/src/utils/providers/kvLocalProvider.js index e71dbb1..8aed1d9 100644 --- a/src/utils/providers/kvLocalProvider.js +++ b/src/utils/providers/kvLocalProvider.js @@ -155,4 +155,32 @@ export const kvLocalProvider = { return formatError("删除同步队列项失败:" + error); } }, + + // --- Prefix-based operations for cache management --- + + /** + * 删除指定前缀的所有键 + * @param {string} prefix — 键名前缀 + * @returns {Promise} 删除的键数 + */ + async deleteByPrefix(prefix) { + try { + const db = await initDB(); + const tx = db.transaction("kv", "readwrite"); + const store = tx.objectStore("kv"); + const allKeys = await store.getAllKeys(); + let deleted = 0; + for (const key of allKeys) { + if (key.startsWith(prefix)) { + await store.delete(key); + deleted++; + } + } + await tx.done; + return deleted; + } catch (error) { + console.warn("kvLocalProvider.deleteByPrefix 失败:", error); + return 0; + } + }, }; diff --git a/src/utils/smartSyncManager.js b/src/utils/smartSyncManager.js new file mode 100644 index 0000000..97f3be4 --- /dev/null +++ b/src/utils/smartSyncManager.js @@ -0,0 +1,148 @@ +/** + * Smart Sync Manager — 增强同步管理器 + * + * 替代 dataProvider.js 中内联的 flushSyncQueue。 + * 功能: + * - 网络恢复时自动刷新同步队列 + * - 任意云端操作成功后,若队列有未同步条目,自动触发全部同步 + * - 同步成功后更新本地缓存的 lastSyncedData/lastSyncedVc 快照 + * - 启动时清理过期缓存 + */ + +import { kvLocalProvider } from "./providers/kvLocalProvider"; +import { kvServerProvider } from "./providers/kvServerProvider"; +import { cleanupExpiredEntries, getCacheEntry, setCacheEntry } from "./cacheManager"; +import { createEmptyMeta } from "./crdtEngine"; +import { getSetting } from "./settings"; + +let _onlineHandler = null; +let _flushing = false; + +/** + * 刷新同步队列 — 逐条重放,失败则停止 + * @param {Object} [options] + * @param {boolean} [options.silent=false] — 静默模式,不输出日志 + * @returns {Promise<{ synced: number, failed: number }>} + */ +export async function flushAll(options = {}) { + if (_flushing) return { synced: 0, failed: 0 }; + _flushing = true; + + let synced = 0; + let failed = 0; + + try { + const queueResult = await kvLocalProvider.getSyncQueue(); + + if (queueResult.success === false || !Array.isArray(queueResult) || queueResult.length === 0) { + return { synced: 0, failed: 0 }; + } + + for (const entry of queueResult) { + try { + const result = await kvServerProvider.saveData(entry.key, entry.data); + + if (result.success !== false) { + // 同步成功 — 从队列移除 + await kvLocalProvider.removeFromSyncQueue(entry.key); + + // 更新本地缓存的 lastSynced 快照 + if (entry.meta) { + const deviceId = getSetting("device.uuid") || "unknown"; + const existingEntry = await getCacheEntry(entry.key); + + if (existingEntry) { + const meta = { ...existingEntry.meta }; + meta.lastSyncedData = entry.data; + meta.lastSyncedTs = Date.now(); + meta.lastSyncedVc = { ...meta.vc }; + await setCacheEntry(entry.key, existingEntry.data, meta); + } else { + // 缓存条目已过期或不存在,重建 + const meta = entry.meta || createEmptyMeta(deviceId); + meta.lastSyncedData = entry.data; + meta.lastSyncedTs = Date.now(); + meta.lastSyncedVc = { ...meta.vc }; + await setCacheEntry(entry.key, entry.data, meta); + } + } + + synced++; + } else { + // 服务器返回错误 (非网络错误) — 跳过此项继续 + failed++; + if (!options.silent) { + console.warn(`smartSync: 跳过 key=${entry.key}, 服务器错误:`, result.error?.message); + } + } + } catch { + // 网络错误 — 停止处理,下次重试 + failed++; + break; + } + } + + // 清理过期缓存 + if (synced > 0) { + cleanupExpiredEntries(); + } + } finally { + _flushing = false; + } + + if (!options.silent && synced > 0) { + console.log(`smartSync: 同步完成,成功 ${synced} 条,失败 ${failed} 条`); + } + + return { synced, failed }; +} + +/** + * 触发条件同步 — 任意云端操作成功后调用 + * 仅在队列中有条目时执行 + * @returns {Promise} + */ +export async function triggerSyncAfterSuccess() { + try { + const queueResult = await kvLocalProvider.getSyncQueue(); + if (queueResult.success === false || !Array.isArray(queueResult)) return; + if (queueResult.length === 0) return; + + // 有未同步条目,触发全部同步 + flushAll({ silent: true }); + } catch { + // 静默失败 + } +} + +/** + * 初始化智能同步管理器 + * - 注册 online 事件监听 + * - 启动时清理过期缓存 + * - 启动时尝试刷新队列 + */ +export function initSmartSync() { + if (_onlineHandler) return; + + _onlineHandler = () => flushAll(); + window.addEventListener("online", _onlineHandler); + + // 启动时清理过期缓存 + cleanupExpiredEntries(); + + // 启动时尝试刷新队列 + if (navigator.onLine) { + flushAll(); + } +} + +/** + * 销毁智能同步管理器 + * 移除事件监听 + */ +export function destroySmartSync() { + if (_onlineHandler) { + window.removeEventListener("online", _onlineHandler); + _onlineHandler = null; + } +}