Skip to content

Commit beed8b4

Browse files
committed
fix(audit3): resolve round-3 review findings on round-2 fixes
High: - OfflineQueue: orderBy('version') on non-indexed field throws Dexie SchemaError, breaking offline enqueue (regression from SYNC2-L4); switched to sortBy in-memory (enqueue/getPendingItems/getReadyItems) - auth.py JWKS: network-failure branch now bounded by stale-grace window (was unbounded stale reuse); in-lock retry_after reuse added; 5xx treated as transient (stale reuse) vs 4xx fail-closed Medium: - error_pattern: cache-hit path now validates/filters like first-run (was bypassing validation -> 500); cache stores cleaned data; negative count/empty keywords filtered - Key rotation: removed per-provider generate rotate (double rotation with wrapper made even-key pools never use the 2nd key) - writeWithLog: resetTable failure no longer propagates to block the main write path - SyncEngine: finally clears syncInProgress unconditionally (pause during in-flight sync left the lock, resume waited 15s timeout) - feynmanStepSlice: catch path now syncs zustand store so retry does not duplicate cards for already-created weak points - windowManager: window recreation clears syncTimeoutTimer (stale timer would app.quit() the rebuilt window) - rate_limit: global-limit overrun now rolls back the feature counter (Lua only rolls back the exceeded layer) Low: - markEntityLogsSynced matches synced !== 1 (legacy boolean/undefined) - parseLocalDate validates format; daysBetween NaN -> Infinity - ecosystem initialize uses local date; restore() re-reads DB - worldState streak diff uses Math.round tolerance - useVoiceInput start timeout surfaces error message - Gemini stream uses provider thread pool (not default executor) - qwen_vision returns max_tokens for truncation detection - jwt_algorithm normalized strip().upper()
1 parent 064f636 commit beed8b4

20 files changed

Lines changed: 186 additions & 55 deletions

File tree

