Skip to content

Commit 7ef8ad8

Browse files
committed
fix(client): 同步引擎高危修复——resume死锁/冲突循环/localData空对象/synced类型不一致
- SYNC2-H3: pause() 不再置 syncInProgress(该锁由 sync finally 释放),resume 等待加 15s 超时兜底——存储路径切换等流程不再永久挂起 - SYNC2-H1: resolve 成功后 markEntityLogsSynced 清理该实体全部未同步日志——配合服务端版本语义收紧(<=冲突),消除 resolve 后旧日志永久循环冲突 - SYNC2-H2: 冲突对象 localData 携带真实本地数据(最后一条 payload 日志,纯 delete 回退读业务表),localVersion 取最后一条日志版本——修复保留本地提交空对象清空实体数据 - SYNC2-H4: operationLog.synced 写入数值 0/1 与查询 equals(0/1) 类型一致,修复 oplog push 链路静默失效与模式切换丢数据;类型同步改为 0|1 验证: lint 0 errors + 875 tests passed
1 parent 7753dc7 commit 7ef8ad8

4 files changed

Lines changed: 92 additions & 9 deletions

File tree

‎client/src/lib/storage/operationLog.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ export async function logOperation<T extends Record<string, unknown>>(
6060
payload: serializedPayload,
6161
patch,
6262
createdAt: new Date(),
63-
synced: false,
63+
// SYNC2-H4: 写入数值 0/1(与查询 equals(0)/equals(1) 保持一致)——
64+
// IndexedDB 键相等判定要求同类型,boolean 与 number 不相等会
65+
// 导致 getUnsyncedLogs 永远返回空、oplog push 静默失效
66+
synced: 0,
6467
version,
6568
deviceId,
6669
});
@@ -81,11 +84,29 @@ export async function getUnsyncedLogsBatch(batchSize: number = 50): Promise<Oper
8184
return logs.slice(0, batchSize);
8285
}
8386

84-
// 标记日志为已同步
87+
// 标记日志为已同步(SYNC2-H4: 写入数值 1 与查询一致)
8588
export async function markLogsSynced(ids: string[]): Promise<void> {
8689
await db.operationLog.bulkUpdate(ids.map(id => ({
8790
key: id,
88-
changes: { synced: true }
91+
changes: { synced: 1 }
92+
})));
93+
}
94+
95+
/**
96+
* SYNC2-H1: 将指定实体的全部未同步日志标记为已同步。
97+
* 冲突 resolve 成功后调用——resolve 已代表该实体最终状态,
98+
* 若不清除本地日志,下次 push 时旧版本日志仍会冲突(服务端
99+
* 版本已推进),形成永久循环冲突。
100+
*/
101+
export async function markEntityLogsSynced(entityType: string, entityId: string): Promise<void> {
102+
const logs = await db.operationLog
103+
.where('entityType').equals(entityType)
104+
.filter(l => l.entityId === entityId && l.synced === 0)
105+
.toArray();
106+
if (logs.length === 0) return;
107+
await db.operationLog.bulkUpdate(logs.map(l => ({
108+
key: l.id,
109+
changes: { synced: 1 },
89110
})));
90111
}
91112

