fix(relay,store): 审计修复 — 自动恢复可达化 + 类型化错误 + 全路径重连
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
C1: mark_key_429 设 is_active=FALSE,使 select_best_key 自动恢复 路径真正可达。之前 429 只设 cooldown_until,恢复代码为死代码。 H1+H2: 重试查询补全 debug 日志(RPM/TPM 跳过、解密失败)+ 修复 fallthrough 错误信息(RateLimited 而非 NotFound)。 H3+H4+M3+M4+M5: agentStore.ts 提取 classifyAgentError() 类型化错误 分类,覆盖 502/503/401/403/429/500,统一 createClone/ createFromTemplate/updateClone/deleteClone 错误处理,不再泄露原始 错误详情。所有 catch 块添加 log.error。 H5+H6: auth.ts 提取 triggerReconnect() 共享函数,login/loginWithTotp/ restoreSession 三处统一调用。状态检查改为仅 'disconnected' 时触发, 避免 connecting/reconnecting 状态下并发 connect。 M1: toggle_key_active(true) 同步清除 cooldown_until,防止管理员 激活后 key 仍被 cooldown 过滤不可见。
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AgentStore>((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<AgentStore>((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<AgentStore>((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<AgentStore>((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 });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -24,6 +24,23 @@ const log = createLogger('SaaSStore:Auth');
|
||||
type SetFn = (partial: Partial<SaaSStore> | ((state: SaaSStore) => Partial<SaaSStore>)) => 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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user