From c99e090ea9340d6e54481b14fd0833afe4696858 Mon Sep 17 00:00:00 2001 From: snaily Date: Sun, 20 Apr 2025 01:41:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(stats):=20=E6=B7=BB=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E4=BD=BF=E7=94=A8=E8=AF=A6=E6=83=85=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能允许用户在 Keys 状态页面点击“详情”按钮,查看指定 API 密钥在过去 24 小时内按模型分类的请求次数统计。 主要变更包括: 后端: - 新增 `app/router/stats_routes.py`,包含 `/api/key-usage-details/{key}` API 端点用于获取密钥使用详情。 - 重构 `app/service/stats_service.py`,将统计相关函数封装到 `StatsService` 类中,并添加 `get_key_usage_details_last_24h` 方法。 - 在 `app/router/routes.py` 中注册新的 `stats_routes`,并更新对 `stats_service` 的调用方式以使用类实例。 - 更新 `app/log/logger.py` 添加 `get_scheduler_routes` 日志记录器,并在 `app/router/scheduler_routes.py` 中使用它。 前端: - 在 `app/templates/keys_status.html` 中为每个有效和无效密钥列表项添加“详情”按钮。 - 在 `app/templates/keys_status.html` 中添加用于显示密钥使用详情的模态框 HTML 结构。 - 在 `app/static/js/keys_status.js` 中添加 JavaScript 函数 (`showKeyUsageDetails`, `closeKeyUsageDetailsModal`, `renderKeyUsageDetails`) 来处理按钮点击事件、调用后端 API、控制模态框显示/隐藏以及渲染获取到的统计数据。 --- app/log/logger.py | 6 +- app/router/routes.py | 13 +- app/router/scheduler_routes.py | 4 +- app/router/stats_routes.py | 60 ++++++++ app/service/stats_service.py | 247 ++++++++++++++++++++------------- app/static/js/keys_status.js | 106 ++++++++++++++ app/templates/keys_status.html | 32 +++++ 7 files changed, 361 insertions(+), 107 deletions(-) create mode 100644 app/router/stats_routes.py diff --git a/app/log/logger.py b/app/log/logger.py index 13fa35c..84a6225 100644 --- a/app/log/logger.py +++ b/app/log/logger.py @@ -203,4 +203,8 @@ def get_stats_logger(): def get_update_logger(): - return Logger.setup_logger("update_service") \ No newline at end of file + return Logger.setup_logger("update_service") + + +def get_scheduler_routes(): + return Logger.setup_logger("scheduler_routes") \ No newline at end of file diff --git a/app/router/routes.py b/app/router/routes.py index 4fdc52b..21d755f 100644 --- a/app/router/routes.py +++ b/app/router/routes.py @@ -8,9 +8,9 @@ from fastapi.templating import Jinja2Templates from app.core.security import verify_auth_token from app.log.logger import get_routes_logger -from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes # 新增导入 +from app.router import gemini_routes, openai_routes, config_routes, log_routes, scheduler_routes, stats_routes # 新增导入 stats_routes from app.service.key.key_manager import get_key_manager_instance -from app.service.stats_service import get_api_usage_stats, get_api_call_details # <-- Import stats service and details function +from app.service.stats_service import StatsService logger = get_routes_logger() @@ -32,6 +32,7 @@ def setup_routers(app: FastAPI) -> None: app.include_router(config_routes.router) app.include_router(log_routes.router) app.include_router(scheduler_routes.router) # 新增包含 scheduler 路由 + app.include_router(stats_routes.router) # 包含 stats API 路由 # 添加页面路由 setup_page_routes(app) @@ -92,8 +93,8 @@ def setup_page_routes(app: FastAPI) -> None: valid_key_count = len(keys_status["valid_keys"]) invalid_key_count = len(keys_status["invalid_keys"]) - # Get API usage stats - api_stats = await get_api_usage_stats() + stats_service = StatsService() + api_stats = await stats_service.get_api_usage_stats() logger.info(f"API stats retrieved: {api_stats}") logger.info(f"Keys status retrieved successfully. Total keys: {total_keys}") @@ -180,7 +181,9 @@ def setup_api_stats_routes(app: FastAPI) -> None: return {"error": "Unauthorized"}, 401 logger.info(f"Fetching API call details for period: {period}") - details = await get_api_call_details(period) + # Use the service instance here as well + stats_service = StatsService() # Create an instance + details = await stats_service.get_api_call_details(period) return details except ValueError as e: logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}") diff --git a/app/router/scheduler_routes.py b/app/router/scheduler_routes.py index 91189d5..d0bd8ff 100644 --- a/app/router/scheduler_routes.py +++ b/app/router/scheduler_routes.py @@ -7,9 +7,9 @@ from fastapi.responses import JSONResponse from app.core.security import verify_auth_token # 导入 verify_auth_token from app.scheduler.key_checker import start_scheduler, stop_scheduler -from app.log.logger import get_routes_logger # 使用路由日志记录器 +from app.log.logger import get_scheduler_routes # 使用路由日志记录器 -logger = get_routes_logger() +logger = get_scheduler_routes() router = APIRouter( prefix="/api/scheduler", diff --git a/app/router/stats_routes.py b/app/router/stats_routes.py new file mode 100644 index 0000000..ce83141 --- /dev/null +++ b/app/router/stats_routes.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from starlette import status +from app.core.security import verify_auth_token +from app.service.stats_service import StatsService +from app.log.logger import get_stats_logger # 使用路由日志记录器 + +logger = get_stats_logger() + + +# 认证检查的辅助函数 +async def verify_token(request: Request): + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + logger.warning("Unauthorized access attempt to scheduler API") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + +router = APIRouter( + prefix="/api", + tags=["stats"], + dependencies=[Depends(verify_token)] # Assuming API routes need authentication +) + +stats_service = StatsService() + +@router.get("/key-usage-details/{key}", + summary="获取指定密钥最近24小时的模型调用次数", + description="根据提供的 API 密钥,返回过去24小时内每个模型被调用的次数统计。") +async def get_key_usage_details(key: str): + """ + Retrieves the model usage count for a specific API key within the last 24 hours. + + Args: + key: The API key to get usage details for. + + Returns: + A dictionary with model names as keys and their call counts as values. + Example: {"gemini-pro": 10, "gemini-1.5-pro-latest": 5} + + Raises: + HTTPException: If an error occurs during data retrieval. + """ + try: + usage_details = await stats_service.get_key_usage_details_last_24h(key) + if usage_details is None: + # Handle case where key might be valid but has no recent usage, + # or if the service layer explicitly returns None for other reasons. + # Returning an empty dict is usually fine for the frontend. + return {} + return usage_details + except Exception as e: + # Log the exception details here if needed + print(f"Error fetching key usage details for key {key[:4]}...: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取密钥使用详情时出错: {e}" + ) \ No newline at end of file diff --git a/app/service/stats_service.py b/app/service/stats_service.py index 45caa17..998e43c 100644 --- a/app/service/stats_service.py +++ b/app/service/stats_service.py @@ -9,117 +9,166 @@ from app.log.logger import get_stats_logger logger = get_stats_logger() -async def get_calls_in_last_seconds(seconds: int) -> int: - """获取过去 N 秒内的调用次数 (包括成功和失败)""" - try: - cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds) - query = select(func.count(RequestLog.id)).where( - RequestLog.request_time >= cutoff_time - ) - count_result = await database.fetch_one(query) - return count_result[0] if count_result else 0 - except Exception as e: - logger.error(f"Failed to get calls in last {seconds} seconds: {e}") - return 0 # Return 0 on error -async def get_calls_in_last_minutes(minutes: int) -> int: - """获取过去 N 分钟内的调用次数 (包括成功和失败)""" - return await get_calls_in_last_seconds(minutes * 60) +class StatsService: + """Service class for handling statistics related operations.""" -async def get_calls_in_last_hours(hours: int) -> int: - """获取过去 N 小时内的调用次数 (包括成功和失败)""" - return await get_calls_in_last_seconds(hours * 3600) + async def get_calls_in_last_seconds(self, seconds: int) -> int: + """获取过去 N 秒内的调用次数 (包括成功和失败)""" + try: + cutoff_time = datetime.datetime.now() - datetime.timedelta(seconds=seconds) + query = select(func.count(RequestLog.id)).where( + RequestLog.request_time >= cutoff_time + ) + count_result = await database.fetch_one(query) + return count_result[0] if count_result else 0 + except Exception as e: + logger.error(f"Failed to get calls in last {seconds} seconds: {e}") + return 0 # Return 0 on error -async def get_calls_in_current_month() -> int: - """获取当前自然月内的调用次数 (包括成功和失败)""" - try: + async def get_calls_in_last_minutes(self, minutes: int) -> int: + """获取过去 N 分钟内的调用次数 (包括成功和失败)""" + return await self.get_calls_in_last_seconds(minutes * 60) + + async def get_calls_in_last_hours(self, hours: int) -> int: + """获取过去 N 小时内的调用次数 (包括成功和失败)""" + return await self.get_calls_in_last_seconds(hours * 3600) + + async def get_calls_in_current_month(self) -> int: + """获取当前自然月内的调用次数 (包括成功和失败)""" + try: + now = datetime.datetime.now() + start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + query = select(func.count(RequestLog.id)).where( + RequestLog.request_time >= start_of_month + ) + count_result = await database.fetch_one(query) + return count_result[0] if count_result else 0 + except Exception as e: + logger.error(f"Failed to get calls in current month: {e}") + return 0 # Return 0 on error + + async def get_api_usage_stats(self) -> dict: + """获取所有需要的 API 使用统计数据""" + try: + calls_1m = await self.get_calls_in_last_minutes(1) + calls_1h = await self.get_calls_in_last_hours(1) + calls_24h = await self.get_calls_in_last_hours(24) + calls_month = await self.get_calls_in_current_month() + + return { + "calls_1m": calls_1m, + "calls_1h": calls_1h, + "calls_24h": calls_24h, + "calls_month": calls_month, + } + except Exception as e: + logger.error(f"Failed to get API usage stats: {e}") + # Return default values on error + return { + "calls_1m": 0, + "calls_1h": 0, + "calls_24h": 0, + "calls_month": 0, + } + + + async def get_api_call_details(self, period: str) -> list[dict]: + """ + 获取指定时间段内的 API 调用详情 + + Args: + period: 时间段标识 ('1m', '1h', '24h') + + Returns: + 包含调用详情的字典列表,每个字典包含 timestamp, key, model, status + + Raises: + ValueError: 如果 period 无效 + """ now = datetime.datetime.now() - start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - query = select(func.count(RequestLog.id)).where( - RequestLog.request_time >= start_of_month - ) - count_result = await database.fetch_one(query) - return count_result[0] if count_result else 0 - except Exception as e: - logger.error(f"Failed to get calls in current month: {e}") - return 0 # Return 0 on error + if period == '1m': + start_time = now - datetime.timedelta(minutes=1) + elif period == '1h': + start_time = now - datetime.timedelta(hours=1) + elif period == '24h': + start_time = now - datetime.timedelta(hours=24) + else: + raise ValueError(f"无效的时间段标识: {period}") -async def get_api_usage_stats() -> dict: - """获取所有需要的 API 使用统计数据""" - try: - calls_1m = await get_calls_in_last_minutes(1) - calls_1h = await get_calls_in_last_hours(1) - calls_24h = await get_calls_in_last_hours(24) - calls_month = await get_calls_in_current_month() + try: + query = select( + RequestLog.request_time.label("timestamp"), + RequestLog.api_key.label("key"), + RequestLog.model_name.label("model"), + RequestLog.status_code # We might need to map this to 'success'/'failure' later + ).where( + RequestLog.request_time >= start_time + ).order_by(RequestLog.request_time.desc()) # Order by most recent first - return { - "calls_1m": calls_1m, - "calls_1h": calls_1h, - "calls_24h": calls_24h, - "calls_month": calls_month, - } - except Exception as e: - logger.error(f"Failed to get API usage stats: {e}") - # Return default values on error - return { - "calls_1m": 0, - "calls_1h": 0, - "calls_24h": 0, - "calls_month": 0, - } + results = await database.fetch_all(query) + # Convert results to list of dicts and map status_code + details = [] + for row in results: + status = 'failure' # 默认状态为 failure,如果 status_code 有效且在 200-299 范围内则更新为 success + if row['status_code'] is not None: # 检查 status_code 是否为空 + status = 'success' if 200 <= row['status_code'] < 300 else 'failure' + details.append({ + "timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility + "key": row['key'], + "model": row['model'], + "status": status + }) + logger.info(f"Retrieved {len(details)} API call details for period '{period}'") + return details -async def get_api_call_details(period: str) -> list[dict]: - """ - 获取指定时间段内的 API 调用详情 + except Exception as e: + logger.error(f"Failed to get API call details for period '{period}': {e}") + # Re-raise the exception to be handled by the route + raise - Args: - period: 时间段标识 ('1m', '1h', '24h') + async def get_key_usage_details_last_24h(self, key: str) -> dict | None: + """ + 获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。 - Returns: - 包含调用详情的字典列表,每个字典包含 timestamp, key, model, status + Args: + key: 要查询的 API 密钥。 - Raises: - ValueError: 如果 period 无效 - """ - now = datetime.datetime.now() - if period == '1m': - start_time = now - datetime.timedelta(minutes=1) - elif period == '1h': - start_time = now - datetime.timedelta(hours=1) - elif period == '24h': - start_time = now - datetime.timedelta(hours=24) - else: - raise ValueError(f"无效的时间段标识: {period}") + Returns: + 一个字典,其中键是模型名称,值是调用次数。 + 如果查询出错或没有找到记录,可能返回 None 或空字典。 + Example: {"gemini-pro": 10, "gemini-1.5-pro-latest": 5} + """ + logger.info(f"Fetching usage details for key ending in ...{key[-4:]} for the last 24h.") + cutoff_time = datetime.datetime.now() - datetime.timedelta(hours=24) - try: - query = select( - RequestLog.request_time.label("timestamp"), - RequestLog.api_key.label("key"), - RequestLog.model_name.label("model"), - RequestLog.status_code # We might need to map this to 'success'/'failure' later - ).where( - RequestLog.request_time >= start_time - ).order_by(RequestLog.request_time.desc()) # Order by most recent first + try: + query = select( + RequestLog.model_name, + func.count(RequestLog.id).label("call_count") + ).where( + RequestLog.api_key == key, + RequestLog.request_time >= cutoff_time, + RequestLog.model_name.isnot(None) # Ensure model_name is not null + ).group_by( + RequestLog.model_name + ).order_by( + func.count(RequestLog.id).desc() # Order by count descending + ) - results = await database.fetch_all(query) + results = await database.fetch_all(query) - # Convert results to list of dicts and map status_code - details = [] - for row in results: - status = 'failure' # 默认状态为 failure,如果 status_code 有效且在 200-299 范围内则更新为 success - if row['status_code'] is not None: # 检查 status_code 是否为空 - status = 'success' if 200 <= row['status_code'] < 300 else 'failure' - details.append({ - "timestamp": row['timestamp'].isoformat(), # Use ISO format for JS compatibility - "key": row['key'], - "model": row['model'], - "status": status - }) - logger.info(f"Retrieved {len(details)} API call details for period '{period}'") - return details + if not results: + logger.info(f"No usage details found for key ending in ...{key[-4:]} in the last 24h.") + return {} # Return empty dict if no records found - except Exception as e: - logger.error(f"Failed to get API call details for period '{period}': {e}") - # Re-raise the exception to be handled by the route - raise \ No newline at end of file + usage_details = {row['model_name']: row['call_count'] for row in results} + logger.info(f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}") + return usage_details + + except Exception as e: + logger.error(f"Failed to get key usage details for key ending in ...{key[-4:]}: {e}", exc_info=True) + # Depending on requirements, you might return None or raise the exception + # Raising allows the route handler to return a 500 error. + raise # Re-raise the exception \ No newline at end of file diff --git a/app/static/js/keys_status.js b/app/static/js/keys_status.js index c860dd4..6a747b5 100644 --- a/app/static/js/keys_status.js +++ b/app/static/js/keys_status.js @@ -914,3 +914,109 @@ function renderApiCallDetails(data, container) { container.innerHTML = tableHtml; } + +// --- 密钥使用详情模态框逻辑 --- + +// 显示密钥使用详情模态框 +window.showKeyUsageDetails = async function(key) { + const modal = document.getElementById('keyUsageDetailsModal'); + const contentArea = document.getElementById('keyUsageDetailsContent'); + const titleElement = document.getElementById('keyUsageDetailsModalTitle'); + const keyDisplay = key.substring(0, 4) + '...' + key.substring(key.length - 4); + + if (!modal || !contentArea || !titleElement) { + console.error('无法找到密钥使用详情模态框元素'); + showNotification('无法显示详情,页面元素缺失', 'error'); + return; + } + + // 设置标题 + titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`; + + // 显示模态框并设置加载状态 + modal.classList.remove('hidden'); + contentArea.innerHTML = ` +
+ +

