1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-06-27 19:35:07 +00:00

优化离线使用体验

This commit is contained in:
Sunwuyuan 2026-06-19 17:44:40 +08:00
parent 5d0b0bb175
commit 0f44a97f83
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
5 changed files with 913 additions and 71 deletions

270
src/utils/cacheManager.js Normal file
View File

@ -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<boolean>}
*/
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<boolean>}
*/
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<boolean>}
*/
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<number>} 删除的条目数
*/
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 };

305
src/utils/crdtEngine.js Normal file
View File

@ -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,
},
};
}

View File

@ -1,18 +1,31 @@
import {kvLocalProvider} from "./providers/kvLocalProvider"; import { kvLocalProvider } from "./providers/kvLocalProvider";
import {kvServerProvider} from "./providers/kvServerProvider"; import { kvServerProvider } from "./providers/kvServerProvider";
import {getSetting, setSetting} from "./settings"; import { getSetting, setSetting } from "./settings";
import {getEffectiveServerUrl} from "./serverRotation"; 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 formatResponse = (data) => data;
export const formatError = (message, code = "UNKNOWN_ERROR") => ({ export const formatError = (message, code = "UNKNOWN_ERROR") => ({
success: false, 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) { function isServerError(result) {
return result && result.success === false; return result && result.success === false;
} }
@ -21,50 +34,32 @@ function isNetworkError(result) {
return isServerError(result) && result.error?.code === "NETWORK_ERROR"; return isServerError(result) && result.error?.code === "NETWORK_ERROR";
} }
// --- Sync manager: flushes queued writes when back online --- /**
* 获取设备 ID用于 CRDT 向量时钟节点标识
let _onlineHandler = null; */
let _flushing = false; function getDeviceId() {
return getSetting("device.uuid") || "unknown";
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;
}
} }
export const syncManager = { /**
init() { * 规范化服务器返回的数据
if (_onlineHandler) return; * 某些端点 ( Bearer token 认证) 返回 {value: [...]} 包装格式
_onlineHandler = () => flushSyncQueue(); * 统一解包为原始数据保证缓存比较的一致性
window.addEventListener("online", _onlineHandler); * @param {*} data 服务器返回的原始数据
// Attempt flush on startup in case items were queued before last exit * @returns {*} 规范化后的数据
if (navigator.onLine) flushSyncQueue(); */
}, function normalizeServerData(data) {
destroy() { if (data && typeof data === "object" && !Array.isArray(data) && "value" in data) {
if (_onlineHandler) { return data.value;
window.removeEventListener("online", _onlineHandler);
_onlineHandler = null;
} }
}, return data;
flushNow: flushSyncQueue, }
// --- Sync manager: 向后兼容的导出 ---
export const syncManager = {
init: initSmartSync,
destroy: destroySmartSync,
flushNow: flushAll,
}; };
// Helper: check if we should use the server provider // Helper: check if we should use the server provider
@ -81,21 +76,95 @@ export default {
return kvLocalProvider.loadData(key); return kvLocalProvider.loadData(key);
} }
// Server mode: network-first with cache fallback // Server mode: network-first with CRDT-aware cache
const result = await kvServerProvider.loadData(key); const rawResult = await kvServerProvider.loadData(key);
// 规范化: 某些端点返回 {value: [...]} 包装格式,统一解包
const result = normalizeServerData(rawResult);
if (!isNetworkError(result)) { if (!isNetworkError(result)) {
// Success or non-network error (e.g. NOT_FOUND) — cache on success // 服务器返回成功或非网络错误 (如 NOT_FOUND)
if (result.success !== false) { 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; return result;
} }
// Network error — try local cache // 网络错误 — 从缓存兜底
const cached = await kvLocalProvider.loadData(CACHE_PREFIX + key); const cached = await getCacheEntry(key);
if (cached.success !== false) { if (cached) {
return {...cached, fromCache: true}; 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; return result;
@ -106,20 +175,45 @@ export default {
return kvLocalProvider.saveData(key, data); return kvLocalProvider.saveData(key, data);
} }
// Server mode: write-through — persist locally first const deviceId = getDeviceId();
await kvLocalProvider.saveData(CACHE_PREFIX + key, data);
// 读取现有缓存条目获取当前向量时钟
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); const result = await kvServerProvider.saveData(key, data);
if (result.success !== false) { 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); await kvLocalProvider.removeFromSyncQueue(key);
// 智能同步: 刷新其他队列中的更改
triggerSyncAfterSuccess();
return result; return result;
} }
// Server save failed — queue for later sync // 服务器保存失败 — 加入同步队列 (含 CRDT metadata)
await kvLocalProvider.addToSyncQueue({key, data, timestamp: Date.now()}); await kvLocalProvider.addToSyncQueue({
return {success: true, queuedForSync: true}; key,
data,
timestamp: Date.now(),
meta,
});
return { success: true, queuedForSync: true };
}, },
loadKeys: async (options = {}) => { loadKeys: async (options = {}) => {
@ -140,7 +234,7 @@ export default {
async getKeyCloudUrl(key, options = {}) { async getKeyCloudUrl(key, options = {}) {
const { const {
migrateFromLocal = true, migrateFromLocal = true,
autoConfigureCloud = true autoConfigureCloud = true,
} = options; } = options;
try { try {
@ -217,27 +311,24 @@ export default {
// 获取认证token // 获取认证token
const authtoken = getSetting("server.kvToken"); const authtoken = getSetting("server.kvToken");
// 构建云端访问URL // 构建云端访问URL
let url = `${serverUrl}/kv/${key}?token=${authtoken}`; const url = `${serverUrl}/kv/${key}?token=${authtoken}`;
return { return {
success: true, success: true,
url, url,
migrated, migrated,
configured configured,
}; };
} catch (error) { } catch (error) {
console.error('获取键云端地址时出错:', error); console.error("获取键云端地址时出错:", error);
return formatError( return formatError(
error.message || "获取键云端地址失败", error.message || "获取键云端地址失败",
"CLOUD_URL_ERROR" "CLOUD_URL_ERROR",
); );
} }
}, },
}; };
export const ErrorCodes = { export const ErrorCodes = {
NOT_FOUND: "数据不存在", NOT_FOUND: "数据不存在",
NETWORK_ERROR: "网络连接失败", NETWORK_ERROR: "网络连接失败",

View File

@ -155,4 +155,32 @@ export const kvLocalProvider = {
return formatError("删除同步队列项失败:" + error); return formatError("删除同步队列项失败:" + error);
} }
}, },
// --- Prefix-based operations for cache management ---
/**
* 删除指定前缀的所有键
* @param {string} prefix 键名前缀
* @returns {Promise<number>} 删除的键数
*/
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;
}
},
}; };

View File

@ -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<void>}
*/
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;
}
}