refactor(failover): merge failover_queue table into providers

- Add in_failover_queue field to providers table
- Remove standalone failover_queue table and related indexes
- Simplify queue ordering by reusing sort_index field
- Remove reorder_failover_queue and set_failover_item_enabled commands
- Update frontend to use simplified FailoverQueueItem type
This commit is contained in:
YoVinchen
2025-12-22 16:41:34 +08:00
Unverified
parent cabe1944ad
commit f6e8d656ce
23 changed files with 172 additions and 610 deletions
+1 -27
View File
@@ -1,6 +1,6 @@
//! 故障转移队列命令
//!
//! 管理代理模式下的故障转移队列
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::database::FailoverQueueItem;
use crate::provider::Provider;
@@ -56,32 +56,6 @@ pub async fn remove_from_failover_queue(
.map_err(|e| e.to_string())
}
/// 重新排序故障转移队列
#[tauri::command]
pub async fn reorder_failover_queue(
state: tauri::State<'_, AppState>,
app_type: String,
provider_ids: Vec<String>,
) -> Result<(), String> {
state
.db
.reorder_failover_queue(&app_type, &provider_ids)
.map_err(|e| e.to_string())
}
/// 设置故障转移队列项的启用状态
#[tauri::command]
pub async fn set_failover_item_enabled(
state: tauri::State<'_, AppState>,
app_type: String,
provider_id: String,
enabled: bool,
) -> Result<(), String> {
state
.db
.set_failover_item_enabled(&app_type, &provider_id, enabled)
.map_err(|e| e.to_string())
}
/// 获取指定应用的自动故障转移开关状态
#[tauri::command]
pub async fn get_auto_failover_enabled(
+6 -6
View File
@@ -156,18 +156,18 @@ pub async fn reset_circuit_breaker(
.get_failover_queue(&app_type)
.map_err(|e| e.to_string())?;
// 找到恢复的供应商和当前供应商在队列中的位置
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index
let restored_order = queue
.iter()
.find(|item| item.provider_id == provider_id && item.enabled)
.map(|item| item.queue_order);
.find(|item| item.provider_id == provider_id)
.and_then(|item| item.sort_index);
let current_order = queue
.iter()
.find(|item| item.provider_id == current_id && item.enabled)
.map(|item| item.queue_order);
.find(|item| item.provider_id == current_id)
.and_then(|item| item.sort_index);
// 如果恢复的供应商优先级更高(queue_order 更小),则切换
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
if let (Some(restored), Some(current)) = (restored_order, current_order) {
if restored < current {
log::info!(
+1 -3
View File
@@ -52,9 +52,7 @@ pub async fn stream_check_all_providers(
}
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
for item in queue {
if item.enabled {
ids.insert(item.provider_id);
}
ids.insert(item.provider_id);
}
}
Some(ids)
+29 -148
View File
@@ -1,36 +1,32 @@
//! 故障转移队列 DAO
//!
//! 管理代理模式下的故障转移队列
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use crate::provider::Provider;
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
/// 故障转移队列条目
/// 故障转移队列条目(简化版,用于前端展示)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FailoverQueueItem {
pub provider_id: String,
pub provider_name: String,
pub queue_order: i32,
pub enabled: bool,
pub created_at: i64,
pub sort_index: Option<usize>,
}
impl Database {
/// 获取故障转移队列(按 queue_order 排序)
/// 获取故障转移队列(按 sort_index 排序)
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn
.prepare(
"SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
FROM failover_queue fq
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
WHERE fq.app_type = ?1
ORDER BY fq.queue_order ASC",
"SELECT id, name, sort_index
FROM providers
WHERE app_type = ?1 AND in_failover_queue = 1
ORDER BY COALESCE(sort_index, 999999), id ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -39,9 +35,7 @@ impl Database {
Ok(FailoverQueueItem {
provider_id: row.get(0)?,
provider_name: row.get(1)?,
queue_order: row.get(2)?,
enabled: row.get(3)?,
created_at: row.get(4)?,
sort_index: row.get(2)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?
@@ -53,43 +47,23 @@ impl Database {
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let queue = self.get_failover_queue(app_type)?;
let all_providers = self.get_all_providers(app_type)?;
let mut result = Vec::new();
for item in queue {
if item.enabled {
if let Some(provider) = all_providers.get(&item.provider_id) {
result.push(provider.clone());
}
}
}
let result: Vec<Provider> = all_providers
.into_values()
.filter(|p| p.in_failover_queue)
.collect();
Ok(result)
}
/// 添加供应商到故障转移队列末尾
/// 添加供应商到故障转移队列
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 获取当前最大 queue_order
let max_order: i32 = conn
.query_row(
"SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1",
[app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
conn.execute(
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
VALUES (?1, ?2, ?3, 1, ?4)",
rusqlite::params![app_type, provider_id, max_order + 1, now],
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -104,104 +78,12 @@ impl Database {
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 获取被删除项的 queue_order
let removed_order: Option<i32> = conn
.query_row(
"SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
|row| row.get(0),
)
.ok();
// 删除该项
conn.execute(
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 重新排序后面的项(填补空隙)
if let Some(order) = removed_order {
conn.execute(
"UPDATE failover_queue
SET queue_order = queue_order - 1
WHERE app_type = ?1 AND queue_order > ?2",
rusqlite::params![app_type, order],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
}
/// 重新排序故障转移队列
/// provider_ids: 按新顺序排列的 provider_id 列表
pub fn reorder_failover_queue(
&self,
app_type: &str,
provider_ids: &[String],
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
// 使用事务确保原子性
conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| AppError::Database(e.to_string()))?;
let result = (|| {
for (index, provider_id) in provider_ids.iter().enumerate() {
conn.execute(
"UPDATE failover_queue
SET queue_order = ?3
WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![app_type, provider_id, (index + 1) as i32],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
Ok(())
})();
match result {
Ok(_) => {
conn.execute("COMMIT", [])
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
Err(e) => {
conn.execute("ROLLBACK", []).ok();
Err(e)
}
}
}
/// 设置故障转移队列中供应商的启用状态
pub fn set_failover_item_enabled(
&self,
app_type: &str,
provider_id: &str,
enabled: bool,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
let rows_affected = conn
.execute(
"UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
rusqlite::params![app_type, provider_id, enabled],
)
.map_err(|e| AppError::Database(e.to_string()))?;
if rows_affected == 0 {
log::warn!(
"set_failover_item_enabled: 未找到匹配记录 app_type={app_type}, provider_id={provider_id}"
);
return Err(AppError::Database(format!(
"未找到故障转移队列项: app_type={app_type}, provider_id={provider_id}"
)));
}
log::info!(
"set_failover_item_enabled: 已更新 app_type={app_type}, provider_id={provider_id}, enabled={enabled}"
);
Ok(())
}
@@ -209,8 +91,11 @@ impl Database {
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn);
conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
@@ -223,15 +108,15 @@ impl Database {
) -> Result<bool, AppError> {
let conn = lock_conn!(self.conn);
let count: i32 = conn
let in_queue: bool = conn
.query_row(
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
[app_type, provider_id],
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
rusqlite::params![provider_id, app_type],
|row| row.get(0),
)
.map_err(|e| AppError::Database(e.to_string()))?;
.unwrap_or(false);
Ok(count > 0)
Ok(in_queue)
}
/// 获取可添加到故障转移队列的供应商(不在队列中的)
@@ -240,14 +125,10 @@ impl Database {
app_type: &str,
) -> Result<Vec<Provider>, AppError> {
let all_providers = self.get_all_providers(app_type)?;
let queue = self.get_failover_queue(app_type)?;
let queue_ids: std::collections::HashSet<_> =
queue.iter().map(|item| &item.provider_id).collect();
let available: Vec<Provider> = all_providers
.into_values()
.filter(|p| !queue_ids.contains(&p.id))
.filter(|p| !p.in_failover_queue)
.collect();
Ok(available)
+19 -11
View File
@@ -17,7 +17,7 @@ impl Database {
) -> Result<IndexMap<String, Provider>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
FROM providers WHERE app_type = ?1
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -35,6 +35,7 @@ impl Database {
let icon: Option<String> = row.get(8)?;
let icon_color: Option<String> = row.get(9)?;
let meta_str: String = row.get(10)?;
let in_failover_queue: bool = row.get(11)?;
let settings_config =
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
@@ -54,6 +55,7 @@ impl Database {
meta: Some(meta),
icon,
icon_color,
in_failover_queue,
},
))
})
@@ -129,7 +131,7 @@ impl Database {
) -> Result<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn);
let result = conn.query_row(
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
FROM providers WHERE id = ?1 AND app_type = ?2",
params![id, app_type],
|row| {
@@ -143,6 +145,7 @@ impl Database {
let icon: Option<String> = row.get(7)?;
let icon_color: Option<String> = row.get(8)?;
let meta_str: String = row.get(9)?;
let in_failover_queue: bool = row.get(10)?;
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
@@ -159,6 +162,7 @@ impl Database {
meta: Some(meta),
icon,
icon_color,
in_failover_queue,
})
},
);
@@ -184,17 +188,18 @@ impl Database {
let mut meta_clone = provider.meta.clone().unwrap_or_default();
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
// 检查是否存在(用于判断新增/更新,以及保留 is_current
let existing: Option<bool> = tx
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue
let existing: Option<(bool, bool)> = tx
.query_row(
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
params![provider.id, app_type],
|row| row.get(0),
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
let is_update = existing.is_some();
let is_current = existing.unwrap_or(false);
let (is_current, in_failover_queue) =
existing.unwrap_or((false, provider.in_failover_queue));
if is_update {
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
@@ -210,8 +215,9 @@ impl Database {
icon = ?8,
icon_color = ?9,
meta = ?10,
is_current = ?11
WHERE id = ?12 AND app_type = ?13",
is_current = ?11,
in_failover_queue = ?12
WHERE id = ?13 AND app_type = ?14",
params![
provider.name,
serde_json::to_string(&provider.settings_config).unwrap(),
@@ -224,6 +230,7 @@ impl Database {
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
in_failover_queue,
provider.id,
app_type,
],
@@ -234,8 +241,8 @@ impl Database {
tx.execute(
"INSERT INTO providers (
id, app_type, name, settings_config, website_url, category,
created_at, sort_index, notes, icon, icon_color, meta, is_current
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
provider.id,
app_type,
@@ -250,6 +257,7 @@ impl Database {
provider.icon_color,
serde_json::to_string(&meta_clone).unwrap(),
is_current,
in_failover_queue,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
+27 -22
View File
@@ -31,6 +31,7 @@ impl Database {
icon_color TEXT,
meta TEXT NOT NULL DEFAULT '{}',
is_current BOOLEAN NOT NULL DEFAULT 0,
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (id, app_type)
)",
[],
@@ -312,29 +313,13 @@ impl Database {
[],
);
// 14. Failover Queue 表 (故障转移队列)
conn.execute(
"CREATE TABLE IF NOT EXISTS failover_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_type TEXT NOT NULL,
provider_id TEXT NOT NULL,
queue_order INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
UNIQUE (app_type, provider_id),
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
)",
// 为故障转移队列创建索引(基于 providers 表)
// 注意:此索引依赖 in_failover_queue 列,对于旧数据库会在迁移时创建
let _ = conn.execute(
"CREATE INDEX IF NOT EXISTS idx_providers_failover
ON providers(app_type, in_failover_queue, sort_index)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 为故障转移队列创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_failover_queue_order
ON failover_queue(app_type, queue_order)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
);
Ok(())
}
@@ -472,6 +457,26 @@ impl Database {
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
Self::add_column_if_missing(
conn,
"providers",
"in_failover_queue",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
// 删除旧的 failover_queue 表(如果存在)
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
conn.execute("DROP TABLE IF EXISTS failover_queue", [])
.map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
// 创建 failover 索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_providers_failover
ON providers(app_type, in_failover_queue, sort_index)",
[],
)
.map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
// proxy_request_logs 表(包含所有字段)
conn.execute(
+1
View File
@@ -245,6 +245,7 @@ fn dry_run_validates_schema_compatibility() {
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
},
);
+1
View File
@@ -132,6 +132,7 @@ pub(crate) fn build_provider_from_request(
meta,
icon: request.icon.clone(),
icon_color: None,
in_failover_queue: false,
};
Ok(provider)
-2
View File
@@ -708,8 +708,6 @@ pub fn run() {
commands::get_available_providers_for_failover,
commands::add_to_failover_queue,
commands::remove_from_failover_queue,
commands::reorder_failover_queue,
commands::set_failover_item_enabled,
commands::get_auto_failover_enabled,
commands::set_auto_failover_enabled,
// Usage statistics
+5
View File
@@ -36,6 +36,10 @@ pub struct Provider {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "iconColor")]
pub icon_color: Option<String>,
/// 是否加入故障转移队列
#[serde(default)]
#[serde(rename = "inFailoverQueue")]
pub in_failover_queue: bool,
}
impl Provider {
@@ -58,6 +62,7 @@ impl Provider {
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
}
+30 -35
View File
@@ -34,7 +34,6 @@ impl ProviderRouter {
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let mut result = Vec::new();
let all_providers = self.db.get_all_providers(app_type)?;
// 检查该应用的自动故障转移开关是否开启
let failover_key = format!("auto_failover_enabled_{app_type}");
@@ -61,42 +60,34 @@ impl ProviderRouter {
};
if auto_failover_enabled {
// 故障转移开启:完全按照队列顺序,忽略当前供应商
let queue = self.db.get_failover_queue(app_type)?;
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
let failover_providers = self.db.get_failover_providers(app_type)?;
log::info!(
"[{}] Failover enabled, using queue order ({} items)",
app_type,
queue.len()
failover_providers.len()
);
for item in queue {
// 跳过禁用的队列项
if !item.enabled {
continue;
}
for provider in failover_providers {
// 检查熔断器状态
let circuit_key = format!("{}:{}", app_type, provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
// 获取供应商信息
if let Some(provider) = all_providers.get(&item.provider_id) {
// 检查熔断器状态
let circuit_key = format!("{}:{}", app_type, provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
if breaker.is_available().await {
log::info!(
"[{}] Queue provider available: {} ({}) at position {}",
app_type,
provider.name,
provider.id,
item.queue_order
);
result.push(provider.clone());
} else {
log::debug!(
"[{}] Queue provider {} circuit breaker open, skipping",
app_type,
provider.name
);
}
if breaker.is_available().await {
log::info!(
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
app_type,
provider.name,
provider.id,
provider.sort_index
);
result.push(provider);
} else {
log::debug!(
"[{}] Queue provider {} circuit breaker open, skipping",
app_type,
provider.name
);
}
}
} else {
@@ -104,7 +95,7 @@ impl ProviderRouter {
log::info!("[{app_type}] Failover disabled, using current provider only");
if let Some(current_id) = self.db.get_current_provider(app_type)? {
if let Some(current) = all_providers.get(&current_id) {
if let Some(current) = self.db.get_provider_by_id(&current_id, app_type)? {
let circuit_key = format!("{}:{}", app_type, current.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
@@ -115,7 +106,7 @@ impl ProviderRouter {
current.name,
current.id
);
result.push(current.clone());
result.push(current);
} else {
log::warn!(
"[{}] Current provider {} circuit breaker open",
@@ -320,10 +311,13 @@ mod tests {
async fn test_failover_enabled_uses_queue_order() {
let db = Arc::new(Database::memory().unwrap());
let provider_a =
// 设置 sort_index 来控制顺序:b=1, a=2
let mut provider_a =
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
let provider_b =
provider_a.sort_index = Some(2);
let mut provider_b =
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
provider_b.sort_index = Some(1);
db.save_provider("claude", &provider_a).unwrap();
db.save_provider("claude", &provider_b).unwrap();
@@ -338,6 +332,7 @@ mod tests {
let providers = router.select_providers("claude").await.unwrap();
assert_eq!(providers.len(), 2);
// 按 sort_index 排序:b(1) 在前,a(2) 在后
assert_eq!(providers[0].id, "b");
assert_eq!(providers[1].id, "a");
}
+1
View File
@@ -270,6 +270,7 @@ mod tests {
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
+1
View File
@@ -174,6 +174,7 @@ mod tests {
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
+1
View File
@@ -250,6 +250,7 @@ mod tests {
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
+1
View File
@@ -209,6 +209,7 @@ mod tests {
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
@@ -394,6 +394,7 @@ mod tests {
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
}
}
+4 -49
View File
@@ -17,9 +17,8 @@ import {
useFailoverQueue,
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
useReorderFailoverQueue,
} from "@/lib/query/failover";
import { useCallback, useEffect, useRef } from "react";
import { useCallback } from "react";
interface ProviderListProps {
providers: Record<string, Provider>;
@@ -67,22 +66,16 @@ export function ProviderList({
const { data: failoverQueue } = useFailoverQueue(appId);
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
const reorderQueue = useReorderFailoverQueue();
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
const isFailoverModeActive =
isProxyTakeover === true && isAutoFailoverEnabled === true;
// 防止重复调用的 ref
const lastReorderRef = useRef<string>("");
// 计算供应商在故障转移队列中的优先级
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
const getFailoverPriority = useCallback(
(providerId: string): number | undefined => {
if (!isFailoverModeActive || !failoverQueue) return undefined;
// 只计算已启用的供应商的优先级
const enabledQueue = failoverQueue.filter((item) => item.enabled);
const index = enabledQueue.findIndex(
const index = failoverQueue.findIndex(
(item) => item.providerId === providerId,
);
return index >= 0 ? index + 1 : undefined;
@@ -94,9 +87,7 @@ export function ProviderList({
const isInFailoverQueue = useCallback(
(providerId: string): boolean => {
if (!isFailoverModeActive || !failoverQueue) return false;
return failoverQueue.some(
(item) => item.providerId === providerId && item.enabled,
);
return failoverQueue.some((item) => item.providerId === providerId);
},
[isFailoverModeActive, failoverQueue],
);
@@ -113,42 +104,6 @@ export function ProviderList({
[appId, addToQueue, removeFromQueue],
);
// 当拖拽排序后,同步故障转移队列顺序
useEffect(() => {
if (!isFailoverModeActive || !failoverQueue || failoverQueue.length === 0)
return;
// 获取当前在队列中且已启用的供应商 ID 列表(按显示顺序)
const enabledProviderIds = sortedProviders
.filter((p) => isInFailoverQueue(p.id))
.map((p) => p.id);
if (enabledProviderIds.length === 0) return;
// 生成唯一标识防止重复调用
const orderKey = enabledProviderIds.join(",");
if (orderKey === lastReorderRef.current) return;
// 检查顺序是否需要更新
const currentOrder = failoverQueue
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => item.providerId)
.join(",");
if (orderKey !== currentOrder) {
lastReorderRef.current = orderKey;
reorderQueue.mutate({ appType: appId, providerIds: enabledProviderIds });
}
}, [
sortedProviders,
isFailoverModeActive,
failoverQueue,
isInFailoverQueue,
appId,
reorderQueue,
]);
const handleTest = (provider: Provider) => {
checkProvider(provider.id, provider.name);
};
+24 -177
View File
@@ -2,37 +2,14 @@
* 故障转移队列管理组件
*
* 允许用户管理代理模式下的故障转移队列,支持:
* - 拖拽排序
* - 添加/移除供应商
* - 启用/禁用队列项
* - 队列顺序基于首页供应商列表的 sort_index
*/
import { useState, useCallback, useMemo } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CSS } from "@dnd-kit/utilities";
import { DndContext, closestCenter } from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { toast } from "sonner";
import {
GripVertical,
Plus,
Trash2,
Loader2,
Info,
AlertTriangle,
} from "lucide-react";
import { Plus, Trash2, Loader2, Info, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -51,8 +28,6 @@ import {
useAvailableProvidersForFailover,
useAddToFailoverQueue,
useRemoveFromFailoverQueue,
useReorderFailoverQueue,
useSetFailoverItemEnabled,
useAutoFailoverEnabled,
useSetAutoFailoverEnabled,
} from "@/lib/query/failover";
@@ -85,65 +60,12 @@ export function FailoverQueueManager({
// Mutations
const addToQueue = useAddToFailoverQueue();
const removeFromQueue = useRemoveFromFailoverQueue();
const reorderQueue = useReorderFailoverQueue();
const setItemEnabled = useSetFailoverItemEnabled();
// 切换故障转移开关
const handleToggleFailover = (enabled: boolean) => {
setFailoverEnabled.mutate({ appType, enabled });
};
// 拖拽配置
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// 排序后的队列
const sortedQueue = useMemo(() => {
if (!queue) return [];
return [...queue].sort((a, b) => a.queueOrder - b.queueOrder);
}, [queue]);
// 处理拖拽结束
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id || !sortedQueue) return;
const oldIndex = sortedQueue.findIndex(
(item) => item.providerId === active.id,
);
const newIndex = sortedQueue.findIndex(
(item) => item.providerId === over.id,
);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(sortedQueue, oldIndex, newIndex);
const providerIds = reordered.map((item) => item.providerId);
try {
await reorderQueue.mutateAsync({ appType, providerIds });
toast.success(
t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"),
{ closeButton: true },
);
} catch (error) {
toast.error(
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
": " +
String(error),
);
}
},
[sortedQueue, appType, reorderQueue, t],
);
// 添加供应商到队列
const handleAddProvider = async () => {
if (!selectedProviderId) return;
@@ -182,19 +104,6 @@ export function FailoverQueueManager({
}
};
// 切换启用状态
const handleToggleEnabled = async (providerId: string, enabled: boolean) => {
try {
await setItemEnabled.mutateAsync({ appType, providerId, enabled });
} catch (error) {
toast.error(
t("proxy.failoverQueue.toggleFailed", "状态更新失败") +
": " +
String(error),
);
}
};
if (isQueueLoading) {
return (
<div className="flex items-center justify-center p-8">
@@ -248,7 +157,7 @@ export function FailoverQueueManager({
<AlertDescription className="text-sm">
{t(
"proxy.failoverQueue.info",
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
"队列顺序与首页供应商列表顺序一致。当请求失败时,系统会按顺序依次尝试队列中的供应商。",
)}
</AlertDescription>
</Alert>
@@ -299,7 +208,7 @@ export function FailoverQueueManager({
</div>
{/* 队列列表 */}
{sortedQueue.length === 0 ? (
{!queue || queue.length === 0 ? (
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
<p className="text-sm text-muted-foreground">
{t(
@@ -309,39 +218,26 @@ export function FailoverQueueManager({
</p>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={sortedQueue.map((item) => item.providerId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{sortedQueue.map((item, index) => (
<SortableQueueItem
key={item.providerId}
item={item}
index={index}
disabled={disabled}
onToggleEnabled={handleToggleEnabled}
onRemove={handleRemoveProvider}
isRemoving={removeFromQueue.isPending}
isToggling={setItemEnabled.isPending}
/>
))}
</div>
</SortableContext>
</DndContext>
<div className="space-y-2">
{queue.map((item, index) => (
<QueueItem
key={item.providerId}
item={item}
index={index}
disabled={disabled}
onRemove={handleRemoveProvider}
isRemoving={removeFromQueue.isPending}
/>
))}
</div>
)}
{/* 队列说明 */}
{sortedQueue.length > 0 && (
{queue && queue.length > 0 && (
<p className="text-xs text-muted-foreground">
{t(
"proxy.failoverQueue.dragHint",
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
"proxy.failoverQueue.orderHint",
"队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。",
)}
</p>
)}
@@ -349,65 +245,29 @@ export function FailoverQueueManager({
);
}
interface SortableQueueItemProps {
interface QueueItemProps {
item: FailoverQueueItem;
index: number;
disabled: boolean;
onToggleEnabled: (providerId: string, enabled: boolean) => void;
onRemove: (providerId: string) => void;
isRemoving: boolean;
isToggling: boolean;
}
function SortableQueueItem({
function QueueItem({
item,
index,
disabled,
onToggleEnabled,
onRemove,
isRemoving,
isToggling,
}: SortableQueueItemProps) {
}: QueueItemProps) {
const { t } = useTranslation();
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({ id: item.providerId, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
isDragging && "opacity-50 shadow-lg",
!item.enabled && "opacity-60",
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className={cn(
"cursor-grab touch-none text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed opacity-50",
)}
{...attributes}
{...listeners}
disabled={disabled}
aria-label={t("provider.dragHandle", "拖拽排序")}
>
<GripVertical className="h-5 w-5" />
</button>
{/* 序号 */}
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
{index + 1}
@@ -415,24 +275,11 @@ function SortableQueueItem({
{/* 供应商名称 */}
<div className="flex-1 min-w-0">
<span
className={cn(
"text-sm font-medium truncate block",
!item.enabled && "text-muted-foreground line-through",
)}
>
<span className="text-sm font-medium truncate block">
{item.providerName}
</span>
</div>
{/* 启用开关 */}
<Switch
checked={item.enabled}
onCheckedChange={(checked) => onToggleEnabled(item.providerId, checked)}
disabled={disabled || isToggling}
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
/>
{/* 删除按钮 */}
<Button
variant="ghost"
+12 -21
View File
@@ -147,13 +147,10 @@ export function ProxyPanel() {
<ProviderQueueGroup
appType="claude"
appLabel="Claude"
targets={claudeQueue
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
targets={claudeQueue.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
@@ -163,13 +160,10 @@ export function ProxyPanel() {
<ProviderQueueGroup
appType="codex"
appLabel="Codex"
targets={codexQueue
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
targets={codexQueue.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
@@ -179,13 +173,10 @@ export function ProxyPanel() {
<ProviderQueueGroup
appType="gemini"
appLabel="Gemini"
targets={geminiQueue
.filter((item) => item.enabled)
.sort((a, b) => a.queueOrder - b.queueOrder)
.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
targets={geminiQueue.map((item) => ({
id: item.providerId,
name: item.providerName,
}))}
status={status}
/>
)}
-21
View File
@@ -84,27 +84,6 @@ export const failoverApi = {
return invoke("remove_from_failover_queue", { appType, providerId });
},
// 重新排序故障转移队列
async reorderFailoverQueue(
appType: string,
providerIds: string[],
): Promise<void> {
return invoke("reorder_failover_queue", { appType, providerIds });
},
// 设置故障转移队列项的启用状态
async setFailoverItemEnabled(
appType: string,
providerId: string,
enabled: boolean,
): Promise<void> {
return invoke("set_failover_item_enabled", {
appType,
providerId,
enabled,
});
},
// 获取指定应用的自动故障转移开关状态
async getAutoFailoverEnabled(appType: string): Promise<boolean> {
return invoke("get_auto_failover_enabled", { appType });
+4 -85
View File
@@ -131,6 +131,9 @@ export function useAddToFailoverQueue() {
queryClient.invalidateQueries({
queryKey: ["availableProvidersForFailover", variables.appType],
});
queryClient.invalidateQueries({
queryKey: ["providers", variables.appType],
});
},
});
}
@@ -156,92 +159,8 @@ export function useRemoveFromFailoverQueue() {
queryClient.invalidateQueries({
queryKey: ["availableProvidersForFailover", variables.appType],
});
},
});
}
/**
* 重新排序故障转移队列
*/
export function useReorderFailoverQueue() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerIds,
}: {
appType: string;
providerIds: string[];
}) => failoverApi.reorderFailoverQueue(appType, providerIds),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
});
},
});
}
/**
* 设置故障转移队列项的启用状态
* 使用乐观更新(Optimistic Update)以提供即时反馈
*/
export function useSetFailoverItemEnabled() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
appType,
providerId,
enabled,
}: {
appType: string;
providerId: string;
enabled: boolean;
}) => failoverApi.setFailoverItemEnabled(appType, providerId, enabled),
// 乐观更新:立即更新缓存中的数据
onMutate: async (variables) => {
// 取消正在进行的查询,防止覆盖乐观更新
await queryClient.cancelQueries({
queryKey: ["failoverQueue", variables.appType],
});
// 保存之前的数据以便回滚
const previousQueue = queryClient.getQueryData<
import("@/types/proxy").FailoverQueueItem[]
>(["failoverQueue", variables.appType]);
// 乐观地更新缓存
if (previousQueue) {
queryClient.setQueryData<import("@/types/proxy").FailoverQueueItem[]>(
["failoverQueue", variables.appType],
previousQueue.map((item) =>
item.providerId === variables.providerId
? { ...item, enabled: variables.enabled }
: item,
),
);
}
// 返回上下文供 onError 使用
return { previousQueue };
},
// 错误时回滚
onError: (_error, variables, context) => {
if (context?.previousQueue) {
queryClient.setQueryData(
["failoverQueue", variables.appType],
context.previousQueue,
);
}
},
// 无论成功失败,都重新获取最新数据以确保一致性
onSettled: (_, __, variables) => {
queryClient.invalidateQueries({
queryKey: ["failoverQueue", variables.appType],
queryKey: ["providers", variables.appType],
});
},
});
+2
View File
@@ -23,6 +23,8 @@ export interface Provider {
// 图标配置
icon?: string; // 图标名称(如 "openai", "anthropic"
iconColor?: string; // 图标颜色(Hex 格式,如 "#00A67E"
// 是否加入故障转移队列
inFailoverQueue?: boolean;
}
export interface AppConfig {
+1 -3
View File
@@ -103,7 +103,5 @@ export interface ProxyUsageRecord {
export interface FailoverQueueItem {
providerId: string;
providerName: string;
queueOrder: number;
enabled: boolean;
createdAt: number;
sortIndex?: number;
}