加载中...

+
`; + + try { + // 调用新的后端 API 获取数据 + // 注意:后端需要实现 /api/key-usage-details/{key} 端点 + const response = await fetch(`/api/key-usage-details/${key}`); + if (!response.ok) { + let errorMsg = `服务器错误: ${response.status}`; + try { + const errorData = await response.json(); + errorMsg = errorData.detail || errorMsg; // 假设后端错误信息在 detail 字段 + } catch (e) { /* 忽略解析错误 */ } + throw new Error(errorMsg); + } + const data = await response.json(); + + // 渲染数据 + renderKeyUsageDetails(data, contentArea); + + } catch (error) { + console.error('获取密钥使用详情失败:', error); + contentArea.innerHTML = ` +
+ +

加载失败: ${error.message}

+
`; + } +} + +// 关闭密钥使用详情模态框 +window.closeKeyUsageDetailsModal = function() { + const modal = document.getElementById('keyUsageDetailsModal'); + if (modal) { + modal.classList.add('hidden'); + } +} + +// 渲染密钥使用详情到模态框 (这个函数主要由 showKeyUsageDetails 调用,不一定需要全局,但保持一致性) +window.renderKeyUsageDetails = function(data, container) { + // data 预期格式: { "model_name1": count1, "model_name2": count2, ... } + if (!data || Object.keys(data).length === 0) { + container.innerHTML = ` +
+ +

该密钥在最近24小时内没有调用记录。

+
`; + return; + } + + // 创建表格 + let tableHtml = ` + + + + + + + + + `; + + // 排序模型(可选,按调用次数降序) + const sortedModels = Object.entries(data).sort(([, countA], [, countB]) => countB - countA); + + // 填充表格行 + sortedModels.forEach(([model, count]) => { + tableHtml += ` + + + + + `; + }); + + tableHtml += ` + +
模型名称调用次数 (24h)
${model}${count}
+ `; + + container.innerHTML = tableHtml; +} diff --git a/app/templates/keys_status.html b/app/templates/keys_status.html index 4c27ef5..1fa25a1 100644 --- a/app/templates/keys_status.html +++ b/app/templates/keys_status.html @@ -374,6 +374,10 @@ 复制 + @@ -443,6 +447,10 @@ 复制 + @@ -564,6 +572,30 @@ + + +