‎client/electron/windowManager.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ export function createMainWindow(
8383
// preventDefault 无法关闭(同步事件只在旧窗口的 webContents 上发送过)
8484
syncBeforeQuitRequested = false;
8585
syncBeforeQuitCompleted = false;
86+
// GW-3: 同步清理超时定时器——旧 timer 回调检查 !syncBeforeQuitCompleted
87+
//(重置后必为 false)会触发 completeSyncBeforeQuit → app.quit(),
88+
// 刚重建的窗口被意外退出
89+
if (syncTimeoutTimer) {
90+
clearTimeout(syncTimeoutTimer);
91+
syncTimeoutTimer = null;
92+
}
8693

8794
const win = new BrowserWindow({
8895
width: WINDOW_DEFAULT_WIDTH,

‎client/src/features/assistant/hooks/useVoiceInput.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,11 @@ export function useVoiceInput(options: UseVoiceInputOptions = {}) {
9494
while (stopPendingRef.current && Date.now() - waitStart < 5000) {
9595
await new Promise((r) => setTimeout(r, 50));
9696
}
97-
if (stopPendingRef.current) return; // 超时放弃本次启动
97+
if (stopPendingRef.current) {
98+
// GW-3: 超时静默放弃会让用户点击无响应——给出明确错误提示
99+
setError('停止拾音未完成,请稍后重试');
100+
return;
101+
}
98102
}
99103
setError(null);
100104

‎client/src/features/feynman/store/feynmanStepSlice.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,17 @@ export const createStepSlice: FeynmanSlice<StepSlice> = (set, get) => ({
217217
// 标记失败可接受:下次转换最多重复建该张卡
218218
}
219219
}
220+
// GW-3: 同步 store——原实现 catch 后 set() 不执行,store 仍显示未
221+
// mastered,重试时 toConvert 从 store 取到已建卡的 wp 再次建卡
222+
//(DB 已 mastered 与 UI 态不一致)
223+
set((state) => ({
224+
weakPoints: {
225+
...state.weakPoints,
226+
[noteId]: (state.weakPoints[noteId] ?? []).map((w) =>
227+
createdWpIds.includes(w.id!) ? { ...w, mastered: true } : w,
228+
),
229+
},
230+
}));
220231
throw err;
221232
}
222233

‎client/src/features/retention/lib/streakEngine.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,15 @@ const toISO = (d: Date) =>
1616
* 将本地日期字符串(YYYY-MM-DD)解析为本地午夜 Date
1717
* FRONT2-M3: new Date("YYYY-MM-DD") 会按 UTC 午夜解析,UTC+8 时
1818
* lastDate 实际落在前一天的 08:00,与本地日期的 today 间隔计算差一天
19+
* GW-3: 格式校验——异常数据(空串/ISO 串)会生成 Invalid Date 或
20+
* 1899 年日期,导致 gap 计算失真;不匹配时回退原生解析并容忍 NaN
1921
*/
2022
function parseLocalDate(dateStr: string): Date {
21-
const [y, m, d] = dateStr.split('-').map(Number);
22-
return new Date(y, m - 1, d);
23+
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
24+
const [y, m, d] = dateStr.split('-').map(Number);
25+
return new Date(y, m - 1, d);
26+
}
27+
return new Date(dateStr);
2328
}
2429

2530
/** 断裂后保留百分比 / Retained percent after break */
@@ -36,9 +41,13 @@ export function isRestDay(date: Date, restDayPreference: number): boolean {
3641
/**
3742
* 计算两个日期之间的天数差(不含起始日)
3843
* Calculate day difference between two dates (exclusive of start)
44+
* GW-3: 异常日期(Invalid Date)按 Infinity 处理——走断裂语义
45+
*(保留 50%)而非 NaN 传播导致连击被静默错误计算
3946
*/
4047
function daysBetween(a: Date, b: Date): number {
41-
return Math.floor((b.getTime() - a.getTime()) / 86_400_000);
48+
const diff = b.getTime() - a.getTime();
49+
if (Number.isNaN(diff)) return Infinity;
50+
return Math.floor(diff / 86_400_000);
4251
}
4352

4453
/**

‎client/src/features/retention/lib/worldState.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ export function computeCurrentStreakFromCorals(corals: CoralRecord[]): number {
8888
for (let i = 1; i < uniqueDays.length; i++) {
8989
const prev = new Date(uniqueDays[i - 1]).getTime();
9090
const curr = new Date(uniqueDays[i]).getTime();
91-
if ((prev - curr) / 86_400_000 === 1) streak++;
91+
// GW-3: Math.round 容差——当前 UTC 解析精确 86400000ms,但防御未来
92+
// 日期格式变更(本地时区偏移)引入的 DST 23/25 小时抖动
93+
const dayDiff = Math.round((prev - curr) / 86_400_000);
94+
if (dayDiff === 1) streak++;
9295
else break;
9396
}
9497
return streak;

‎client/src/features/retention/store/useEcosystemStore.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ export const useEcosystemStore = create<EcosystemState>((set, get) => ({
4646
const sorted = [...corals].sort(
4747
(a, b) => new Date(b.plantedAt).getTime() - new Date(a.plantedAt).getTime(),
4848
);
49-
const lastDate = new Date(sorted[0].plantedAt).toISOString().split('T')[0];
49+
// GW-3: 本地日期口径(与 FRONT2-M3 一致)——原实现 toISOString 取
50+
// UTC 日期,UTC+8 用户凌晨种植后白化判定偏差一天
51+
const lastPlant = new Date(sorted[0].plantedAt);
52+
const lastDate = `${lastPlant.getFullYear()}-${String(lastPlant.getMonth() + 1).padStart(2, '0')}-${String(lastPlant.getDate()).padStart(2, '0')}`;
5053
const toBleach = checkBleaching(corals, lastDate, new Date());
5154
if (toBleach.length > 0) {
5255
for (const id of toBleach) {
@@ -117,6 +120,9 @@ export const useEcosystemStore = create<EcosystemState>((set, get) => ({
117120
await db.coralEcosystem.update(c.id, { health: 'healthy' });
118121
} catch { /* ignore */ }
119122
}
120-
set({ corals: restored });
123+
// GW-3: 与 plantCoral 同口径——写库完成后重读再 set,避免旧快照
124+
// 覆盖并发 plantCoral 的最新状态
125+
const latest = await db.coralEcosystem.toArray();
126+
set({ corals: latest });
121127
},
122128
}));

