diff --git a/app/database/connection.py b/app/database/connection.py index dca6d27..25c4b74 100644 --- a/app/database/connection.py +++ b/app/database/connection.py @@ -14,7 +14,8 @@ logger = get_database_logger() DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}" # 创建数据库引擎 -engine = create_engine(DATABASE_URL) +# pool_pre_ping=True: 在从连接池获取连接前执行简单的 "ping" 测试,确保连接有效 +engine = create_engine(DATABASE_URL, pool_pre_ping=True) # 创建元数据对象 metadata = MetaData() @@ -23,7 +24,11 @@ metadata = MetaData() Base = declarative_base(metadata=metadata) # 创建数据库连接池,并配置连接池参数 -# pool_recycle=3600: 回收空闲超过1小时的连接,防止MySQL服务器超时断开 +# min_size/max_size: 连接池的最小/最大连接数 +# pool_recycle=3600: 连接在池中允许存在的最大秒数(生命周期)。 +# 设置为 3600 秒(1小时),确保在 MySQL 默认的 wait_timeout (通常8小时) 或其他网络超时之前回收连接。 +# 如果遇到连接失效问题,可以尝试调低此值,使其小于实际的 wait_timeout 或网络超时时间。 +# databases 库会自动处理连接失效后的重连尝试。 database = Database(DATABASE_URL, min_size=5, max_size=20, pool_recycle=3600) diff --git a/app/router/routes.py b/app/router/routes.py index 8051094..8c90da7 100644 --- a/app/router/routes.py +++ b/app/router/routes.py @@ -10,7 +10,7 @@ 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.service.key.key_manager import get_key_manager_instance -from app.service.stats_service import get_api_usage_stats # <-- Import stats service +from app.service.stats_service import get_api_usage_stats, get_api_call_details # <-- Import stats service and details function logger = get_routes_logger() @@ -38,6 +38,7 @@ def setup_routers(app: FastAPI) -> None: # 添加健康检查路由 setup_health_routes(app) + setup_api_stats_routes(app) # Add API stats routes def setup_page_routes(app: FastAPI) -> None: @@ -158,3 +159,32 @@ def setup_health_routes(app: FastAPI) -> None: """健康检查端点""" logger.info("Health check endpoint called") return {"status": "healthy"} + + +def setup_api_stats_routes(app: FastAPI) -> None: + """ + 设置 API 统计相关的路由 + + Args: + app: FastAPI应用程序实例 + """ + @app.get("/api/stats/details") + async def api_stats_details(request: Request, period: str): + """获取指定时间段内的 API 调用详情""" + try: + # 验证认证 + auth_token = request.cookies.get("auth_token") + if not auth_token or not verify_auth_token(auth_token): + logger.warning("Unauthorized access attempt to API stats details") + # Returning JSON error instead of redirect for API endpoint + return {"error": "Unauthorized"}, 401 + + logger.info(f"Fetching API call details for period: {period}") + details = await get_api_call_details(period) + return details + except ValueError as e: + logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}") + return {"error": str(e)}, 400 + except Exception as e: + logger.error(f"Error fetching API stats details for period {period}: {str(e)}") + return {"error": "Internal server error"}, 500 diff --git a/app/service/stats_service.py b/app/service/stats_service.py index 1e67ffb..faf30f9 100644 --- a/app/service/stats_service.py +++ b/app/service/stats_service.py @@ -66,4 +66,58 @@ async def get_api_usage_stats() -> dict: "calls_1h": 0, "calls_24h": 0, "calls_month": 0, - } \ No newline at end of file + } + + +async def get_api_call_details(period: str) -> list[dict]: + """ + 获取指定时间段内的 API 调用详情 + + Args: + period: 时间段标识 ('1m', '1h', '24h') + + Returns: + 包含调用详情的字典列表,每个字典包含 timestamp, key, model, status + + 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}") + + 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 + + results = await database.fetch_all(query) + + # Convert results to list of dicts and map status_code + details = [] + for row in results: + 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 + + 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 diff --git a/app/static/js/keys_status.js b/app/static/js/keys_status.js index b00e601..b931253 100644 --- a/app/static/js/keys_status.js +++ b/app/static/js/keys_status.js @@ -460,6 +460,13 @@ document.addEventListener('DOMContentLoaded', () => { // 初始加载时应用一次筛选 filterValidKeys(); } + + // 添加自动刷新功能,每60秒刷新一次 + const autoRefreshInterval = 60000; // 60秒 + setInterval(() => { + console.log('自动刷新 keys_status 页面...'); + location.reload(); + }, autoRefreshInterval); }); // Service Worker registration @@ -493,3 +500,117 @@ function toggleKeyVisibility(button) { button.title = '显示密钥'; } } + +// --- API 调用详情模态框逻辑 --- + +// 显示 API 调用详情模态框 +async function showApiCallDetails(period) { + const modal = document.getElementById('apiCallDetailsModal'); + const contentArea = document.getElementById('apiCallDetailsContent'); + const titleElement = document.getElementById('apiCallDetailsModalTitle'); + + if (!modal || !contentArea || !titleElement) { + console.error('无法找到 API 调用详情模态框元素'); + showNotification('无法显示详情,页面元素缺失', 'error'); + return; + } + + // 设置标题 + let periodText = ''; + switch (period) { + case '1m': periodText = '最近 1 分钟'; break; + case '1h': periodText = '最近 1 小时'; break; + case '24h': periodText = '最近 24 小时'; break; + default: periodText = '指定时间段'; + } + titleElement.textContent = `${periodText} API 调用详情`; + + // 显示模态框并设置加载状态 + modal.classList.remove('hidden'); + contentArea.innerHTML = ` +
加载中...
+加载失败: ${error.message}
+该时间段内没有 API 调用记录。
+| 时间 | +密钥 (部分) | +模型 | +状态 | +
|---|---|---|---|
| ${timestamp} | +${keyDisplay} | +${call.model || 'N/A'} | ++ + ${call.status} + | +