mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-09-06 16:16:37 +08:00
feat: 实现API密钥的单独和批量删除功能
本次更新引入了删除API密钥的功能,包括前端界面和后端逻辑。
主要变更:
- **API路由 (`app/router/config_routes.py`):**
- 添加了新的API端点 `/keys/{key_to_delete}` 用于删除单个密钥。
- 添加了新的API端点 `/keys/delete-selected` 用于批量删除选定的密钥。
- 增加了对请求体 `DeleteKeysRequest` 的Pydantic模型定义。
- 在删除操作前进行身份验证。
- **配置服务 (`app/service/config/config_service.py`):**
- 实现了 `delete_key` 方法来处理单个密钥的删除逻辑。
- 实现了 `delete_selected_keys` 方法来处理批量密钥的删除逻辑。
- 确保在删除操作后更新配置。
- **密钥管理器 (`app/service/key/key_manager.py`):**
- 更新了 `remove_key` 方法,以确保从活动密钥列表中正确移除密钥。
- 改进了 `reset_instance` 方法,在重置时保留下一个密钥提示(`_preserved_next_key_in_cycle`),以防止在配置重载后立即丢失轮换状态。
- **前端JavaScript (`app/static/js/keys_status.js`):**
- 添加了 `showSingleKeyDeleteConfirmModal` 函数,用于显示单个密钥删除的确认模态框。
- 添加了 `executeSingleKeyDelete` 函数,用于执行单个密钥的删除请求。
- 添加了 `showDeleteConfirmationModal` 函数,用于显示批量删除密钥的确认模态框。
- 添加了 `executeDeleteSelectedKeys` 函数,用于执行批量删除密钥的请求。
- 更新了UI交互,包括按钮状态(加载中、禁用)和结果通知。
- **HTML模板 (`app/templates/keys_status.html`):**
- 为有效密钥和无效密钥列表中的每个密钥添加了“删除”按钮。
- 为有效密钥和无效密钥列表添加了“批量删除”按钮。
- 添加了用于单个密钥删除和批量删除的确认模态框HTML结构。
- 调整了现有模态框的样式,以提高视觉一致性。
这些更改增强了密钥管理功能,允许用户更灵活地管理其API密钥。
This commit is contained in:
@@ -2,10 +2,11 @@
|
|||||||
配置路由模块
|
配置路由模块
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.core.security import verify_auth_token
|
from app.core.security import verify_auth_token
|
||||||
from app.log.logger import Logger, get_config_routes_logger
|
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))
|
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")
|
@router.get("/ui/models")
|
||||||
async def get_ui_models(request: Request):
|
async def get_ui_models(request: Request):
|
||||||
auth_token_cookie = request.cookies.get("auth_token")
|
auth_token_cookie = request.cookies.get("auth_token")
|
||||||
|
|||||||
@@ -125,6 +125,78 @@ class ConfigService:
|
|||||||
|
|
||||||
return await ConfigService.get_config()
|
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
|
@staticmethod
|
||||||
async def reset_config() -> Dict[str, Any]:
|
async def reset_config() -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+188
-13
@@ -2,7 +2,6 @@ import asyncio
|
|||||||
from itertools import cycle
|
from itertools import cycle
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
from app.log.logger import get_key_manager_logger
|
from app.log.logger import get_key_manager_logger
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ class KeyManager:
|
|||||||
async with self.failure_count_lock:
|
async with self.failure_count_lock:
|
||||||
for key in self.key_failure_counts:
|
for key in self.key_failure_counts:
|
||||||
self.key_failure_counts[key] = 0
|
self.key_failure_counts[key] = 0
|
||||||
|
|
||||||
async def reset_key_failure_count(self, key: str) -> bool:
|
async def reset_key_failure_count(self, key: str) -> bool:
|
||||||
"""重置指定key的失败计数"""
|
"""重置指定key的失败计数"""
|
||||||
async with self.failure_count_lock:
|
async with self.failure_count_lock:
|
||||||
@@ -45,7 +44,9 @@ class KeyManager:
|
|||||||
self.key_failure_counts[key] = 0
|
self.key_failure_counts[key] = 0
|
||||||
logger.info(f"Reset failure count for key: {key}")
|
logger.info(f"Reset failure count for key: {key}")
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
async def get_next_working_key(self) -> str:
|
async def get_next_working_key(self) -> str:
|
||||||
@@ -62,7 +63,7 @@ class KeyManager:
|
|||||||
# await self.reset_failure_counts() 取消重置
|
# await self.reset_failure_counts() 取消重置
|
||||||
return current_key
|
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调用失败"""
|
"""处理API调用失败"""
|
||||||
async with self.failure_count_lock:
|
async with self.failure_count_lock:
|
||||||
self.key_failure_counts[api_key] += 1
|
self.key_failure_counts[api_key] += 1
|
||||||
@@ -72,7 +73,7 @@ class KeyManager:
|
|||||||
)
|
)
|
||||||
if retries < settings.MAX_RETRIES:
|
if retries < settings.MAX_RETRIES:
|
||||||
return await self.get_next_working_key()
|
return await self.get_next_working_key()
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def get_fail_count(self, key: str) -> int:
|
def get_fail_count(self, key: str) -> int:
|
||||||
@@ -100,10 +101,32 @@ class KeyManager:
|
|||||||
for key in self.key_failure_counts:
|
for key in self.key_failure_counts:
|
||||||
if self.key_failure_counts[key] < self.MAX_FAILURES:
|
if self.key_failure_counts[key] < self.MAX_FAILURES:
|
||||||
return key
|
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_instance = None
|
||||||
_singleton_lock = asyncio.Lock()
|
_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:
|
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 初始化 KeyManager。
|
||||||
如果已创建实例,则忽略 api_keys 参数,返回现有单例。
|
如果已创建实例,则忽略 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:
|
async with _singleton_lock:
|
||||||
if _singleton_instance is None:
|
if _singleton_instance is None:
|
||||||
if api_keys 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)
|
_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
|
return _singleton_instance
|
||||||
|
|
||||||
|
|
||||||
async def reset_key_manager_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:
|
async with _singleton_lock:
|
||||||
if _singleton_instance:
|
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
|
_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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -1235,6 +1235,162 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// updateBatchActions('valid');
|
// updateBatchActions('valid');
|
||||||
// updateBatchActions('invalid');
|
// 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 = `确定要删除密钥 <span class="font-mono text-amber-300 font-semibold">${keyDisplay}</span> 吗?<br>此操作无法撤销。`;
|
||||||
|
|
||||||
|
// 移除旧的监听器并重新附加,以确保 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 = '<i class="fas fa-spinner fa-spin mr-1"></i>删除中';
|
||||||
|
|
||||||
|
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 = '<i class="fas fa-spinner fa-spin"></i> 删除中';
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function toggleKeyVisibility(button) {
|
||||||
const keyContainer = button.closest(".flex.items-center.gap-1");
|
const keyContainer = button.closest(".flex.items-center.gap-1");
|
||||||
const keyTextSpan = keyContainer.querySelector(".key-text");
|
const keyTextSpan = keyContainer.querySelector(".key-text");
|
||||||
|
|||||||
@@ -802,6 +802,13 @@ endblock %} {% block head_extra_styles %}
|
|||||||
>
|
>
|
||||||
<i class="fas fa-copy"></i> 批量复制
|
<i class="fas fa-copy"></i> 批量复制
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onclick="event.stopPropagation(); showDeleteConfirmationModal('valid', event)"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash-alt"></i> 批量删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||||
@@ -882,6 +889,13 @@ endblock %} {% block head_extra_styles %}
|
|||||||
<i class="fas fa-chart-pie"></i>
|
<i class="fas fa-chart-pie"></i>
|
||||||
详情
|
详情
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||||
|
onclick="showSingleKeyDeleteConfirmModal('{{ key }}', this)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -972,6 +986,13 @@ endblock %} {% block head_extra_styles %}
|
|||||||
>
|
>
|
||||||
<i class="fas fa-copy"></i> 批量复制
|
<i class="fas fa-copy"></i> 批量复制
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onclick="event.stopPropagation(); showDeleteConfirmationModal('invalid', event)"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash-alt"></i> 批量删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||||
@@ -1050,6 +1071,13 @@ endblock %} {% block head_extra_styles %}
|
|||||||
<i class="fas fa-chart-pie"></i>
|
<i class="fas fa-chart-pie"></i>
|
||||||
详情
|
详情
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||||
|
onclick="showSingleKeyDeleteConfirmModal('{{ key }}', this)"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1131,31 +1159,44 @@ endblock %} {% block head_extra_styles %}
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in"
|
class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in"
|
||||||
|
style="
|
||||||
|
background-color: rgba(70, 50, 150, 0.95);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(120, 100, 200, 0.4);
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-gray-800" id="verifyModalTitle">
|
<h3
|
||||||
|
class="text-lg font-semibold"
|
||||||
|
id="verifyModalTitle"
|
||||||
|
style="
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
font-weight: 600;
|
||||||
|
"
|
||||||
|
>
|
||||||
批量验证密钥
|
批量验证密钥
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onclick="closeVerifyModal()"
|
onclick="closeVerifyModal()"
|
||||||
class="text-gray-500 hover:text-gray-700 focus:outline-none"
|
class="text-gray-300 hover:text-white focus:outline-none"
|
||||||
>
|
>
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<p class="text-gray-600" id="verifyModalMessage"></p>
|
<p style="color: #f8fafc" id="verifyModalMessage"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onclick="closeVerifyModal()"
|
onclick="closeVerifyModal()"
|
||||||
class="px-3 py-1.5 text-xs font-medium bg-slate-500 hover:bg-slate-600 text-white rounded-lg transition-colors"
|
class="px-3 py-1.5 text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="confirmVerifyBtn"
|
id="confirmVerifyBtn"
|
||||||
class="px-3 py-1.5 text-xs font-medium bg-teal-600 hover:bg-teal-700 text-white rounded-lg transition-colors"
|
class="px-3 py-1.5 text-xs font-medium bg-teal-700 hover:bg-teal-800 text-white rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
确认验证
|
确认验证
|
||||||
</button>
|
</button>
|
||||||
@@ -1163,6 +1204,110 @@ endblock %} {% block head_extra_styles %}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 删除确认模态框 -->
|
||||||
|
<div
|
||||||
|
id="deleteConfirmModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-white rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in"
|
||||||
|
style="
|
||||||
|
background-color: rgba(70, 50, 150, 0.95);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(120, 100, 200, 0.4);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3
|
||||||
|
class="text-lg font-semibold"
|
||||||
|
id="deleteConfirmModalTitle"
|
||||||
|
style="
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
font-weight: 600;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onclick="closeDeleteConfirmationModal()"
|
||||||
|
class="text-gray-300 hover:text-white focus:outline-none"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<p style="color: #f8fafc" id="deleteConfirmModalMessage"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onclick="closeDeleteConfirmationModal()"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="confirmDeleteBtn"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-red-700 hover:bg-red-800 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增:单个密钥删除确认模态框 -->
|
||||||
|
<div
|
||||||
|
id="singleKeyDeleteConfirmModal"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="rounded-lg p-6 shadow-xl max-w-md w-full animate-fade-in"
|
||||||
|
style="
|
||||||
|
background-color: rgba(70, 50, 150, 0.95);
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: rgba(120, 100, 200, 0.4);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3
|
||||||
|
class="text-lg font-semibold"
|
||||||
|
id="singleKeyDeleteConfirmModalTitle"
|
||||||
|
style="
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
font-weight: 600;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
确认删除密钥
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onclick="closeSingleKeyDeleteConfirmModal()"
|
||||||
|
class="text-gray-300 hover:text-white focus:outline-none"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<p style="color: #f8fafc" id="singleKeyDeleteConfirmModalMessage"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onclick="closeSingleKeyDeleteConfirmModal()"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-slate-600 hover:bg-slate-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="confirmSingleKeyDeleteBtn"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-red-700 hover:bg-red-800 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 操作结果模态框 -->
|
<!-- 操作结果模态框 -->
|
||||||
<div
|
<div
|
||||||
id="resultModal"
|
id="resultModal"
|
||||||
|
|||||||
Reference in New Issue
Block a user