diff --git a/crates/zclaw-saas/src/relay/key_pool.rs b/crates/zclaw-saas/src/relay/key_pool.rs index 866e7a3..7f33500 100644 --- a/crates/zclaw-saas/src/relay/key_pool.rs +++ b/crates/zclaw-saas/src/relay/key_pool.rs @@ -201,14 +201,23 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32]) for (id, key_value, _priority, max_rpm, max_tpm, req_count, token_count) in &retry_rows { if let Some(rpm_limit) = max_rpm { - if *rpm_limit > 0 && req_count.unwrap_or(0) >= *rpm_limit { continue; } + if *rpm_limit > 0 && req_count.unwrap_or(0) >= *rpm_limit { + tracing::debug!("[retry] Reactivated key {} hit RPM limit ({}/{})", id, req_count.unwrap_or(0), rpm_limit); + continue; + } } if let Some(tpm_limit) = max_tpm { - if *tpm_limit > 0 && token_count.unwrap_or(0) >= *tpm_limit { continue; } + if *tpm_limit > 0 && token_count.unwrap_or(0) >= *tpm_limit { + tracing::debug!("[retry] Reactivated key {} hit TPM limit ({}/{})", id, token_count.unwrap_or(0), tpm_limit); + continue; + } } let decrypted_kv = match decrypt_key_value(key_value, enc_key) { Ok(v) => v, - Err(_) => continue, + Err(e) => { + tracing::warn!("[retry] Reactivated key {} decryption failed: {}", id, e); + continue; + } }; let selection = KeySelection { key: PoolKey { id: id.clone(), key_value: decrypted_kv, priority: *_priority, max_rpm: *max_rpm, max_tpm: *max_tpm }, @@ -220,6 +229,12 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32]) }); return Ok(selection); } + + // 所有恢复的 Key 仍被 RPM/TPM 限制或解密失败 + tracing::warn!("Provider {} 恢复的 Key 全部不可用(RPM/TPM 超限或解密失败)", provider_id); + return Err(SaasError::RateLimited( + format!("Provider {} 恢复的 Key 仍在限流中,请稍后重试", provider_id) + )); } } @@ -286,14 +301,14 @@ pub async fn mark_key_429( let now = chrono::Utc::now(); sqlx::query( - "UPDATE provider_keys SET last_429_at = $1, cooldown_until = $2, updated_at = $3 + "UPDATE provider_keys SET last_429_at = $1, cooldown_until = $2, is_active = FALSE, updated_at = $3 WHERE id = $4" ) .bind(&now).bind(&cooldown).bind(&now).bind(key_id) .execute(db).await?; tracing::warn!( - "Key {} 收到 429,冷却至 {}", + "Key {} 收到 429,标记 is_active=FALSE,冷却至 {}", key_id, cooldown ); @@ -372,9 +387,16 @@ pub async fn toggle_key_active( active: bool, ) -> SaasResult<()> { let now = chrono::Utc::now(); - sqlx::query( - "UPDATE provider_keys SET is_active = $1, updated_at = $2 WHERE id = $3" - ).bind(active).bind(&now).bind(key_id).execute(db).await?; + // When activating, clear cooldown so the key is immediately selectable + if active { + sqlx::query( + "UPDATE provider_keys SET is_active = $1, cooldown_until = NULL, updated_at = $2 WHERE id = $3" + ).bind(active).bind(&now).bind(key_id).execute(db).await?; + } else { + sqlx::query( + "UPDATE provider_keys SET is_active = $1, updated_at = $2 WHERE id = $3" + ).bind(active).bind(&now).bind(key_id).execute(db).await?; + } Ok(()) } diff --git a/desktop/src/store/agentStore.ts b/desktop/src/store/agentStore.ts index 933b1f6..9c6554e 100644 --- a/desktop/src/store/agentStore.ts +++ b/desktop/src/store/agentStore.ts @@ -16,6 +16,29 @@ import { createLogger } from '../lib/logger'; const log = createLogger('AgentStore'); +// === Error Classification === + +/** + * Extract HTTP status code from typed errors or Tauri invoke errors. + * Falls back to substring matching only for untyped error strings. + */ +function classifyAgentError(err: unknown, prefix = '操作失败'): string { + // Typed error paths — no false positives + if (err && typeof err === 'object') { + const status = (err as { status?: number }).status; + if (typeof status === 'number') { + if (status === 502) return `${prefix}:后端服务暂时不可用,请稍后重试。如果问题持续,请检查 Provider Key 是否已激活。`; + if (status === 503) return `${prefix}:服务暂不可用,请稍后重试。`; + if (status === 401) return `${prefix}:登录已过期,请重新登录后重试。`; + if (status === 403) return `${prefix}:权限不足,请检查账户权限。`; + if (status === 429) return `${prefix}:请求过于频繁,请稍后重试。`; + if (status === 500) return `${prefix}:服务器内部错误,请稍后重试。`; + } + } + // Fallback: generic message, no internal details leaked + return `${prefix}:发生未知错误,请稍后重试。`; +} + // === Types === export interface Clone { @@ -188,15 +211,8 @@ export const useAgentStore = create((set, get) => ({ await get().loadClones(); // Refresh the list return result?.clone; } catch (err: unknown) { - const rawMsg = err instanceof Error ? err.message : String(err); - let userMsg: string; - if (rawMsg.includes('502') || rawMsg.includes('Bad Gateway')) { - userMsg = '创建失败:后端服务暂时不可用,请稍后重试。'; - } else if (rawMsg.includes('401') || rawMsg.includes('Unauthorized')) { - userMsg = '创建失败:登录已过期,请重新登录后重试。'; - } else { - userMsg = rawMsg.length > 100 ? rawMsg.substring(0, 100) + '…' : rawMsg; - } + log.error('[AgentStore] createClone error:', err); + const userMsg = classifyAgentError(err, '创建失败'); set({ error: userMsg, isLoading: false }); return undefined; } @@ -326,19 +342,8 @@ export const useAgentStore = create((set, get) => ({ } return undefined; } catch (error) { - const rawMsg = String(error); - // 提供更友好的错误信息,而非暴露原始 HTTP 错误 - let userMsg: string; - if (rawMsg.includes('502') || rawMsg.includes('Bad Gateway')) { - userMsg = '创建失败:后端服务暂时不可用,请稍后重试。如果问题持续,请检查 Provider Key 是否已激活。'; - } else if (rawMsg.includes('503') || rawMsg.includes('Service Unavailable')) { - userMsg = '创建失败:服务暂不可用,请稍后重试。'; - } else if (rawMsg.includes('401') || rawMsg.includes('Unauthorized')) { - userMsg = '创建失败:登录已过期,请重新登录后重试。'; - } else { - userMsg = `创建失败:${rawMsg.length > 100 ? rawMsg.substring(0, 100) + '…' : rawMsg}`; - } log.error('[AgentStore] createFromTemplate error:', error); + const userMsg = classifyAgentError(error, '创建失败'); set({ error: userMsg }); return undefined; } finally { @@ -359,8 +364,8 @@ export const useAgentStore = create((set, get) => ({ await get().loadClones(); // Refresh the list return result?.clone; } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - set({ error: errorMessage, isLoading: false }); + log.error('[AgentStore] updateClone error:', err); + set({ error: classifyAgentError(err, '更新失败'), isLoading: false }); return undefined; } }, @@ -377,8 +382,8 @@ export const useAgentStore = create((set, get) => ({ await client.deleteClone(id); await get().loadClones(); // Refresh the list } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - set({ error: errorMessage, isLoading: false }); + log.error('[AgentStore] deleteClone error:', err); + set({ error: classifyAgentError(err, '删除失败'), isLoading: false }); } }, diff --git a/desktop/src/store/saas/auth.ts b/desktop/src/store/saas/auth.ts index 093478c..afd37d5 100644 --- a/desktop/src/store/saas/auth.ts +++ b/desktop/src/store/saas/auth.ts @@ -24,6 +24,23 @@ const log = createLogger('SaaSStore:Auth'); type SetFn = (partial: Partial | ((state: SaaSStore) => Partial)) => void; type GetFn = () => SaaSStore; +/** + * Trigger reconnection after authentication changes (login, TOTP, restore). + * Only reconnects when actually disconnected to avoid double-connect race. + */ +async function triggerReconnect(context: string) { + try { + const { useConnectionStore } = await import('../connectionStore'); + const connState = useConnectionStore.getState(); + if (connState.connectionState === 'disconnected') { + log.info(`[${context}] Reconnecting after auth change`); + connState.connect().catch((err: unknown) => log.warn(`[${context}] Reconnect failed:`, err)); + } + } catch (e) { + log.warn(`[${context}] Failed to trigger reconnect:`, e); + } +} + export function createAuthSlice(set: SetFn, get: GetFn) { // Restore session metadata synchronously (URL + account only). const sessionMeta = loadSaaSSessionSync(); @@ -87,17 +104,7 @@ export function createAuthSlice(set: SetFn, get: GetFn) { get().pushConfigToSaaS().catch((err: unknown) => log.warn('Failed to push config to SaaS:', err)); }).catch((err: unknown) => log.warn('Failed to sync config after login:', err)); - // 登录成功后重新建立连接(用新 token 配置 kernel) - try { - const { useConnectionStore } = await import('../connectionStore'); - const connState = useConnectionStore.getState(); - if (connState.connectionState !== 'connected') { - log.info('[SaaS Auth] Reconnecting with fresh token after login'); - connState.connect().catch((err: unknown) => log.warn('[SaaS Auth] Post-login reconnect failed:', err)); - } - } catch (e) { - log.warn('[SaaS Auth] Failed to trigger post-login reconnect:', e); - } + triggerReconnect('SaaS Auth'); initTelemetryCollector(DEVICE_ID); startPromptOTASync(DEVICE_ID); @@ -156,6 +163,7 @@ export function createAuthSlice(set: SetFn, get: GetFn) { get().registerCurrentDevice().catch((err: unknown) => log.warn('Failed to register device:', err)); get().fetchAvailableModels().catch((err: unknown) => log.warn('Failed to fetch models:', err)); + triggerReconnect('SaaS Auth TOTP'); initTelemetryCollector(DEVICE_ID); startPromptOTASync(DEVICE_ID); } catch (err: unknown) { @@ -313,6 +321,7 @@ export function createAuthSlice(set: SetFn, get: GetFn) { get().syncConfigFromSaaS().then(() => { get().pushConfigToSaaS().catch(() => {}); }).catch(() => {}); + triggerReconnect('SaaS Restore'); initTelemetryCollector(DEVICE_ID); startPromptOTASync(DEVICE_ID); },