diff --git a/app/router/config_routes.py b/app/router/config_routes.py
index 5f1149c..b11c88d 100644
--- a/app/router/config_routes.py
+++ b/app/router/config_routes.py
@@ -2,10 +2,11 @@
配置路由模块
"""
-from typing import Any, Dict
+from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
+from pydantic import BaseModel, Field
from app.core.security import verify_auth_token
from app.log.logger import Logger, get_config_routes_logger
@@ -54,6 +55,73 @@ async def reset_config(request: Request):
raise HTTPException(status_code=400, detail=str(e))
+# Pydantic model for bulk delete request
+class DeleteKeysRequest(BaseModel):
+ keys: List[str] = Field(..., description="List of API keys to delete")
+
+
+@router.delete("/keys/{key_to_delete}", response_model=Dict[str, Any])
+async def delete_single_key(key_to_delete: str, request: Request):
+ auth_token = request.cookies.get("auth_token")
+ if not auth_token or not verify_auth_token(auth_token):
+ logger.warning(f"Unauthorized attempt to delete key: {key_to_delete}")
+ return RedirectResponse(url="/", status_code=302)
+ try:
+ logger.info(f"Attempting to delete key: {key_to_delete}")
+ result = await ConfigService.delete_key(key_to_delete)
+ if not result.get("success"):
+ # Optionally, translate specific errors to HTTP status codes
+ # For now, let's assume 400 for any failure from service if not found,
+ # or 500 if it was an unexpected error (though service should handle that)
+ raise HTTPException(
+ status_code=(
+ 404 if "not found" in result.get("message", "").lower() else 400
+ ),
+ detail=result.get("message"),
+ )
+ return result
+ except HTTPException as e:
+ # Re-raise HTTPExceptions directly
+ raise e
+ except Exception as e:
+ logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=f"Error deleting key: {str(e)}")
+
+
+@router.post("/keys/delete-selected", response_model=Dict[str, Any])
+async def delete_selected_keys_route(
+ delete_request: DeleteKeysRequest, request: Request
+):
+ auth_token = request.cookies.get("auth_token")
+ if not auth_token or not verify_auth_token(auth_token):
+ logger.warning("Unauthorized attempt to bulk delete keys")
+ return RedirectResponse(url="/", status_code=302)
+
+ if not delete_request.keys:
+ logger.warning("Attempt to bulk delete keys with an empty list.")
+ raise HTTPException(status_code=400, detail="No keys provided for deletion.")
+
+ try:
+ logger.info(f"Attempting to bulk delete {len(delete_request.keys)} keys.")
+ result = await ConfigService.delete_selected_keys(delete_request.keys)
+ # Similar to single delete, we can check result["success"]
+ if not result.get("success") and result.get("deleted_count", 0) == 0:
+ # If no keys were actually deleted, it might be a client error (e.g., all keys not found)
+ # or an empty list was somehow passed despite the check above.
+ raise HTTPException(
+ status_code=400, detail=result.get("message", "Failed to delete keys.")
+ )
+ # If some keys were deleted but others not found, it's still a partial success, return 200 with details.
+ return result
+ except HTTPException as e:
+ raise e
+ except Exception as e:
+ logger.error(f"Error bulk deleting keys: {e}", exc_info=True)
+ raise HTTPException(
+ status_code=500, detail=f"Error bulk deleting keys: {str(e)}"
+ )
+
+
@router.get("/ui/models")
async def get_ui_models(request: Request):
auth_token_cookie = request.cookies.get("auth_token")
diff --git a/app/service/config/config_service.py b/app/service/config/config_service.py
index a499155..78fa553 100644
--- a/app/service/config/config_service.py
+++ b/app/service/config/config_service.py
@@ -125,6 +125,78 @@ class ConfigService:
return await ConfigService.get_config()
+ @staticmethod
+ async def delete_key(key_to_delete: str) -> Dict[str, Any]:
+ """删除单个API密钥"""
+ # 确保 settings.API_KEYS 是一个列表
+ if not isinstance(settings.API_KEYS, list):
+ settings.API_KEYS = []
+
+ original_keys_count = len(settings.API_KEYS)
+ # 创建一个不包含待删除密钥的新列表
+ updated_api_keys = [k for k in settings.API_KEYS if k != key_to_delete]
+
+ if len(updated_api_keys) < original_keys_count:
+ # 密钥已找到并从列表中移除
+ settings.API_KEYS = updated_api_keys # 首先更新内存中的 settings
+ # 使用 update_config 持久化更改,它同时处理数据库和 KeyManager
+ await ConfigService.update_config({"API_KEYS": settings.API_KEYS})
+ logger.info(f"密钥 '{key_to_delete}' 已成功删除。")
+ return {"success": True, "message": f"密钥 '{key_to_delete}' 已成功删除。"}
+ else:
+ # 未找到密钥
+ logger.warning(f"尝试删除密钥 '{key_to_delete}',但未找到该密钥。")
+ return {"success": False, "message": f"未找到密钥 '{key_to_delete}'。"}
+
+ @staticmethod
+ async def delete_selected_keys(keys_to_delete: List[str]) -> Dict[str, Any]:
+ """批量删除选定的API密钥"""
+ if not isinstance(settings.API_KEYS, list):
+ settings.API_KEYS = []
+
+ deleted_count = 0
+ not_found_keys: List[str] = []
+
+ current_api_keys = list(settings.API_KEYS) # 创建副本以进行修改
+ keys_actually_removed: List[str] = []
+
+ for key_to_del in keys_to_delete:
+ if key_to_del in current_api_keys:
+ current_api_keys.remove(key_to_del)
+ keys_actually_removed.append(key_to_del)
+ deleted_count += 1
+ else:
+ not_found_keys.append(key_to_del)
+
+ if deleted_count > 0:
+ settings.API_KEYS = current_api_keys # 更新内存中的 settings
+ await ConfigService.update_config({"API_KEYS": settings.API_KEYS})
+ logger.info(
+ f"成功删除 {deleted_count} 个密钥。密钥: {keys_actually_removed}"
+ )
+ message = f"成功删除 {deleted_count} 个密钥。"
+ if not_found_keys:
+ message += f" {len(not_found_keys)} 个密钥未找到: {not_found_keys}。"
+ return {
+ "success": True,
+ "message": message,
+ "deleted_count": deleted_count,
+ "not_found_keys": not_found_keys,
+ }
+ else:
+ message = "没有密钥被删除。"
+ if not_found_keys: # 如果提供了密钥但都未找到
+ message = f"所有 {len(not_found_keys)} 个指定的密钥均未找到: {not_found_keys}。"
+ elif not keys_to_delete: # 如果 keys_to_delete 列表为空
+ message = "未指定要删除的密钥。"
+ logger.warning(message)
+ return {
+ "success": False,
+ "message": message,
+ "deleted_count": 0,
+ "not_found_keys": not_found_keys,
+ }
+
@staticmethod
async def reset_config() -> Dict[str, Any]:
"""
diff --git a/app/service/key/key_manager.py b/app/service/key/key_manager.py
index ec87db7..5d3f2b4 100644
--- a/app/service/key/key_manager.py
+++ b/app/service/key/key_manager.py
@@ -2,7 +2,6 @@ import asyncio
from itertools import cycle
from typing import Dict
-
from app.config.config import settings
from app.log.logger import get_key_manager_logger
@@ -37,7 +36,7 @@ class KeyManager:
async with self.failure_count_lock:
for key in self.key_failure_counts:
self.key_failure_counts[key] = 0
-
+
async def reset_key_failure_count(self, key: str) -> bool:
"""重置指定key的失败计数"""
async with self.failure_count_lock:
@@ -45,7 +44,9 @@ class KeyManager:
self.key_failure_counts[key] = 0
logger.info(f"Reset failure count for key: {key}")
return True
- logger.warning(f"Attempt to reset failure count for non-existent key: {key}")
+ logger.warning(
+ f"Attempt to reset failure count for non-existent key: {key}"
+ )
return False
async def get_next_working_key(self) -> str:
@@ -62,7 +63,7 @@ class KeyManager:
# await self.reset_failure_counts() 取消重置
return current_key
- async def handle_api_failure(self, api_key: str,retries: int) -> str:
+ async def handle_api_failure(self, api_key: str, retries: int) -> str:
"""处理API调用失败"""
async with self.failure_count_lock:
self.key_failure_counts[api_key] += 1
@@ -72,7 +73,7 @@ class KeyManager:
)
if retries < settings.MAX_RETRIES:
return await self.get_next_working_key()
- else:
+ else:
return ""
def get_fail_count(self, key: str) -> int:
@@ -100,10 +101,32 @@ class KeyManager:
for key in self.key_failure_counts:
if self.key_failure_counts[key] < self.MAX_FAILURES:
return key
- return self.api_keys[0]
+ # 如果所有 key 都无效,或者列表为空,则尝试返回第一个(如果列表不为空)
+ # 或者根据具体逻辑处理,这里保持原样,可能在空列表或全无效时需要调整
+ if self.api_keys:
+ return self.api_keys[0]
+ # 如果 api_keys 为空,这里会出问题。实际应用中应有非空保证或更好处理。
+ # 为了保持接口一致性,如果列表为空,可能应该抛出异常或返回特定值。
+ # 暂且假设 api_keys 不会为空,或者调用者处理后续的空 key 问题。
+ # 根据现有代码,如果api_keys为空,self.api_keys[0]会报错。
+ # 如果没有有效key且列表不空,返回第一个。若列表为空,这里会出IndexError。
+ # 更安全的做法是:
+ if not self.api_keys:
+ logger.warning("API key list is empty, cannot get first valid key.")
+ # Depending on desired behavior, either raise error or return an indicator like "" or None
+ # For now, let's allow it to potentially fail if a key is expected by caller
+ # but it's better to be explicit. Let's return empty string for consistency with handle_api_failure
+ return ""
+ return self.api_keys[
+ 0
+ ] # Fallback to the first key if no key is "valid" but list is not empty
+
_singleton_instance = None
_singleton_lock = asyncio.Lock()
+_preserved_failure_counts: Dict[str, int] | None = None
+_preserved_old_api_keys_for_reset: list | None = None
+_preserved_next_key_in_cycle: str | None = None
async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
@@ -112,22 +135,174 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
如果尚未创建实例,将使用提供的 api_keys 初始化 KeyManager。
如果已创建实例,则忽略 api_keys 参数,返回现有单例。
+ 如果在重置后调用,会尝试恢复之前的状态(失败计数、循环位置)。
"""
- global _singleton_instance
+ global _singleton_instance, _preserved_failure_counts, _preserved_old_api_keys_for_reset, _preserved_next_key_in_cycle
async with _singleton_lock:
if _singleton_instance is None:
if api_keys is None:
- raise ValueError("API keys are required to initialize the KeyManager")
+ # This case needs careful handling. If it's the very first call, api_keys are required.
+ # If it's after a reset and no api_keys are provided, what should happen?
+ # The original ValueError was "API keys are required to initialize the KeyManager".
+ # Let's assume if api_keys is None here, it's an error unless we are restoring from non-None _preserved_old_api_keys_for_reset.
+ # However, the user's request implies new api_keys will be part of the reset flow.
+ # For now, stick to a strict requirement for api_keys if _singleton_instance is None.
+ raise ValueError(
+ "API keys are required to initialize or re-initialize the KeyManager instance."
+ )
+ if not api_keys: # Handle case where api_keys is an empty list
+ logger.warning(
+ "Initializing KeyManager with an empty list of API keys."
+ )
+ # Consider if this should be an error or allowed. Current KeyManager supports it.
+
_singleton_instance = KeyManager(api_keys)
- logger.info("KeyManager instance created.")
+ logger.info(
+ f"KeyManager instance created/re-created with {len(api_keys)} API keys."
+ )
+
+ # 1. 恢复失败计数
+ if _preserved_failure_counts:
+ # Initialize new instance's failure_counts for all new keys to 0
+ current_failure_counts = {
+ key: 0 for key in _singleton_instance.api_keys
+ }
+ # Inherit counts for keys that exist in both old and new lists
+ for key, count in _preserved_failure_counts.items():
+ if key in current_failure_counts:
+ current_failure_counts[key] = count
+ _singleton_instance.key_failure_counts = current_failure_counts
+ logger.info("Inherited failure counts for applicable keys.")
+ _preserved_failure_counts = None # Clear after use
+
+ # 2. 调整 key_cycle 的起始点
+ start_key_for_new_cycle = None
+ if (
+ _preserved_old_api_keys_for_reset
+ and _preserved_next_key_in_cycle
+ and _singleton_instance.api_keys # Ensure new api_keys list is not empty
+ ):
+ try:
+ # Find the index of the preserved next key in the *old* list
+ start_idx_in_old = _preserved_old_api_keys_for_reset.index(
+ _preserved_next_key_in_cycle
+ )
+
+ # Iterate through the old key list (circularly) starting from _preserved_next_key_in_cycle
+ # Find the first key that also exists in the new api_keys list
+ for i in range(len(_preserved_old_api_keys_for_reset)):
+ current_old_key_idx = (start_idx_in_old + i) % len(
+ _preserved_old_api_keys_for_reset
+ )
+ key_candidate = _preserved_old_api_keys_for_reset[
+ current_old_key_idx
+ ]
+ if key_candidate in _singleton_instance.api_keys:
+ start_key_for_new_cycle = key_candidate
+ break
+ except ValueError:
+ logger.warning(
+ f"Preserved next key '{_preserved_next_key_in_cycle}' not found in preserved old API keys. "
+ "New cycle will start from the beginning of the new list."
+ )
+ except Exception as e:
+ logger.error(
+ f"Error determining start key for new cycle from preserved state: {e}. "
+ "New cycle will start from the beginning."
+ )
+
+ if start_key_for_new_cycle and _singleton_instance.api_keys:
+ try:
+ # Find the index of the determined start_key in the new api_keys list
+ target_idx = _singleton_instance.api_keys.index(
+ start_key_for_new_cycle
+ )
+ # Advance the new cycle by calling next() target_idx times
+ # This positions the cycle so that the *next* call to next() will yield start_key_for_new_cycle
+ for _ in range(target_idx):
+ next(_singleton_instance.key_cycle)
+ logger.info(
+ f"Key cycle in new instance advanced. Next call to get_next_key() will yield: {start_key_for_new_cycle}"
+ )
+ except ValueError:
+ # This should not happen if start_key_for_new_cycle was correctly found in api_keys
+ logger.warning(
+ f"Determined start key '{start_key_for_new_cycle}' not found in new API keys during cycle advancement. "
+ "New cycle will start from the beginning."
+ )
+ except (
+ StopIteration
+ ): # Should not happen with cycle unless api_keys is empty, handled by _singleton_instance.api_keys check
+ logger.error(
+ "StopIteration while advancing key cycle, implies empty new API key list previously missed."
+ )
+ except Exception as e:
+ logger.error(
+ f"Error advancing new key cycle: {e}. Cycle will start from beginning."
+ )
+ else:
+ if _singleton_instance.api_keys:
+ logger.info(
+ "New key cycle will start from the beginning of the new API key list (no specific start key determined or needed)."
+ )
+ else:
+ logger.info(
+ "New key cycle not applicable as the new API key list is empty."
+ )
+
+ # 清理所有保存的状态
+ _preserved_old_api_keys_for_reset = None
+ _preserved_next_key_in_cycle = None
+ # _preserved_failure_counts already cleared
+
return _singleton_instance
-
+
async def reset_key_manager_instance():
- """重置 KeyManager 单例实例"""
- global _singleton_instance
+ """
+ 重置 KeyManager 单例实例。
+ 将保存当前实例的状态(失败计数、旧 API keys、下一个 key 提示)
+ 以供下一次 get_key_manager_instance 调用时恢复。
+ """
+ global _singleton_instance, _preserved_failure_counts, _preserved_old_api_keys_for_reset, _preserved_next_key_in_cycle
async with _singleton_lock:
if _singleton_instance:
+ # 1. 保存失败计数
+ _preserved_failure_counts = _singleton_instance.key_failure_counts.copy()
+
+ # 2. 保存旧的 API keys 列表
+ _preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
+
+ # 3. 保存 key_cycle 的下一个 key 提示
+ # This should be the key that get_next_key() would return next.
+ try:
+ if (
+ _singleton_instance.api_keys
+ ): # Only if there are keys to cycle through
+ # Calling get_next_key() consumes one key and returns it. This is the key
+ # we want the new cycle to effectively start with.
+ _preserved_next_key_in_cycle = (
+ await _singleton_instance.get_next_key()
+ )
+ else:
+ _preserved_next_key_in_cycle = None # No keys, so no next key
+ except (
+ StopIteration
+ ): # Should be caught by "if _singleton_instance.api_keys"
+ logger.warning(
+ "Could not preserve next key hint: key cycle was empty or exhausted in old instance."
+ )
+ _preserved_next_key_in_cycle = None
+ except Exception as e:
+ logger.error(f"Error preserving next key hint during reset: {e}")
+ _preserved_next_key_in_cycle = None
+
_singleton_instance = None
- logger.info("KeyManager instance reset.")
+ logger.info(
+ "KeyManager instance has been reset. State (failure counts, old keys, next key hint) preserved for next instantiation."
+ )
+ else:
+ logger.info(
+ "KeyManager instance was not set (or already reset), no reset action performed."
+ )
diff --git a/app/static/js/keys_status.js b/app/static/js/keys_status.js
index 7ead1c2..42fc54e 100644
--- a/app/static/js/keys_status.js
+++ b/app/static/js/keys_status.js
@@ -1235,6 +1235,162 @@ document.addEventListener("DOMContentLoaded", () => {
// updateBatchActions('valid');
// updateBatchActions('invalid');
});
+
+// --- 新增:删除密钥相关功能 ---
+
+// 新版:显示单个密钥删除确认模态框
+function showSingleKeyDeleteConfirmModal(key, button) {
+ const modalElement = document.getElementById("singleKeyDeleteConfirmModal");
+ const titleElement = document.getElementById(
+ "singleKeyDeleteConfirmModalTitle"
+ );
+ const messageElement = document.getElementById(
+ "singleKeyDeleteConfirmModalMessage"
+ );
+ const confirmButton = document.getElementById("confirmSingleKeyDeleteBtn");
+
+ const keyDisplay =
+ key.substring(0, 4) + "..." + key.substring(key.length - 4);
+ titleElement.textContent = "确认删除密钥";
+ messageElement.innerHTML = `确定要删除密钥 ${keyDisplay} 吗?
此操作无法撤销。`;
+
+ // 移除旧的监听器并重新附加,以确保 key 和 button 参数是最新的
+ const newConfirmButton = confirmButton.cloneNode(true);
+ confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton);
+
+ newConfirmButton.onclick = () => executeSingleKeyDelete(key, button);
+
+ modalElement.classList.remove("hidden");
+}
+
+// 新版:关闭单个密钥删除确认模态框
+function closeSingleKeyDeleteConfirmModal() {
+ document
+ .getElementById("singleKeyDeleteConfirmModal")
+ .classList.add("hidden");
+}
+
+// 新版:执行单个密钥删除
+async function executeSingleKeyDelete(key, button) {
+ closeSingleKeyDeleteConfirmModal();
+
+ button.disabled = true;
+ const originalHtml = button.innerHTML;
+ // 使用字体图标,确保一致性
+ button.innerHTML = '删除中';
+
+ try {
+ const response = await fetchAPI(`/api/config/keys/${key}`, {
+ method: "DELETE",
+ });
+
+ if (response.success) {
+ // 使用 resultModal 并确保刷新
+ showResultModal(true, response.message || "密钥删除成功", true);
+ } else {
+ // 使用 resultModal,失败时不刷新,以便用户看到错误信息
+ showResultModal(false, response.message || "密钥删除失败", false);
+ button.innerHTML = originalHtml;
+ button.disabled = false;
+ }
+ } catch (error) {
+ console.error("删除密钥 API 请求失败:", error);
+ showResultModal(false, `删除密钥请求失败: ${error.message}`, false);
+ button.innerHTML = originalHtml;
+ button.disabled = false;
+ }
+}
+
+// 显示批量删除确认模态框
+function showDeleteConfirmationModal(type, event) {
+ if (event) {
+ event.stopPropagation();
+ }
+ const modalElement = document.getElementById("deleteConfirmModal");
+ const titleElement = document.getElementById("deleteConfirmModalTitle");
+ const messageElement = document.getElementById("deleteConfirmModalMessage");
+ const confirmButton = document.getElementById("confirmDeleteBtn");
+
+ const selectedKeys = getSelectedKeys(type);
+ const count = selectedKeys.length;
+
+ titleElement.textContent = "确认批量删除";
+ if (count > 0) {
+ messageElement.textContent = `确定要批量删除选中的 ${count} 个${
+ type === "valid" ? "有效" : "无效"
+ }密钥吗?此操作无法撤销。`;
+ confirmButton.disabled = false;
+ } else {
+ // 此情况理论上不应发生,因为批量删除按钮在未选中时是禁用的
+ messageElement.textContent = `请先选择要删除的${
+ type === "valid" ? "有效" : "无效"
+ }密钥。`;
+ confirmButton.disabled = true;
+ }
+
+ confirmButton.onclick = () => executeDeleteSelectedKeys(type);
+ modalElement.classList.remove("hidden");
+}
+
+// 关闭批量删除确认模态框
+function closeDeleteConfirmationModal() {
+ document.getElementById("deleteConfirmModal").classList.add("hidden");
+}
+
+// 执行批量删除
+async function executeDeleteSelectedKeys(type) {
+ closeDeleteConfirmationModal();
+
+ const selectedKeys = getSelectedKeys(type);
+ if (selectedKeys.length === 0) {
+ showNotification("没有选中的密钥可删除", "warning");
+ return;
+ }
+
+ // 找到批量删除按钮并显示加载状态 (假设它在对应类型的 batchActions 中是最后一个按钮)
+ const batchActionsDiv = document.getElementById(`${type}BatchActions`);
+ const deleteButton = batchActionsDiv
+ ? batchActionsDiv.querySelector("button.bg-red-600")
+ : null;
+
+ let originalDeleteBtnHtml = "";
+ if (deleteButton) {
+ originalDeleteBtnHtml = deleteButton.innerHTML;
+ deleteButton.disabled = true;
+ deleteButton.innerHTML = ' 删除中';
+ }
+
+ try {
+ const response = await fetchAPI("/api/config/keys/delete-selected", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ keys: selectedKeys }),
+ });
+
+ if (response.success) {
+ // 使用 resultModal 显示更详细的结果
+ const message =
+ response.message ||
+ `成功删除 ${response.deleted_count || selectedKeys.length} 个密钥。`;
+ showResultModal(true, message, true); // true 表示成功,message,true 表示关闭后刷新
+ } else {
+ showResultModal(false, response.message || "批量删除密钥失败", true); // false 表示失败,message,true 表示关闭后刷新
+ }
+ } catch (error) {
+ console.error("批量删除 API 请求失败:", error);
+ showResultModal(false, `批量删除请求失败: ${error.message}`, true);
+ } finally {
+ // resultModal 关闭时会刷新页面,所以通常不需要在这里恢复按钮状态。
+ // 如果不刷新,则需要恢复按钮状态:
+ // if (deleteButton && (!document.getElementById("resultModal") || document.getElementById("resultModal").classList.contains("hidden") || document.getElementById("resultModalTitle").textContent.includes("失败"))) {
+ // deleteButton.innerHTML = originalDeleteBtnHtml;
+ // // 按钮的 disabled 状态会在 updateBatchActions 中处理,或者因页面刷新而重置
+ // }
+ }
+}
+
+// --- 结束:删除密钥相关功能 ---
+
function toggleKeyVisibility(button) {
const keyContainer = button.closest(".flex.items-center.gap-1");
const keyTextSpan = keyContainer.querySelector(".key-text");
diff --git a/app/templates/keys_status.html b/app/templates/keys_status.html
index ccae6fb..071cd49 100644
--- a/app/templates/keys_status.html
+++ b/app/templates/keys_status.html
@@ -802,6 +802,13 @@ endblock %} {% block head_extra_styles %}
>
批量复制
+