From 4ee587d0708460e5c9221a0f6e9fdbc5a35c22e0 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 19 Apr 2026 13:16:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(relay,store):=20Provider=20Key=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=81=A2=E5=A4=8D=20+=20Agent=20=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E5=8F=8B=E5=A5=BD=E9=94=99=E8=AF=AF=20+=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=90=8E=E9=87=8D=E8=BF=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-1: key_pool.rs 新增 cooldown 过期 Key 自动恢复逻辑。 当所有 Key 的 is_active=false 且 cooldown_until 已过期时, 自动重新激活并重试选择,避免 relay/models 返回空数组导致聊天失败。 P0-2: agentStore.ts createClone/createFromTemplate 错误信息 从原始 HTTP 错误改为可操作的中文提示(502/503/401 分类处理)。 P1-2: auth.ts login 成功后触发 connectionStore.connect(), 确保 kernel 使用新 JWT 而非旧 token。 --- crates/zclaw-saas/src/relay/key_pool.rs | 67 +++++++++++++++++++++++-- desktop/src/store/agentStore.ts | 27 ++++++++-- desktop/src/store/saas/auth.ts | 12 +++++ 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/crates/zclaw-saas/src/relay/key_pool.rs b/crates/zclaw-saas/src/relay/key_pool.rs index 3718548..866e7a3 100644 --- a/crates/zclaw-saas/src/relay/key_pool.rs +++ b/crates/zclaw-saas/src/relay/key_pool.rs @@ -142,13 +142,13 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32]) return Ok(selection); } - // 所有 Key 都超限或无 Key — 先检查是否存在活跃 Key - let has_any_key: Option<(bool,)> = sqlx::query_as( + // 所有活跃 Key 都超限 — 先检查是否存在活跃 Key + let has_any_active: Option<(bool,)> = sqlx::query_as( "SELECT COUNT(*) > 0 FROM provider_keys WHERE provider_id = $1 AND is_active = TRUE" ).bind(provider_id).fetch_optional(db).await?; - if has_any_key.is_some_and(|(b,)| b) { - // 有 key 但全部 cooldown 或超限 — 检查最快恢复时间 + if has_any_active.is_some_and(|(b,)| b) { + // 有活跃 key 但全部 cooldown 或超限 — 检查最快恢复时间 let cooldown_row: Option<(String,)> = sqlx::query_as( "SELECT cooldown_until::TEXT FROM provider_keys WHERE provider_id = $1 AND is_active = TRUE AND cooldown_until IS NOT NULL AND cooldown_until::timestamptz > $2 @@ -169,7 +169,64 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32]) )); } - Err(SaasError::NotFound(format!("Provider {} 没有可用的 API Key", provider_id))) + // 没有活跃 Key — 自动恢复 cooldown 已过期但 is_active=false 的 Key + let reactivated: Option<(i64,)> = sqlx::query_as( + "UPDATE provider_keys SET is_active = TRUE, cooldown_until = NULL, updated_at = NOW() + WHERE provider_id = $1 AND is_active = FALSE + AND (cooldown_until IS NOT NULL AND cooldown_until::timestamptz <= $2) + RETURNING (SELECT COUNT(*) FROM provider_keys WHERE provider_id = $1 AND is_active = TRUE)" + ).bind(provider_id).bind(&now).fetch_optional(db).await?; + + if let Some((active_count,)) = &reactivated { + if *active_count > 0 { + tracing::info!( + "Provider {} 自动恢复了 {} 个 cooldown 过期的 Key,重试选择", + provider_id, active_count + ); + invalidate_cache(provider_id); + // 重试查询(不用递归,直接再走一次查询逻辑) + let retry_rows: Vec<(String, String, i32, Option, Option, Option, Option)> = + sqlx::query_as( + "SELECT pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm, + COALESCE(SUM(uw.request_count), 0)::bigint, + COALESCE(SUM(uw.token_count), 0)::bigint + FROM provider_keys pk + LEFT JOIN key_usage_window uw ON pk.id = uw.key_id + AND uw.window_minute >= to_char(NOW() - INTERVAL '1 minute', 'YYYY-MM-DDTHH24:MI') + WHERE pk.provider_id = $1 AND pk.is_active = TRUE + AND (pk.cooldown_until IS NULL OR pk.cooldown_until::timestamptz <= $2) + GROUP BY pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm + ORDER BY pk.priority ASC, pk.last_used_at ASC NULLS FIRST" + ).bind(provider_id).bind(&now).fetch_all(db).await?; + + 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 let Some(tpm_limit) = max_tpm { + if *tpm_limit > 0 && token_count.unwrap_or(0) >= *tpm_limit { continue; } + } + let decrypted_kv = match decrypt_key_value(key_value, enc_key) { + Ok(v) => v, + Err(_) => continue, + }; + let selection = KeySelection { + key: PoolKey { id: id.clone(), key_value: decrypted_kv, priority: *_priority, max_rpm: *max_rpm, max_tpm: *max_tpm }, + key_id: id.clone(), + }; + get_cache().insert(provider_id.to_string(), CachedSelection { + selection: selection.clone(), + cached_at: Instant::now(), + }); + return Ok(selection); + } + } + } + + Err(SaasError::NotFound(format!( + "Provider {} 没有可用的 API Key(所有 Key 已停用,请在管理后台激活)", + provider_id + ))) } /// 记录 Key 使用量(滑动窗口) diff --git a/desktop/src/store/agentStore.ts b/desktop/src/store/agentStore.ts index 87746ac..933b1f6 100644 --- a/desktop/src/store/agentStore.ts +++ b/desktop/src/store/agentStore.ts @@ -188,8 +188,16 @@ 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 }); + 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; + } + set({ error: userMsg, isLoading: false }); return undefined; } }, @@ -318,7 +326,20 @@ export const useAgentStore = create((set, get) => ({ } return undefined; } catch (error) { - set({ error: String(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); + set({ error: userMsg }); return undefined; } finally { set({ isLoading: false }); diff --git a/desktop/src/store/saas/auth.ts b/desktop/src/store/saas/auth.ts index 62d1bcd..093478c 100644 --- a/desktop/src/store/saas/auth.ts +++ b/desktop/src/store/saas/auth.ts @@ -87,6 +87,18 @@ 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); + } + initTelemetryCollector(DEVICE_ID); startPromptOTASync(DEVICE_ID); } catch (err: unknown) {