‎client/src/lib/crypto/CryptoManager.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,12 +171,11 @@ export class CryptoManager {
171171
if (api?.safeStorageEncrypt && api?.safeStorageDecrypt) {
172172
const storedEnc = localStorage.getItem(encryptedKey);
173173
if (storedEnc) {
174-
try {
175-
return await api.safeStorageDecrypt(storedEnc);
176-
} catch {
177-
// OS 密钥轮换/系统重装导致解密失败:回退重建新密钥材料
178-
console.warn('[CryptoManager] safeStorage 解密失败,重建密钥材料');
179-
}
174+
// GW-3: 解密失败绝不自动重建密钥——旧密钥材料丢失会使此前加密的
175+
// 全部敏感字段永久不可解密(原实现静默重建并覆写 _enc 键,数据
176+
// 不可逆损坏)。异常向上传播到 init 的 catch → initFailed + 抛错,
177+
// 由 UI 提示"加密数据不可用"并保留旧密文等待用户恢复
178+
return await api.safeStorageDecrypt(storedEnc);
180179
}
181180
const deviceKey = this.generateDeviceKey();
182181
try {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,10 @@ export async function markLogsSynced(ids: string[]): Promise<void> {
101101
export async function markEntityLogsSynced(entityType: string, entityId: string): Promise<void> {
102102
const logs = await db.operationLog
103103
.where('entityType').equals(entityType)
104-
.filter(l => l.entityId === entityId && l.synced === 0)
104+
// GW-3: 用 `!== 1` 而非 `=== 0`——旧数据 synced 可能为 boolean false
105+
//(历史版本写入)或 undefined,严格相等会漏标,形成永久孤数据;
106+
// 且这些旧日志若未来迁移为 0 会重新参与 push 触发冲突循环
107+
.filter(l => l.entityId === entityId && l.synced !== 1)
105108
.toArray();
106109
if (logs.length === 0) return;
107110
await db.operationLog.bulkUpdate(logs.map(l => ({

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,14 @@ async function applyCRDTChange(
173173
// applyLocalChange 已前滚内存 doc,若 enqueue/persistDoc 失败,
174174
// 内存与队列/快照基线漂移,下次变更会生成非法 changeset
175175
//(MissingDependencyError),该表 CRDT 路径从此永久失效
176-
await crdtEngine.resetTable(tableName);
176+
// GW-3: resetTable 本身可能失败(IndexedDB 异常),嵌套保护避免
177+
//"降级路径"反而阻塞主写入(resetTable 失败时内存状态未重建,
178+
// 下次 init 会从快照重建,风险低于阻塞业务写入)
179+
try {
180+
await crdtEngine.resetTable(tableName);
181+
} catch (resetErr) {
182+
console.warn('[writeWithLog] CRDT resetTable also failed (non-blocking):', resetErr);
183+
}
177184
console.warn('[writeWithLog] CRDT change failed, table reset (non-blocking):', err);
178185
}
179186
}

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@ export class OfflineQueue {
4242
const deviceId = getDeviceId();
4343

4444
// 在 Dexie 事务内完成"读 max version + 1 + 写入",避免版本号竞态
45-
// SYNC2-L4: 取最大值必须按 version(单调自增主键)而非 createdAt——
46-
// Date 毫秒精度下同一毫秒的并发写入会取到错误基线,产生重复版本号
45+
// SYNC2-L4: 取最大值按 version(单调自增)而非 createdAt——Date 毫秒
46+
// 精度下同一毫秒的并发写入会取到错误基线,产生重复版本号
47+
// GW-3: version 非索引字段,orderBy 会抛 SchemaError(Dexie 硬性要求)——
48+
// 改用 sortBy 内存排序(队列数据量小,性能可接受)
4749
await db.transaction('rw', db.offlineQueue, async () => {
48-
const items = await db.offlineQueue.orderBy('version').reverse().limit(1).toArray();
49-
const version = items.length > 0 ? (items[0].version || 0) + 1 : 1;
50+
const items = await db.offlineQueue.sortBy('version');
51+
const version = items.length > 0 ? (items[items.length - 1].version || 0) + 1 : 1;
5052

5153
await db.offlineQueue.add({
5254
id,
@@ -68,7 +70,9 @@ export class OfflineQueue {
6870
* 获取所有待处理队列项
6971
*/
7072
async getPendingItems(): Promise<OfflineQueueItem[]> {
71-
return db.offlineQueue.orderBy('version').toArray();
73+
// GW-3: version 非索引字段,orderBy 抛 SchemaError(Dexie 硬性要求)——
74+
// 原实现从未真正工作;改 sortBy 内存排序
75+
return db.offlineQueue.sortBy('version');
7276
}
7377

7478
/**
@@ -122,7 +126,8 @@ export class OfflineQueue {
122126
*/
123127
async getReadyItems(maxRetries: number = 5): Promise<OfflineQueueItem[]> {
124128
const now = Date.now();
125-
const items = await db.offlineQueue.orderBy('version').toArray();
129+
// GW-3: 同 getPendingItems——orderBy('version') 抛 SchemaError,改内存排序
130+
const items = await db.offlineQueue.sortBy('version');
126131
return items.filter(
127132
item =>
128133
item.retryCount < maxRetries &&

0 commit comments

Comments
 (0)