Skip to content

Commit 1feb466

Browse files
committed
fix(client): 客户端高危修复——采集stop挂起/迁移列错位/搜索全量返回/FTS阻塞/复习双击竞态
- CL-H1: screen_capture_stop 与 dispose 结算防抖中悬挂的 pendingStartResolve,消除渲染层 invoke 永久挂起(快速开始→停止、帧超时重启链不再卡死) - CL-H2: importTable 每行显式按首行列集合取值(缺失补 null),修复 IndexedDB 迁移行间字段不一致时的列错位静默数据损坏 - CL-H3: db:search LIKE 降级路径统一 LIMIT 20,常见词搜索不再同步全表扫描 + 全量 IPC 传输 - CL-H4: rebuildIndex 改为异步分批执行(每批 500 行 + setImmediate 让出事件循环),数万文档重建不再阻塞主进程窗口/托盘/IPC;同步更新 main.ts 与 migration:complete 调用点 - CL-H5: rateCard 增加 isRating 防重入锁(try/finally 保证异常释放),消除双击/触屏误触导致的同一卡片双调度与重复复习记录 验证: npm run lint (0 errors) + npm run test (875 passed) + npm run build 全部通过
1 parent d3be873 commit 1feb466

6 files changed

Lines changed: 89 additions & 31 deletions

File tree

‎client/electron/db/dbIpcHandlers.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,19 +235,21 @@ export function registerDbIpcHandlers(): void {
235235
}
236236

237237
// 降级方案:LIKE 模糊匹配(兼容 FTS5 不可用或结果为空时)
238+
// CL-H3: 与 FTS5 路径一致限制 20 行——常见词("的/了/是")LIKE 命中海量行,
239+
// 无 LIMIT 会同步全表扫描 + 全量结果经 IPC 传输,卡死主进程与渲染层
238240
const dbConn = getConnection();
239241
const like = `%${query}%`;
240242
if (tableName === 'notes') {
241243
return dbConn.prepare(
242-
`SELECT * FROM notes WHERE title LIKE ? OR content LIKE ?`
244+
`SELECT * FROM notes WHERE title LIKE ? OR content LIKE ? LIMIT 20`
243245
).all(like, like);
244246
}
245247
// 通用回退:获取表的 TEXT 列并搜索
246248
const colInfo = dbConn.prepare(`PRAGMA table_info("${tableName}")`).all() as Array<{ name: string; type: string }>;
247249
const textCols = colInfo.filter((c) => c.type === 'TEXT' && c.name !== 'id');
248250
if (textCols.length === 0) return [];
249251
const where = textCols.map((c) => `"${c.name}" LIKE ?`).join(' OR ');
250-
return dbConn.prepare(`SELECT * FROM "${tableName}" WHERE ${where}`).all(...textCols.map(() => like));
252+
return dbConn.prepare(`SELECT * FROM "${tableName}" WHERE ${where} LIMIT 20`).all(...textCols.map(() => like));
251253
});
252254

253255
/** db:batch — 批量操作:事务执行 */

‎client/electron/db/fts5Search.ts‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -169,24 +169,37 @@ export function search(query: string, options?: SearchOptions): SearchResult[] {
169169
}));
170170
}
171171

