From 446e20195e9171b8a780550cc5be4b14d5bf4600 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 28 May 2026 17:03:46 +0800 Subject: [PATCH 1/8] fix: harden AI/WebDAV config sync to prevent data loss and silent failures - Wrap encrypt() calls in try-catch so a single config encryption failure doesn't abort the entire bulk sync transaction - Add safety guard: if ALL configs are skipped (missing keys), roll back the transaction instead of committing an empty database - Track and return skipped config details in the response so the frontend can surface partial failures - Switch syncAIConfigs/syncWebDAVConfigs from fetchWithTimeout to fetchWithRetry (3 retries) matching the pattern used by repo/release sync - Add pre-sync warnings for configs with empty apiKey/password - Add new error codes: SYNC_AI_CONFIGS_ALL_SKIPPED, SYNC_WEBDAV_CONFIGS_ALL_SKIPPED Co-Authored-By: Claude Opus 4.7 --- server/src/routes/configs.ts | 84 +++++++++++++++++++++++++++++----- src/services/backendAdapter.ts | 38 +++++++++++++-- src/utils/backendErrors.ts | 2 + 3 files changed, 109 insertions(+), 15 deletions(-) diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index 3ffeca48..62035d40 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -124,6 +124,9 @@ router.put('/api/configs/ai/bulk', (req, res) => { return; } + // Shared between transaction and response — transaction populates, response reads + const syncResult = { inserted: 0, skipped: [] as Array<{ id: string; name: string; reason: string }> }; + const bulkSync = db.transaction(() => { const existingKeys = new Map(); const existingRows = db.prepare('SELECT id, api_key_encrypted FROM ai_configs').all() as Array<{ id: string; api_key_encrypted: string }>; @@ -138,18 +141,25 @@ router.put('/api/configs/ai/bulk', (req, res) => { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - const skippedConfigs: Array<{ id: string; name: string; reason: string }> = []; - for (const c of configs) { let encryptedKey = ''; if (c.apiKey && !c.apiKey.startsWith('***')) { - encryptedKey = encrypt(c.apiKey, config.encryptionKey); + try { + encryptedKey = encrypt(String(c.apiKey), config.encryptionKey); + } catch (encErr) { + console.error(`[configs] Failed to encrypt API key for config "${c.name}" (${c.id}), falling back to existing key:`, encErr); + encryptedKey = existingKeys.get(String(c.id)) ?? ''; + if (!encryptedKey) { + syncResult.skipped.push({ id: c.id, name: c.name ?? '', reason: 'encrypt_failed' }); + continue; + } + } } else { encryptedKey = existingKeys.get(String(c.id)) ?? ''; } if (!encryptedKey) { - skippedConfigs.push({ + syncResult.skipped.push({ id: c.id, name: c.name ?? '', reason: c.apiKey?.startsWith('***') @@ -164,18 +174,30 @@ router.put('/api/configs/ai/bulk', (req, res) => { encryptedKey, c.model ?? '', c.isActive ? 1 : 0, c.customPrompt ?? null, c.useCustomPrompt ? 1 : 0, c.concurrency ?? 1, c.reasoningEffort ?? null ); + syncResult.inserted++; } - if (skippedConfigs.length > 0) { - console.warn('[configs] Skipped AI configs with missing keys:', skippedConfigs); + if (syncResult.skipped.length > 0) { + console.warn('[configs] Skipped AI configs with missing keys:', syncResult.skipped); + } + + // Safety guard: prevent committing an empty database when all configs were skipped + if (syncResult.inserted === 0 && configs.length > 0) { + throw new Error('ALL_CONFIGS_SKIPPED'); } }); bulkSync(); - res.json({ synced: configs.length }); + res.json({ synced: syncResult.inserted, skipped: syncResult.skipped.length, errors: syncResult.skipped }); } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); console.error('PUT /api/configs/ai/bulk error:', err); - res.status(500).json({ error: 'Failed to sync AI configs', code: 'SYNC_AI_CONFIGS_FAILED' }); + if (errMsg === 'ALL_CONFIGS_SKIPPED') { + const count = Array.isArray(req.body?.configs) ? req.body.configs.length : 0; + res.status(422).json({ error: 'All AI configs were skipped due to missing API keys', code: 'SYNC_AI_CONFIGS_ALL_SKIPPED', synced: 0, skipped: count }); + } else { + res.status(500).json({ error: 'Failed to sync AI configs', code: 'SYNC_AI_CONFIGS_FAILED' }); + } } }); @@ -314,6 +336,9 @@ router.put('/api/configs/webdav/bulk', (req, res) => { return; } + // Shared between transaction and response + const syncResult = { inserted: 0, skipped: [] as Array<{ id: string; name: string; reason: string }> }; + const bulkSync = db.transaction(() => { // Read existing passwords BEFORE delete const existingPwds = new Map(); @@ -332,22 +357,59 @@ router.put('/api/configs/webdav/bulk', (req, res) => { for (const c of configs) { let encryptedPwd = ''; if (c.password && !c.password.startsWith('***')) { - encryptedPwd = encrypt(c.password, config.encryptionKey); + try { + encryptedPwd = encrypt(String(c.password), config.encryptionKey); + } catch (encErr) { + console.error(`[configs] Failed to encrypt WebDAV password for "${c.name}" (${c.id}), falling back to existing:`, encErr); + encryptedPwd = existingPwds.get(String(c.id)) ?? ''; + if (!encryptedPwd) { + syncResult.skipped.push({ id: c.id, name: c.name ?? '', reason: 'encrypt_failed' }); + continue; + } + } } else { encryptedPwd = existingPwds.get(String(c.id)) ?? ''; } + + if (!encryptedPwd) { + syncResult.skipped.push({ + id: c.id, + name: c.name ?? '', + reason: c.password?.startsWith('***') + ? 'Password is masked and no existing password found' + : 'Password is empty', + }); + continue; + } + stmt.run( c.id, c.name ?? '', c.url ?? '', c.username ?? '', encryptedPwd, c.path ?? '/', c.isActive ? 1 : 0 ); + syncResult.inserted++; + } + + if (syncResult.skipped.length > 0) { + console.warn('[configs] Skipped WebDAV configs with missing passwords:', syncResult.skipped); + } + + // Safety guard: prevent committing an empty database when all configs were skipped + if (syncResult.inserted === 0 && configs.length > 0) { + throw new Error('ALL_CONFIGS_SKIPPED'); } }); bulkSync(); - res.json({ synced: configs.length }); + res.json({ synced: syncResult.inserted, skipped: syncResult.skipped.length, errors: syncResult.skipped }); } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); console.error('PUT /api/configs/webdav/bulk error:', err); - res.status(500).json({ error: 'Failed to sync WebDAV configs', code: 'SYNC_WEBDAV_CONFIGS_FAILED' }); + if (errMsg === 'ALL_CONFIGS_SKIPPED') { + const count = Array.isArray(req.body?.configs) ? req.body.configs.length : 0; + res.status(422).json({ error: 'All WebDAV configs were skipped due to missing passwords', code: 'SYNC_WEBDAV_CONFIGS_ALL_SKIPPED', synced: 0, skipped: count }); + } else { + res.status(500).json({ error: 'Failed to sync WebDAV configs', code: 'SYNC_WEBDAV_CONFIGS_FAILED' }); + } } }); diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 61df6b84..b3555496 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -340,12 +340,27 @@ class BackendAdapter { async syncAIConfigs(configs: AIConfig[]): Promise { if (!this._backendUrl) return; - const res = await this.fetchWithTimeout(`${this._backendUrl}/configs/ai/bulk`, { + // Pre-sync validation: warn about configs that will likely be skipped + for (const c of configs) { + if (!c.apiKey) { + console.warn(`[sync] AI config "${c.name}" (${c.id}) has empty apiKey, will be skipped if no existing key on backend`); + } + } + + const res = await this.fetchWithRetry(`${this._backendUrl}/configs/ai/bulk`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify({ configs }) - }); + }, 30000, 3); if (!res.ok) await this.throwTranslatedError(res, 'Sync AI configs error'); + + // Parse response for partial failure details + try { + const data = await res.json() as { synced?: number; skipped?: number; errors?: Array<{ id: string; name: string; reason: string }> }; + if (data.skipped && data.skipped > 0) { + console.warn(`[sync] ${data.skipped} AI config(s) skipped:`, data.errors); + } + } catch { /* ignore parse errors */ } } async fetchAIConfigs(): Promise { @@ -361,12 +376,27 @@ class BackendAdapter { async syncWebDAVConfigs(configs: WebDAVConfig[]): Promise { if (!this._backendUrl) return; - const res = await this.fetchWithTimeout(`${this._backendUrl}/configs/webdav/bulk`, { + // Pre-sync validation: warn about configs that will likely be skipped + for (const c of configs) { + if (!c.password) { + console.warn(`[sync] WebDAV config "${c.name}" (${c.id}) has empty password, will be skipped if no existing password on backend`); + } + } + + const res = await this.fetchWithRetry(`${this._backendUrl}/configs/webdav/bulk`, { method: 'PUT', headers: this.getAuthHeaders(), body: JSON.stringify({ configs }) - }); + }, 30000, 3); if (!res.ok) await this.throwTranslatedError(res, 'Sync WebDAV configs error'); + + // Parse response for partial failure details + try { + const data = await res.json() as { synced?: number; skipped?: number; errors?: Array<{ id: string; name: string; reason: string }> }; + if (data.skipped && data.skipped > 0) { + console.warn(`[sync] ${data.skipped} WebDAV config(s) skipped:`, data.errors); + } + } catch { /* ignore parse errors */ } } async fetchWebDAVConfigs(): Promise { diff --git a/src/utils/backendErrors.ts b/src/utils/backendErrors.ts index 00dbe5b1..7dc27e56 100644 --- a/src/utils/backendErrors.ts +++ b/src/utils/backendErrors.ts @@ -49,7 +49,9 @@ const ERROR_MESSAGES: Record = { UPDATE_WEBDAV_CONFIG_FAILED: { zh: '更新 WebDAV 配置失败', en: 'Failed to update WebDAV config' }, DELETE_WEBDAV_CONFIG_FAILED: { zh: '删除 WebDAV 配置失败', en: 'Failed to delete WebDAV config' }, SYNC_AI_CONFIGS_FAILED: { zh: '同步 AI 配置失败', en: 'Failed to sync AI configs' }, + SYNC_AI_CONFIGS_ALL_SKIPPED: { zh: '同步 AI 配置失败:所有配置均被跳过,请检查 API Key 是否正确填写', en: 'Failed to sync AI configs: all configs skipped, please check API Keys' }, SYNC_WEBDAV_CONFIGS_FAILED: { zh: '同步 WebDAV 配置失败', en: 'Failed to sync WebDAV configs' }, + SYNC_WEBDAV_CONFIGS_ALL_SKIPPED: { zh: '同步 WebDAV 配置失败:所有配置均被跳过,请检查密码是否正确填写', en: 'Failed to sync WebDAV configs: all configs skipped, please check passwords' }, INVALID_REQUEST: { zh: '无效的请求', en: 'Invalid request' }, FETCH_SETTINGS_FAILED: { zh: '获取设置失败', en: 'Failed to fetch settings' }, UPDATE_SETTINGS_FAILED: { zh: '更新设置失败', en: 'Failed to update settings' }, From 94529467fc51d97ea3c6722e8c034161823cccf7 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 28 May 2026 17:19:02 +0800 Subject: [PATCH 2/8] fix: address CodeRabbit review - include skip details in 422 response and throw on partial sync - Move syncResult declaration before try block so catch handler can access skip details in the ALL_CONFIGS_SKIPPED 422 response - Frontend sync methods now throw on partial failure (skipped > 0) instead of silently logging, so autoSync keeps _hasPendingLocalChanges and won't overwrite local data with incomplete backend state Co-Authored-By: Claude Opus 4.7 --- server/src/routes/configs.ts | 18 ++++++++---------- src/services/backendAdapter.ts | 20 ++++++++++++++------ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index 62035d40..55e0d9e1 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -103,6 +103,9 @@ router.post('/api/configs/ai', (req, res) => { // PUT /api/configs/ai/bulk — replace all AI configs (for sync) // MUST be registered before :id route to avoid matching 'bulk' as an id router.put('/api/configs/ai/bulk', (req, res) => { + // Shared between transaction, response, and error handler + const syncResult = { inserted: 0, skipped: [] as Array<{ id: string; name: string; reason: string }> }; + try { const db = getDb(); const configs = req.body.configs as Array<{ @@ -124,9 +127,6 @@ router.put('/api/configs/ai/bulk', (req, res) => { return; } - // Shared between transaction and response — transaction populates, response reads - const syncResult = { inserted: 0, skipped: [] as Array<{ id: string; name: string; reason: string }> }; - const bulkSync = db.transaction(() => { const existingKeys = new Map(); const existingRows = db.prepare('SELECT id, api_key_encrypted FROM ai_configs').all() as Array<{ id: string; api_key_encrypted: string }>; @@ -193,8 +193,7 @@ router.put('/api/configs/ai/bulk', (req, res) => { const errMsg = err instanceof Error ? err.message : String(err); console.error('PUT /api/configs/ai/bulk error:', err); if (errMsg === 'ALL_CONFIGS_SKIPPED') { - const count = Array.isArray(req.body?.configs) ? req.body.configs.length : 0; - res.status(422).json({ error: 'All AI configs were skipped due to missing API keys', code: 'SYNC_AI_CONFIGS_ALL_SKIPPED', synced: 0, skipped: count }); + res.status(422).json({ error: 'All AI configs were skipped due to missing API keys', code: 'SYNC_AI_CONFIGS_ALL_SKIPPED', synced: 0, skipped: syncResult.skipped.length, errors: syncResult.skipped }); } else { res.status(500).json({ error: 'Failed to sync AI configs', code: 'SYNC_AI_CONFIGS_FAILED' }); } @@ -319,6 +318,9 @@ router.post('/api/configs/webdav', (req, res) => { // PUT /api/configs/webdav/bulk — replace all WebDAV configs (for sync) // MUST be registered before :id route to avoid matching 'bulk' as an id router.put('/api/configs/webdav/bulk', (req, res) => { + // Shared between transaction, response, and error handler + const syncResult = { inserted: 0, skipped: [] as Array<{ id: string; name: string; reason: string }> }; + try { const db = getDb(); const configs = req.body.configs as Array<{ @@ -336,9 +338,6 @@ router.put('/api/configs/webdav/bulk', (req, res) => { return; } - // Shared between transaction and response - const syncResult = { inserted: 0, skipped: [] as Array<{ id: string; name: string; reason: string }> }; - const bulkSync = db.transaction(() => { // Read existing passwords BEFORE delete const existingPwds = new Map(); @@ -405,8 +404,7 @@ router.put('/api/configs/webdav/bulk', (req, res) => { const errMsg = err instanceof Error ? err.message : String(err); console.error('PUT /api/configs/webdav/bulk error:', err); if (errMsg === 'ALL_CONFIGS_SKIPPED') { - const count = Array.isArray(req.body?.configs) ? req.body.configs.length : 0; - res.status(422).json({ error: 'All WebDAV configs were skipped due to missing passwords', code: 'SYNC_WEBDAV_CONFIGS_ALL_SKIPPED', synced: 0, skipped: count }); + res.status(422).json({ error: 'All WebDAV configs were skipped due to missing passwords', code: 'SYNC_WEBDAV_CONFIGS_ALL_SKIPPED', synced: 0, skipped: syncResult.skipped.length, errors: syncResult.skipped }); } else { res.status(500).json({ error: 'Failed to sync WebDAV configs', code: 'SYNC_WEBDAV_CONFIGS_FAILED' }); } diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index b3555496..ee128aa0 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -354,13 +354,17 @@ class BackendAdapter { }, 30000, 3); if (!res.ok) await this.throwTranslatedError(res, 'Sync AI configs error'); - // Parse response for partial failure details + // Parse response and throw on partial failure so callers don't clear pending changes try { const data = await res.json() as { synced?: number; skipped?: number; errors?: Array<{ id: string; name: string; reason: string }> }; if (data.skipped && data.skipped > 0) { - console.warn(`[sync] ${data.skipped} AI config(s) skipped:`, data.errors); + const reasons = data.errors?.map(e => `${e.name}: ${e.reason}`).join('; ') ?? ''; + throw new Error(`Sync AI configs partial failure: ${data.skipped} skipped${reasons ? ` (${reasons})` : ''}`); } - } catch { /* ignore parse errors */ } + } catch (err) { + // Re-throw our own errors; ignore JSON parse errors from empty responses + if (err instanceof Error && err.message.startsWith('Sync AI configs partial failure')) throw err; + } } async fetchAIConfigs(): Promise { @@ -390,13 +394,17 @@ class BackendAdapter { }, 30000, 3); if (!res.ok) await this.throwTranslatedError(res, 'Sync WebDAV configs error'); - // Parse response for partial failure details + // Parse response and throw on partial failure so callers don't clear pending changes try { const data = await res.json() as { synced?: number; skipped?: number; errors?: Array<{ id: string; name: string; reason: string }> }; if (data.skipped && data.skipped > 0) { - console.warn(`[sync] ${data.skipped} WebDAV config(s) skipped:`, data.errors); + const reasons = data.errors?.map(e => `${e.name}: ${e.reason}`).join('; ') ?? ''; + throw new Error(`Sync WebDAV configs partial failure: ${data.skipped} skipped${reasons ? ` (${reasons})` : ''}`); } - } catch { /* ignore parse errors */ } + } catch (err) { + // Re-throw our own errors; ignore JSON parse errors from empty responses + if (err instanceof Error && err.message.startsWith('Sync WebDAV configs partial failure')) throw err; + } } async fetchWebDAVConfigs(): Promise { From 036fa3acf2190a23e5e20c0a722df01120e501d9 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 28 May 2026 19:26:00 +0800 Subject: [PATCH 3/8] fix: resolve sync cascade failure and category data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem 1: Backend returned 422 on ALL_CONFIGS_SKIPPED, causing frontend to throw and set _hasPendingLocalChanges=true, which blocked all subsequent pulls from backend and prevented other syncs (settings, repos) from completing. Problem 2: Backend decrypt_failed status caused empty apiKey values to overwrite valid local data, and subsequent push would skip all configs. Problem 3: handleSyncToBackend used sequential await, so one failure (like AI config sync) would abort the entire sync operation. Solutions: 1. Backend returns 200 (not 422) for ALL_CONFIGS_SKIPPED, since the transaction rollback preserves existing data correctly. 2. Frontend logs warnings instead of throwing on partial skip. 3. autoSync merges decrypt_failed configs with local values to preserve valid apiKey data instead of overwriting with empty strings. 4. handleSyncToBackend uses Promise.allSettled to isolate failures. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.7 --- server/src/routes/configs.ts | 9 ++++-- src/components/settings/BackendPanel.tsx | 36 ++++++++++++++++++------ src/services/autoSync.ts | 18 ++++++++++-- src/services/backendAdapter.ts | 18 ++++-------- 4 files changed, 56 insertions(+), 25 deletions(-) diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index 55e0d9e1..334a2c52 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -193,7 +193,10 @@ router.put('/api/configs/ai/bulk', (req, res) => { const errMsg = err instanceof Error ? err.message : String(err); console.error('PUT /api/configs/ai/bulk error:', err); if (errMsg === 'ALL_CONFIGS_SKIPPED') { - res.status(422).json({ error: 'All AI configs were skipped due to missing API keys', code: 'SYNC_AI_CONFIGS_ALL_SKIPPED', synced: 0, skipped: syncResult.skipped.length, errors: syncResult.skipped }); + // Transaction rolled back — existing data is preserved. + // Return 200 (not 422) so the sync chain is not blocked. + // The skipped/errors fields tell the frontend which configs were skipped. + res.json({ synced: 0, skipped: syncResult.skipped.length, errors: syncResult.skipped }); } else { res.status(500).json({ error: 'Failed to sync AI configs', code: 'SYNC_AI_CONFIGS_FAILED' }); } @@ -404,7 +407,9 @@ router.put('/api/configs/webdav/bulk', (req, res) => { const errMsg = err instanceof Error ? err.message : String(err); console.error('PUT /api/configs/webdav/bulk error:', err); if (errMsg === 'ALL_CONFIGS_SKIPPED') { - res.status(422).json({ error: 'All WebDAV configs were skipped due to missing passwords', code: 'SYNC_WEBDAV_CONFIGS_ALL_SKIPPED', synced: 0, skipped: syncResult.skipped.length, errors: syncResult.skipped }); + // Transaction rolled back — existing data is preserved. + // Return 200 (not 422) so the sync chain is not blocked. + res.json({ synced: 0, skipped: syncResult.skipped.length, errors: syncResult.skipped }); } else { res.status(500).json({ error: 'Failed to sync WebDAV configs', code: 'SYNC_WEBDAV_CONFIGS_FAILED' }); } diff --git a/src/components/settings/BackendPanel.tsx b/src/components/settings/BackendPanel.tsx index 0b545499..9ac04c08 100644 --- a/src/components/settings/BackendPanel.tsx +++ b/src/components/settings/BackendPanel.tsx @@ -90,15 +90,33 @@ export const BackendPanel: React.FC = ({ t }) => { } setIsSyncingToBackend(true); try { - await backend.syncRepositories(repositories); - await backend.syncReleases(releases); - await backend.syncAIConfigs(aiConfigs); - await backend.syncWebDAVConfigs(webdavConfigs); - await backend.syncSettings({ hiddenDefaultCategoryIds }); - toast(t( - `已同步到后端:仓库 ${repositories.length},发布 ${releases.length},AI配置 ${aiConfigs.length},WebDAV配置 ${webdavConfigs.length}`, - `Synced to backend: repos ${repositories.length}, releases ${releases.length}, AI configs ${aiConfigs.length}, WebDAV configs ${webdavConfigs.length}` - ), 'success'); + // Use allSettled so that one failure doesn't block other syncs + const results = await Promise.allSettled([ + backend.syncRepositories(repositories), + backend.syncReleases(releases), + backend.syncAIConfigs(aiConfigs), + backend.syncWebDAVConfigs(webdavConfigs), + backend.syncSettings({ hiddenDefaultCategoryIds }), + ]); + + const failures = results.filter(r => r.status === 'rejected'); + const successes = results.filter(r => r.status === 'fulfilled'); + + if (failures.length > 0) { + console.warn('Some syncs failed:', failures.map(f => (f as PromiseRejectedResult).reason)); + toast( + t( + `同步部分失败:${failures.length} 项失败,${successes.length} 项成功`, + `Partial sync failure: ${failures.length} failed, ${successes.length} succeeded` + ), + 'error' + ); + } else { + toast(t( + `已同步到后端:仓库 ${repositories.length},发布 ${releases.length},AI配置 ${aiConfigs.length},WebDAV配置 ${webdavConfigs.length}`, + `Synced to backend: repos ${repositories.length}, releases ${releases.length}, AI configs ${aiConfigs.length}, WebDAV configs ${webdavConfigs.length}` + ), 'success'); + } } catch (error) { console.error('Sync to backend failed:', error); toast(`${t('同步失败', 'Sync failed')}: ${(error as Error).message}`, 'error'); diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 608ab98f..f1acb40d 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -150,8 +150,22 @@ export async function syncFromBackend(): Promise { _lastHash.releases = hashes.releases; } if (changed.ai && aiResult.status === 'fulfilled') { - state.setAIConfigs(aiResult.value); - _lastHash.ai = hashes.ai; + // Filter out configs with decrypt_failed status — preserve local apiKey values + // to prevent backend decryption failures from overwriting valid local data. + const backendConfigs = aiResult.value; + const localConfigs = state.aiConfigs; + const mergedConfigs = backendConfigs.map(bc => { + if (bc.apiKeyStatus === 'decrypt_failed' || !bc.apiKey) { + const local = localConfigs.find(lc => lc.id === bc.id); + if (local && local.apiKey) { + console.warn(`[sync] Backend decrypt_failed for AI config "${bc.name}", preserving local apiKey`); + return { ...bc, apiKey: local.apiKey, apiKeyStatus: 'ok' as const }; + } + } + return bc; + }); + state.setAIConfigs(mergedConfigs); + _lastHash.ai = quickHash(mergedConfigs); } if (changed.webdav && webdavResult.status === 'fulfilled') { state.setWebDAVConfigs(webdavResult.value); diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index ee128aa0..b340ef4b 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -340,13 +340,6 @@ class BackendAdapter { async syncAIConfigs(configs: AIConfig[]): Promise { if (!this._backendUrl) return; - // Pre-sync validation: warn about configs that will likely be skipped - for (const c of configs) { - if (!c.apiKey) { - console.warn(`[sync] AI config "${c.name}" (${c.id}) has empty apiKey, will be skipped if no existing key on backend`); - } - } - const res = await this.fetchWithRetry(`${this._backendUrl}/configs/ai/bulk`, { method: 'PUT', headers: this.getAuthHeaders(), @@ -354,16 +347,17 @@ class BackendAdapter { }, 30000, 3); if (!res.ok) await this.throwTranslatedError(res, 'Sync AI configs error'); - // Parse response and throw on partial failure so callers don't clear pending changes + // Parse response to detect partial failures (some configs skipped due to missing keys). + // Log warnings but do not throw — the backend has preserved existing data via rollback, + // and throwing would block subsequent syncs of other data types (repos, settings). try { const data = await res.json() as { synced?: number; skipped?: number; errors?: Array<{ id: string; name: string; reason: string }> }; if (data.skipped && data.skipped > 0) { const reasons = data.errors?.map(e => `${e.name}: ${e.reason}`).join('; ') ?? ''; - throw new Error(`Sync AI configs partial failure: ${data.skipped} skipped${reasons ? ` (${reasons})` : ''}`); + console.warn(`[sync] ${data.skipped} AI config(s) skipped${reasons ? ` (${reasons})` : ''}`); } - } catch (err) { - // Re-throw our own errors; ignore JSON parse errors from empty responses - if (err instanceof Error && err.message.startsWith('Sync AI configs partial failure')) throw err; + } catch { + // Ignore parse errors (empty body, etc.) } } From 299de58f7c99696245842caaa56ddd619d423b4f Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 28 May 2026 19:39:07 +0800 Subject: [PATCH 4/8] fix: use raw backend hash for AI config change detection CodeRabbit identified that storing quickHash(mergedConfigs) in _lastHash.ai would cause a mismatch with the raw backend payload hash used in change detection, triggering infinite update loops on every poll cycle. Now storing hashes.ai (raw backend hash) to maintain consistency with the change detection comparison logic. --- src/services/autoSync.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index f1acb40d..40fbbe7c 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -165,7 +165,9 @@ export async function syncFromBackend(): Promise { return bc; }); state.setAIConfigs(mergedConfigs); - _lastHash.ai = quickHash(mergedConfigs); + // Store raw backend hash so change detection compares against the same payload. + // Using mergedConfigs would cause a mismatch and re-trigger on every poll. + _lastHash.ai = hashes.ai; } if (changed.webdav && webdavResult.status === 'fulfilled') { state.setWebDAVConfigs(webdavResult.value); From b083f9a6745ad5fe2aa80622232a0d4529de1b41 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 28 May 2026 19:56:57 +0800 Subject: [PATCH 5/8] fix: return 422 for ALL_CONFIGS_SKIPPED to emit error codes Backend now returns HTTP 422 (not 200) when all configs are skipped, so the error codes SYNC_AI_CONFIGS_ALL_SKIPPED and SYNC_WEBDAV_CONFIGS_ALL_SKIPPED reach the frontend error handling. Promise.allSettled keeps the rest of the sync run from being blocked. --- server/src/routes/configs.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index 334a2c52..d44d103b 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -193,10 +193,13 @@ router.put('/api/configs/ai/bulk', (req, res) => { const errMsg = err instanceof Error ? err.message : String(err); console.error('PUT /api/configs/ai/bulk error:', err); if (errMsg === 'ALL_CONFIGS_SKIPPED') { - // Transaction rolled back — existing data is preserved. - // Return 200 (not 422) so the sync chain is not blocked. - // The skipped/errors fields tell the frontend which configs were skipped. - res.json({ synced: 0, skipped: syncResult.skipped.length, errors: syncResult.skipped }); + res.status(422).json({ + error: 'All AI configs were skipped due to missing API keys', + code: 'SYNC_AI_CONFIGS_ALL_SKIPPED', + synced: 0, + skipped: syncResult.skipped.length, + errors: syncResult.skipped, + }); } else { res.status(500).json({ error: 'Failed to sync AI configs', code: 'SYNC_AI_CONFIGS_FAILED' }); } @@ -407,9 +410,13 @@ router.put('/api/configs/webdav/bulk', (req, res) => { const errMsg = err instanceof Error ? err.message : String(err); console.error('PUT /api/configs/webdav/bulk error:', err); if (errMsg === 'ALL_CONFIGS_SKIPPED') { - // Transaction rolled back — existing data is preserved. - // Return 200 (not 422) so the sync chain is not blocked. - res.json({ synced: 0, skipped: syncResult.skipped.length, errors: syncResult.skipped }); + res.status(422).json({ + error: 'All WebDAV configs were skipped due to missing passwords', + code: 'SYNC_WEBDAV_CONFIGS_ALL_SKIPPED', + synced: 0, + skipped: syncResult.skipped.length, + errors: syncResult.skipped, + }); } else { res.status(500).json({ error: 'Failed to sync WebDAV configs', code: 'SYNC_WEBDAV_CONFIGS_FAILED' }); } From df23785ff67f8c6ad5634cc6480af5cdda552c9a Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 28 May 2026 20:03:43 +0800 Subject: [PATCH 6/8] fix: throw error on partial skip in syncAIConfigs Align syncAIConfigs behavior with syncWebDAVConfigs - both now throw errors when configs are skipped, ensuring callers (autoSync, BackendPanel) properly track pending changes instead of clearing them on partial success. This addresses CodeRabbit's consistency concern about partial skip handling. --- src/services/backendAdapter.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index b340ef4b..6e0fbaf1 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -347,17 +347,16 @@ class BackendAdapter { }, 30000, 3); if (!res.ok) await this.throwTranslatedError(res, 'Sync AI configs error'); - // Parse response to detect partial failures (some configs skipped due to missing keys). - // Log warnings but do not throw — the backend has preserved existing data via rollback, - // and throwing would block subsequent syncs of other data types (repos, settings). + // Parse response and throw on partial failure so callers don't clear pending changes try { const data = await res.json() as { synced?: number; skipped?: number; errors?: Array<{ id: string; name: string; reason: string }> }; if (data.skipped && data.skipped > 0) { const reasons = data.errors?.map(e => `${e.name}: ${e.reason}`).join('; ') ?? ''; - console.warn(`[sync] ${data.skipped} AI config(s) skipped${reasons ? ` (${reasons})` : ''}`); + throw new Error(`Sync AI configs partial failure: ${data.skipped} skipped${reasons ? ` (${reasons})` : ''}`); } - } catch { - // Ignore parse errors (empty body, etc.) + } catch (err) { + // Re-throw our own errors; ignore JSON parse errors from empty responses + if (err instanceof Error && err.message.startsWith('Sync AI configs partial failure')) throw err; } } From a913d1c44dcaa6e2d200d69e70e02bc0a9c057f3 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 28 May 2026 20:23:30 +0800 Subject: [PATCH 7/8] fix: complete all remaining sync risks per Issue #166 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. WebDAV decrypt_failed protection: autoSync now merges decrypt_failed WebDAV configs with local password values, same as AI configs. Prevents backend decryption failures from overwriting valid local data. 2. Align syncAIConfigs/syncWebDAVConfigs: added pre-sync validation warnings to syncAIConfigs for empty apiKey configs, matching the pattern already in syncWebDAVConfigs. 3. BackendPanel handleSyncToBackend: now passes all 7 settings fields (activeAIConfig, activeWebDAVConfig, categoryOrder, customCategories, assetFilters, collapsedSidebarCategoryCount) instead of only hiddenDefaultCategoryIds. This prevents category data loss when manually syncing to backend — the root cause of the user's "分类数据丢失" complaint in Issue #166 comments. Co-Authored-By: Claude Opus 4.7 --- src/components/settings/BackendPanel.tsx | 16 +++++++++++++++- src/services/autoSync.ts | 17 ++++++++++++++++- src/services/backendAdapter.ts | 7 +++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/components/settings/BackendPanel.tsx b/src/components/settings/BackendPanel.tsx index 9ac04c08..8932c942 100644 --- a/src/components/settings/BackendPanel.tsx +++ b/src/components/settings/BackendPanel.tsx @@ -14,7 +14,13 @@ export const BackendPanel: React.FC = ({ t }) => { releases, aiConfigs, webdavConfigs, + activeAIConfig, + activeWebDAVConfig, hiddenDefaultCategoryIds, + categoryOrder, + customCategories, + assetFilters, + collapsedSidebarCategoryCount, backendApiSecret, setBackendApiSecret, setRepositories, @@ -96,7 +102,15 @@ export const BackendPanel: React.FC = ({ t }) => { backend.syncReleases(releases), backend.syncAIConfigs(aiConfigs), backend.syncWebDAVConfigs(webdavConfigs), - backend.syncSettings({ hiddenDefaultCategoryIds }), + backend.syncSettings({ + activeAIConfig, + activeWebDAVConfig, + hiddenDefaultCategoryIds, + categoryOrder, + customCategories, + assetFilters, + collapsedSidebarCategoryCount, + }), ]); const failures = results.filter(r => r.status === 'rejected'); diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 40fbbe7c..eacf1145 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -170,7 +170,22 @@ export async function syncFromBackend(): Promise { _lastHash.ai = hashes.ai; } if (changed.webdav && webdavResult.status === 'fulfilled') { - state.setWebDAVConfigs(webdavResult.value); + // Filter out configs with decrypt_failed status — preserve local password values + // to prevent backend decryption failures from overwriting valid local data. + const backendConfigs = webdavResult.value; + const localConfigs = state.webdavConfigs; + const mergedConfigs = backendConfigs.map(bc => { + if (bc.passwordStatus === 'decrypt_failed' || !bc.password) { + const local = localConfigs.find(lc => lc.id === bc.id); + if (local && local.password) { + console.warn(`[sync] Backend decrypt_failed for WebDAV config "${bc.name}", preserving local password`); + return { ...bc, password: local.password, passwordStatus: 'ok' as const }; + } + } + return bc; + }); + state.setWebDAVConfigs(mergedConfigs); + // Store raw backend hash for consistent change detection _lastHash.webdav = hashes.webdav; } // Sync active selections from settings diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 6e0fbaf1..ee128aa0 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -340,6 +340,13 @@ class BackendAdapter { async syncAIConfigs(configs: AIConfig[]): Promise { if (!this._backendUrl) return; + // Pre-sync validation: warn about configs that will likely be skipped + for (const c of configs) { + if (!c.apiKey) { + console.warn(`[sync] AI config "${c.name}" (${c.id}) has empty apiKey, will be skipped if no existing key on backend`); + } + } + const res = await this.fetchWithRetry(`${this._backendUrl}/configs/ai/bulk`, { method: 'PUT', headers: this.getAuthHeaders(), From e20b22f45dc09a1b87aa6123786af63a798ad157 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Thu, 28 May 2026 20:57:46 +0800 Subject: [PATCH 8/8] fix: use neutral error messages in 422 all-skipped responses CodeRabbit nitpick: the 422 error messages were too specific ('due to missing API keys'/'due to missing passwords'), which could misdiagnose the real failure. The per-config reasons are in the errors field, so the top-level message should be neutral and point users to that field. Aligned backend error messages, frontend error translations, and the error code naming convention. --- server/src/routes/configs.ts | 4 ++-- src/utils/backendErrors.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index d44d103b..4e62735d 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -194,7 +194,7 @@ router.put('/api/configs/ai/bulk', (req, res) => { console.error('PUT /api/configs/ai/bulk error:', err); if (errMsg === 'ALL_CONFIGS_SKIPPED') { res.status(422).json({ - error: 'All AI configs were skipped due to missing API keys', + error: 'All AI configs were skipped — check the errors field for per-config reasons', code: 'SYNC_AI_CONFIGS_ALL_SKIPPED', synced: 0, skipped: syncResult.skipped.length, @@ -411,7 +411,7 @@ router.put('/api/configs/webdav/bulk', (req, res) => { console.error('PUT /api/configs/webdav/bulk error:', err); if (errMsg === 'ALL_CONFIGS_SKIPPED') { res.status(422).json({ - error: 'All WebDAV configs were skipped due to missing passwords', + error: 'All WebDAV configs were skipped — check the errors field for per-config reasons', code: 'SYNC_WEBDAV_CONFIGS_ALL_SKIPPED', synced: 0, skipped: syncResult.skipped.length, diff --git a/src/utils/backendErrors.ts b/src/utils/backendErrors.ts index 7dc27e56..0b166b18 100644 --- a/src/utils/backendErrors.ts +++ b/src/utils/backendErrors.ts @@ -49,9 +49,9 @@ const ERROR_MESSAGES: Record = { UPDATE_WEBDAV_CONFIG_FAILED: { zh: '更新 WebDAV 配置失败', en: 'Failed to update WebDAV config' }, DELETE_WEBDAV_CONFIG_FAILED: { zh: '删除 WebDAV 配置失败', en: 'Failed to delete WebDAV config' }, SYNC_AI_CONFIGS_FAILED: { zh: '同步 AI 配置失败', en: 'Failed to sync AI configs' }, - SYNC_AI_CONFIGS_ALL_SKIPPED: { zh: '同步 AI 配置失败:所有配置均被跳过,请检查 API Key 是否正确填写', en: 'Failed to sync AI configs: all configs skipped, please check API Keys' }, + SYNC_AI_CONFIGS_ALL_SKIPPED: { zh: '同步 AI 配置失败:所有配置均被跳过,请查看错误详情', en: 'Failed to sync AI configs: all configs skipped, see errors for details' }, SYNC_WEBDAV_CONFIGS_FAILED: { zh: '同步 WebDAV 配置失败', en: 'Failed to sync WebDAV configs' }, - SYNC_WEBDAV_CONFIGS_ALL_SKIPPED: { zh: '同步 WebDAV 配置失败:所有配置均被跳过,请检查密码是否正确填写', en: 'Failed to sync WebDAV configs: all configs skipped, please check passwords' }, + SYNC_WEBDAV_CONFIGS_ALL_SKIPPED: { zh: '同步 WebDAV 配置失败:所有配置均被跳过,请查看错误详情', en: 'Failed to sync WebDAV configs: all configs skipped, see errors for details' }, INVALID_REQUEST: { zh: '无效的请求', en: 'Invalid request' }, FETCH_SETTINGS_FAILED: { zh: '获取设置失败', en: 'Failed to fetch settings' }, UPDATE_SETTINGS_FAILED: { zh: '更新设置失败', en: 'Failed to update settings' },