‎client/src/lib/sync/SyncEngine.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { apiClient } from '@/lib/http/apiClient';
2-
import { getDeviceId } from '../storage/operationLog';
2+
import { getDeviceId, markEntityLogsSynced } from '../storage/operationLog';
33
import { offlineQueue } from './OfflineQueue';
44
import { networkManager, type NetworkStatus } from './NetworkManager';
55
import type { SyncConflict } from '@/types/models';
@@ -90,10 +90,13 @@ export class SyncEngine {
9090
/**
9191
* 暂停同步引擎
9292
* 阻止新的同步请求(用于路径切换等关键操作前)
93+
* SYNC2-H3: 不再置 syncInProgress=true——该标志是"sync 执行中"的锁,
94+
* 由 sync() 的 finally 负责释放;pause 直接置位会导致无 sync 运行时
95+
* 锁永不清除,resume() 轮询等待永久挂起(同步引擎瘫痪)。
96+
* paused 标志本身已足以阻止新 sync 启动。
9397
*/
9498
pause(): void {
9599
this.paused = true;
96-
this.syncInProgress = true;
97100
}
98101

99102
/**
@@ -102,12 +105,19 @@ export class SyncEngine {
102105
*/
103106
async resume(): Promise<void> {
104107
this.paused = false;
105-
// 如有进行中的 sync,等待其完成
108+
// 如有进行中的 sync,等待其完成(SYNC2-H3: 15s 超时保护,
109+
// 防止 sync 异常挂起导致 resume 永久等待)
106110
if (this.syncInProgress) {
107111
await new Promise<void>((resolve) => {
112+
const startedAt = Date.now();
108113
const check = () => {
109114
if (!this.syncInProgress) {
110115
resolve();
116+
} else if (Date.now() - startedAt > 15000) {
117+
// 超时兜底:强制清锁继续(sync 的 finally 可能因异常路径未执行)
118+
logger.warn('[SyncEngine] resume: waiting for in-flight sync timed out, forcing unlock');
119+
this.syncInProgress = false;
120+
resolve();
111121
} else {
112122
setTimeout(check, 50);
113123
}
@@ -290,6 +300,13 @@ export class SyncEngine {
290300
version: strategy === 'remote' ? conflict.remoteVersion : Math.max(conflict.localVersion, conflict.remoteVersion) + 1,
291301
});
292302

303+
if (response.resolved) {
304+
// SYNC2-H1: resolve 成功后该实体的本地日志全部标记已同步——
305+
// resolve 已代表该实体最终状态;若不清理,旧版本日志下次 push
306+
// 仍会冲突(服务端版本已推进),形成永久循环冲突
307+
await markEntityLogsSynced(conflict.entityType, conflict.entityId);
308+
}
309+
293310
return { resolved: response.resolved, errors };
294311
} catch (error: unknown) {
295312
const message = error instanceof Error ? error.message : String(error);

‎client/src/lib/sync/oplogSyncChannel.ts‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { apiClient } from '@/lib/http/apiClient';
22
import { getDeviceId, getUnsyncedLogsBatch, markLogsSynced } from '../storage/operationLog';
33
import type { SyncConflict } from '@/types/models';
44
import { getLastSyncVersion, setLastSyncVersion } from './syncCursors';
5+
import type { OperationLog } from '@/types/sync';
56

67
/**
78
* oplog 同步通道(传统 operationLog 推拉路径)
@@ -71,13 +72,16 @@ export async function oplogPush(
7172
// Handle conflicts
7273
if (response.conflicts.length > 0) {
7374
for (const conflict of response.conflicts) {
75+
// SYNC2-H2: 冲突对象必须携带真实本地数据——原实现 localData 硬编码 '{}'
76+
// 导致用户选择"保留本地"时提交空对象覆盖服务端实体(数据清空)
77+
const { localDataStr, localVersion } = await resolveLocalConflictData(logs, conflict.entityType, conflict.entityId);
7478
conflicts.push({
7579
id: crypto.randomUUID(),
7680
entityType: conflict.entityType,
7781
entityId: conflict.entityId,
78-
localData: '{}',
82+
localData: localDataStr,
7983
remoteData: JSON.stringify(conflict.serverData),
80-
localVersion: logs.find(l => l.entityId === conflict.entityId)?.version || 0,
84+
localVersion,
8185
remoteVersion: conflict.serverVersion,
8286
status: 'pending',
8387
createdAt: new Date(),
@@ -171,3 +175,43 @@ function getEntityTableName(entityType: string): string | null {
171175
};
172176
return typeMap[entityType] || null;
173177
}
178+
179+
/**
180+
* SYNC2-H2: 解析冲突实体的本地数据与版本。
181+
* 优先取该实体最后一条携带 payload 的日志(本地最终写入状态);
182+
* 无 payload(纯 delete 日志)时回退读取业务表当前数据。
183+
* localVersion 取该实体最后一条日志的版本(原实现取第一条=最小版本)。
184+
*/
185+
async function resolveLocalConflictData(
186+
logs: OperationLog[],
187+
entityType: string,
188+
entityId: string,
189+
): Promise<{ localDataStr: string; localVersion: number }> {
190+
const entityLogs = logs.filter(l => l.entityId === entityId && l.entityType === entityType);
191+
const lastLog = entityLogs[entityLogs.length - 1];
192+
const lastPayloadLog = [...entityLogs].reverse().find(l => l.payload);
193+
194+
if (lastPayloadLog?.payload) {
195+
return {
196+
localDataStr: lastPayloadLog.payload,
197+
localVersion: lastLog?.version ?? lastPayloadLog.version,
198+
};
199+
}
200+
201+
// 纯 delete 或 payload 缺失:尝试从业务表读取当前数据
202+
if (lastLog) {
203+
const { db } = await import('../storage/database');
204+
const tableName = getEntityTableName(entityType);
205+
if (tableName) {
206+
try {
207+
const row = await db.table(tableName).get(entityId);
208+
if (row) {
209+
return { localDataStr: JSON.stringify(row), localVersion: lastLog.version };
210+
}
211+
} catch {
212+
// 表不存在/读取失败时回退空对象(保持原有行为)
213+
}
214+
}
215+
}
216+
return { localDataStr: '{}', localVersion: lastLog?.version ?? 0 };
217+
}

‎client/src/types/sync.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export interface OperationLog {
1818
operation: 'create' | 'update' | 'delete';
1919
payload?: string; // JSON 格式的变更数据
2020
createdAt: Date;
21-
synced: boolean; // 是否已同步到云端
21+
/** 是否已同步到云端(SYNC2-H4: 存储为数值 0/1——IndexedDB 键相等判定要求同类型,boolean 与 number 0/1 不相等会导致查询失效) */
22+
synced: 0 | 1;
2223
// MVP-2 新增同步字段
2324
version: number;
2425
deviceId: string;

0 commit comments

Comments
 (0)