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 %} > 批量复制 +
@@ -882,6 +889,13 @@ endblock %} {% block head_extra_styles %} 详情 +
@@ -972,6 +986,13 @@ endblock %} {% block head_extra_styles %} > 批量复制 +
@@ -1050,6 +1071,13 @@ endblock %} {% block head_extra_styles %} 详情 +
@@ -1131,31 +1159,44 @@ endblock %} {% block head_extra_styles %} >
-

+

批量验证密钥

-

+

@@ -1163,6 +1204,110 @@ endblock %} {% block head_extra_styles %}
+ + + + + +