172-
/** 批量重建全文索引(事务内先清空再逐表写入) */
173-
export function rebuildIndex(tables: IndexTableInput[]): void {
172+
/** 批量重建全文索引(CL-H4: 分批执行 + 每批让出事件循环,避免阻塞主进程) */
173+
export async function rebuildIndex(tables: IndexTableInput[]): Promise<void> {
174174
const db = getConnection();
175175
const insertStmt = db.prepare(
176176
`INSERT INTO fts_content (id, table_name, title, content) VALUES (?, ?, ?, ?)`,
177177
);
178178

179-
const transaction = db.transaction(() => {
180-
db.exec(`DELETE FROM fts_content`);
181-
for (const t of tables) {
182-
for (const row of t.rows) {
183-
insertStmt.run(row.id, t.name, row.title ?? '', row.content);
184-
}
179+
// 清空在事务外执行,随后逐批事务插入
180+
db.exec(`DELETE FROM fts_content`);
181+
182+
// CL-H4: 每批 500 行——better-sqlite3 为同步 API,数万文档的全量重建
183+
// (含分词)会长时间占用主进程事件循环;分批执行并在批次间让出
184+
const BATCH_SIZE = 500;
185+
let processed = 0;
186+
187+
for (const t of tables) {
188+
for (let i = 0; i < t.rows.length; i += BATCH_SIZE) {
189+
const batch = t.rows.slice(i, i + BATCH_SIZE);
190+
const txn = db.transaction((rows: IndexTableInput['rows']) => {
191+
for (const row of rows) {
192+
insertStmt.run(row.id, t.name, row.title ?? '', row.content);
193+
}
194+
});
195+
txn(batch);
196+
processed += batch.length;
197+
// 让出事件循环,保证窗口事件/托盘/其他 IPC 不被长时间阻塞
198+
await new Promise<void>((resolve) => setImmediate(resolve));
185199
}
186-
});
200+
}
187201

188-
transaction();
189-
logger.info(`[FTS5] Index rebuilt for ${tables.length} table(s)`);
202+
logger.info(`[FTS5] Index rebuilt for ${tables.length} table(s), ${processed} documents`);
190203
}
191204

192205
// ================================================================

‎client/electron/db/migration.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ export function importTable(sqliteTable: string, rows: Record<string, unknown>[]
104104
}
105105

106106
// 构建列名(从第一行推断,camelCase → snake_case)
107+
// CL-H2: 列集合以首行为准;每行取值时显式按该列集合对齐,
108+
// 防止行间字段集合/顺序不一致时按位置错位写入(SQLite 不校验列类型,会静默损坏)
107109
const cols = Object.keys(rows[0]).map(toSnake);
108110

109111
const placeholders = cols.map(() => '?').join(', ');
@@ -113,8 +115,13 @@ export function importTable(sqliteTable: string, rows: Record<string, unknown>[]
113115

114116
const txn = db.transaction((items: Record<string, unknown>[]) => {
115117
for (const item of items) {
116-
const values = Object.keys(item).map((k) => {
117-
const val = item[k];
118+
// 先将本行 key 归一化为 snake_case,再按首行列集合取值(缺失补 null)
119+
const snakeRow: Record<string, unknown> = {};
120+
for (const [k, v] of Object.entries(item)) {
121+
snakeRow[toSnake(k)] = v;
122+
}
123+
const values = cols.map((col) => {
124+
const val = snakeRow[col];
118125
// 自动 JSON 序列化对象/数组字段
119126
if (val !== null && typeof val === 'object' && !(val instanceof Date)) {
120127
return JSON.stringify(val);
@@ -123,7 +130,8 @@ export function importTable(sqliteTable: string, rows: Record<string, unknown>[]
123130
if (typeof val === 'boolean') return val ? 1 : 0;
124131
// Date → ISO string
125132
if (val instanceof Date) return val.toISOString();
126-
return val;
133+
// 缺失字段补 null(better-sqlite3 不接受 undefined 绑定)
134+
return val === undefined ? null : val;
127135
});
128136
stmt.run(...values);
129137
}
@@ -199,7 +207,7 @@ export function registerMigrationHandlers(
199207
// 迁移完成后重建 FTS5 全文索引(迁移数据绕过业务写入路径,未触发增量索引维护)
200208
try {
201209
const indexData = collectIndexableData(db);
202-
rebuildIndex(indexData);
210+
await rebuildIndex(indexData);
203211
logger.info(`[Migration] FTS5 index rebuilt: ${indexData.reduce((sum, t) => sum + t.rows.length, 0)} documents`);
204212
} catch (ftsErr) {
205213
logger.warn(`[Migration] FTS5 index rebuild failed (non-fatal): ${ftsErr instanceof Error ? ftsErr.message : String(ftsErr)}`);

‎client/electron/main.ts‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,15 +169,19 @@ if (!gotTheLock) {
169169
// 期间增量索引写入(indexDocument/removeDocument)自动进入队列,
170170
// 重建完成后由 setFtsIndexReady(true) 统一 flush,不丢索引
171171
setTimeout(() => {
172-
try {
173-
const indexData = collectIndexableData(sqliteDb);
174-
rebuildIndex(indexData);
175-
logger.info(`[DB] FTS5 index rebuilt: ${indexData.reduce((sum, t) => sum + t.rows.length, 0)} documents indexed`);
176-
} catch (err) {
177-
logger.warn(`[FTS5] Startup index rebuild failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
178-
} finally {
179-
setFtsIndexReady(true);
180-
}
172+
// CL-H4: rebuildIndex 已改为异步分批执行(每批让出事件循环),
173+
// 数万文档重建不再阻塞主进程窗口/托盘/IPC
174+
(async () => {
175+
try {
176+
const indexData = collectIndexableData(sqliteDb);
177+
await rebuildIndex(indexData);
178+
logger.info(`[DB] FTS5 index rebuilt: ${indexData.reduce((sum, t) => sum + t.rows.length, 0)} documents indexed`);
179+
} catch (err) {
180+
logger.warn(`[FTS5] Startup index rebuild failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
181+
} finally {
182+
setFtsIndexReady(true);
183+
}
184+
})();
181185
}, 0);
182186
logger.info('[DB] SQLite initialized and schema ready (FTS5 enabled)');
183187

‎client/electron/screenCaptureHandlers.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ export function registerScreenCaptureHandlers(): void {
123123
startDebounceTimer = null;
124124
}
125125

126+
// CL-H1: 结算防抖中悬挂的 start Promise——若在 500ms 防抖窗口内调用
127+
// stop,debounce 回调永不执行,pendingStartResolve 必须在此显式 settle,
128+
// 否则渲染层 invoke('screen_capture_start') 永久挂起(loading 态卡死)
129+
if (pendingStartResolve) {
130+
pendingStartResolve({ success: false });
131+
pendingStartResolve = null;
132+
}
133+
126134
if (activeCapture) {
127135
activeCapture.dispose();
128136
activeCapture = null;
@@ -223,6 +231,11 @@ export function disposeScreenCaptureHandlers(): void {
223231
clearTimeout(startDebounceTimer);
224232
startDebounceTimer = null;
225233
}
234+
// CL-H1: 应用退出路径同样需要结算悬挂的 start Promise
235+
if (pendingStartResolve) {
236+
pendingStartResolve({ success: false });
237+
pendingStartResolve = null;
238+
}
226239
if (windowWatchTimer) {
227240
clearInterval(windowWatchTimer);
228241
windowWatchTimer = null;

‎client/src/features/flashcards/store/useStudySessionStore.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ interface StudySessionState {
6262
clearDeckSession: (deckId: string) => void;
6363
/** v0.9.0: 获取当前会话及历史 goldenErrors */
6464
getGoldenErrors: () => GoldenError[];
65+
/** CL-H5: 评分处理中标志——防双击/连点导致同一卡片被调度两次 */
66+
isRating: boolean;
6567
}
6668

6769
// ---------------------------------------------------------------------------
@@ -113,6 +115,7 @@ export const useStudySessionStore = create<StudySessionState>((set, get) => {
113115
lastStabilityAfter: null,
114116
lastRating: null,
115117
showStrengthPulse: false,
118+
isRating: false,
116119

117120
// -----------------------------------------------------------------------
118121
// startSession:加载到期卡片 + 补充新卡(带每日限额)
@@ -213,17 +216,26 @@ export const useStudySessionStore = create<StudySessionState>((set, get) => {
213216
// rateCard:评分并推进到下一张
214217
// -----------------------------------------------------------------------
215218
rateCard: async (rating, confidence) => {
216-
const { sessionCards, currentIndex, cardStartTime, isFlipped, goldenErrors } = get();
219+
const { sessionCards, currentIndex, cardStartTime, isFlipped, goldenErrors, isRating } = get();
217220

218221
// 必须已翻面才能评分
219222
if (!isFlipped) return;
220223

224+
// CL-H5: 防重入——rateCard 中有 await 落库窗口(isFlipped 尚未重置),
225+
// 快速双击/触屏误触会让同一卡片被调度两次(双条复习记录 + 重复调度)
226+
if (isRating) return;
227+
set({ isRating: true });
228+
221229
const card = sessionCards[currentIndex];
222-
if (!card || card.id === undefined) return;
230+
if (!card || card.id === undefined) {
231+
set({ isRating: false });
232+
return;
233+
}
223234

224-
// 宪法第一条:复习行为=秩序波纹。任何评分都是一次对混沌的推退
225-
// (包括 Again——唤醒本身即正向,零负向语义),经世界事件总线驱动深海场景
226-
useWorldEvents.getState().emitOrderRipple('flashcards');
235+
try {
236+
// 宪法第一条:复习行为=秩序波纹。任何评分都是一次对混沌的推退
237+
// (包括 Again——唤醒本身即正向,零负向语义),经世界事件总线驱动深海场景
238+
useWorldEvents.getState().emitOrderRipple('flashcards');
227239

228240
// v0.9.0: goldenError 判定 — 高自信答错(Again)
229241
const isWrong = rating === Rating.Again;
@@ -340,6 +352,7 @@ export const useStudySessionStore = create<StudySessionState>((set, get) => {
340352
isActive: false,
341353
isFlipped: false,
342354
cardStartTime: null,
355+
isRating: false,
343356
// v0.29: 记忆强度追踪
344357
lastStabilityBefore: card.stability ?? 0,
345358
lastStabilityAfter: result.stability ?? 0,
@@ -356,13 +369,18 @@ export const useStudySessionStore = create<StudySessionState>((set, get) => {
356369
goldenErrors: newGoldenErrors,
357370
isFlipped: false,
358371
cardStartTime: new Date(),
372+
isRating: false,
359373
// v0.29: 记忆强度追踪
360374
lastStabilityBefore: card.stability ?? 0,
361375
lastStabilityAfter: result.stability ?? 0,
362376
lastRating: rating,
363377
showStrengthPulse: true,
364378
});
365379
}
380+
} finally {
381+
// 异常路径(落库失败)也必须释放锁,避免评分永久不可用
382+
set({ isRating: false });
383+
}
366384
},
367385

368386
// -----------------------------------------------------------------------

0 commit comments

Comments
 (0)