feat: 集成数据库配置管理并添加错误日志查看器

主要变更:

1.  **数据库集成**:
    *   引入 MySQL 数据库支持,使用 SQLAlchemy 和 `databases` 库持久化存储应用程序设置。
    *   添加了 `app/database` 目录,包含数据库连接、模型和初始化逻辑。
    *   更新 `requirements.txt` 添加数据库相关依赖 (`pymysql`, `sqlalchemy`, `aiomysql`, `databases`, `python-dotenv`)。

2.  **配置管理重构**:
    *   重构 `ConfigService` (`app/service/config/config_service.py`),使其从数据库加载和保存设置,并支持从 `.env` 文件同步初始配置到数据库。
    *   修改 `Settings` 模型 (`app/config/config.py`) 以包含数据库连接信息,并添加了从数据库加载/同步配置的逻辑。
    *   配置相关的路由 (`app/router/config_routes.py`) 更新为异步,并调用新的 `ConfigService` 方法。
    *   `KeyManager` (`app/service/key/key_manager.py`) 现在可以在配置更新后重置和重新初始化。

3.  **错误日志查看器**:
    *   新增 `/logs` 页面 (`app/templates/error_logs.html`) 用于展示应用程序错误日志。
    *   添加了相应的路由 (`app/router/log_routes.py`)、静态资源 (`app/static/css/error_logs.css`, `app/static/js/error_logs.js`) 和日志记录器 (`app/log/logger.py`)。
    *   在配置页面和密钥管理页面的导航栏中添加了指向日志页面的链接。

4.  **异步操作**:
    *   将配置服务和相关路由转换为异步 (`async def`) 以支持异步数据库操作。

5.  **其他**:
    *   更新了应用程序初始化逻辑 (`app/core/application.py`, `app/core/initialization.py`) 以包含数据库连接的建立和关闭。
This commit is contained in:
snaily
2025-04-09 15:04:29 +08:00
parent a7dc05a359
commit 169488851f
21 changed files with 1510 additions and 86 deletions

View File

@@ -1,14 +1,34 @@
"""
应用程序配置模块
"""
from typing import List
import datetime
import json
from typing import List, Any, Dict, Type
from pydantic import ValidationError
from pydantic_settings import BaseSettings
from dotenv import find_dotenv, load_dotenv
from sqlalchemy import insert, update, select
from app.core.constants import API_VERSION, DEFAULT_CREATE_IMAGE_MODEL, DEFAULT_FILTER_MODELS, DEFAULT_MODEL, DEFAULT_STREAM_CHUNK_SIZE, DEFAULT_STREAM_LONG_TEXT_THRESHOLD, DEFAULT_STREAM_MAX_DELAY, DEFAULT_STREAM_MIN_DELAY, DEFAULT_STREAM_SHORT_TEXT_THRESHOLD, DEFAULT_TIMEOUT
from app.log.logger import get_config_logger
# 延迟导入以避免循环依赖,仅在 sync_initial_settings 中使用
# from app.database.connection import database
# from app.database.models import Settings as SettingsModel
# from app.database.services import get_all_settings # get_all_settings 可能不适合启动时调用,直接查询
logger = get_config_logger()
class Settings(BaseSettings):
"""应用程序配置"""
# 数据库配置
MYSQL_HOST: str
MYSQL_PORT: int
MYSQL_USER: str
MYSQL_PASSWORD: str
MYSQL_DATABASE: str
# API相关配置
API_KEYS: List[str]
ALLOWED_TOKENS: List[str]
@@ -48,10 +68,215 @@ class Settings(BaseSettings):
# 设置默认AUTH_TOKEN如果未提供
if not self.AUTH_TOKEN and self.ALLOWED_TOKENS:
self.AUTH_TOKEN = self.ALLOWED_TOKENS[0]
class Config:
env_file = ".env"
# 创建全局配置实例
settings = Settings()
# 添加重新加载配置的函数
def reload_settings():
"""重新加载环境变量并更新配置"""
global settings
# 显式加载 .env 文件,覆盖现有环境变量
# find_dotenv() 会查找 .env 文件
load_dotenv(find_dotenv(), override=True)
settings = Settings()
# 可以在这里添加日志记录,确认配置已重新加载
# print("Settings reloaded") # 用于调试
# --- Initial Settings Synchronization ---
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
"""尝试将数据库字符串值解析为目标 Python 类型"""
try:
if target_type == List[str]:
# 尝试解析 JSON 列表,如果失败则按逗号分割
try:
parsed = json.loads(db_value)
if isinstance(parsed, list):
return [str(item) for item in parsed]
except json.JSONDecodeError:
# 回退到逗号分割,去除空格
return [item.strip() for item in db_value.split(',') if item.strip()]
# 如果解析后不是列表或解析失败,返回空列表或进行其他处理
logger.warning(f"Could not parse '{db_value}' as List[str] for key '{key}', falling back to comma split or empty list.")
return [item.strip() for item in db_value.split(',') if item.strip()] # Fallback
elif target_type == bool:
return db_value.lower() in ('true', '1', 'yes', 'on')
elif target_type == int:
return int(db_value)
elif target_type == float:
return float(db_value)
else: # 默认为 str 或其他 pydantic 能处理的类型
return db_value
except (ValueError, TypeError, json.JSONDecodeError) as e:
logger.warning(f"Failed to parse db_value '{db_value}' for key '{key}' as type {target_type}: {e}. Using original string value.")
return db_value # 解析失败则返回原始字符串
async def sync_initial_settings():
"""
应用启动时同步配置:
1. 从数据库加载设置。
2. 将数据库设置合并到内存 settings (数据库优先)。
3. 将最终的内存 settings 同步回数据库。
"""
# 延迟导入以避免循环依赖和确保数据库连接已初始化
from app.database.connection import database
from app.database.models import Settings as SettingsModel
global settings
logger.info("Starting initial settings synchronization...")
if not database.is_connected:
try:
await database.connect()
logger.info("Database connection established for initial sync.")
except Exception as e:
logger.error(f"Failed to connect to database for initial settings sync: {e}. Skipping sync.")
return
try:
# 1. 从数据库加载设置
db_settings_raw: List[Dict[str, Any]] = []
try:
query = select(SettingsModel.key, SettingsModel.value)
results = await database.fetch_all(query)
db_settings_raw = [{"key": row["key"], "value": row["value"]} for row in results]
logger.info(f"Fetched {len(db_settings_raw)} settings from database.")
except Exception as e:
logger.error(f"Failed to fetch settings from database: {e}. Proceeding with environment/dotenv settings.")
# 即使数据库读取失败,也要继续执行,确保基于 env/dotenv 的配置能同步到数据库
db_settings_map: Dict[str, str] = {s['key']: s['value'] for s in db_settings_raw}
# 2. 将数据库设置合并到内存 settings (数据库优先)
updated_in_memory = False
for key, db_value in db_settings_map.items():
if hasattr(settings, key):
target_type = Settings.__annotations__.get(key)
if target_type:
try:
parsed_db_value = _parse_db_value(key, db_value, target_type)
memory_value = getattr(settings, key)
# 比较解析后的值和内存中的值
# 注意:对于列表等复杂类型,直接比较可能不够健壮,但这里简化处理
if parsed_db_value != memory_value:
# 检查类型是否匹配,以防解析函数返回了不兼容的类型
# 优先处理 List[str] 类型,避免直接对泛型使用 isinstance
if target_type == List[str]:
if isinstance(parsed_db_value, list):
# 可以选择性地添加对列表元素的检查,但这里保持简化
setattr(settings, key, parsed_db_value)
logger.info(f"Updated setting '{key}' in memory from database value (List[str]).")
updated_in_memory = True
else:
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected List[str], got {type(parsed_db_value)}. Skipping update.")
# 对于其他非泛型类型,使用常规的 isinstance 检查
elif isinstance(parsed_db_value, target_type):
setattr(settings, key, parsed_db_value)
logger.info(f"Updated setting '{key}' in memory from database value.")
updated_in_memory = True
else:
logger.warning(f"Parsed DB value type mismatch for key '{key}'. Expected {target_type}, got {type(parsed_db_value)}. Skipping update.")
except Exception as e:
logger.error(f"Error processing database setting for key '{key}': {e}")
else:
logger.warning(f"Database setting '{key}' not found in Settings model definition. Ignoring.")
# 如果内存中有更新,重新验证 Pydantic 模型(可选但推荐)
if updated_in_memory:
try:
# 重新加载以确保类型转换和验证
settings = Settings(**settings.model_dump())
logger.info("Settings object re-validated after merging database values.")
except ValidationError as e:
logger.error(f"Validation error after merging database settings: {e}. Settings might be inconsistent.")
# 3. 将最终的内存 settings 同步回数据库
final_memory_settings = settings.model_dump()
settings_to_update: List[Dict[str, Any]] = []
settings_to_insert: List[Dict[str, Any]] = []
now = datetime.datetime.now(datetime.timezone.utc)
existing_db_keys = set(db_settings_map.keys())
for key, value in final_memory_settings.items():
# 序列化值为字符串或 JSON 字符串
if isinstance(value, list):
db_value = json.dumps(value)
elif isinstance(value, bool):
db_value = str(value).lower()
else:
db_value = str(value)
data = {
'key': key,
'value': db_value,
'description': f"{key} configuration setting", # 默认描述
'updated_at': now
}
if key in existing_db_keys:
# 仅当值与数据库中的不同时才更新
if db_settings_map[key] != db_value:
settings_to_update.append(data)
else:
# 如果键不在数据库中,则插入
data['created_at'] = now
settings_to_insert.append(data)
# 在事务中执行批量插入和更新
if settings_to_insert or settings_to_update:
try:
async with database.transaction():
if settings_to_insert:
# 获取现有描述以避免覆盖
query_existing = select(SettingsModel.key, SettingsModel.description).where(SettingsModel.key.in_([s['key'] for s in settings_to_insert]))
existing_desc = {row['key']: row['description'] for row in await database.fetch_all(query_existing)}
for item in settings_to_insert:
item['description'] = existing_desc.get(item['key'], item['description'])
query_insert = insert(SettingsModel).values(settings_to_insert)
await database.execute(query=query_insert)
logger.info(f"Synced (inserted) {len(settings_to_insert)} settings to database.")
if settings_to_update:
# 获取现有描述以避免覆盖
query_existing = select(SettingsModel.key, SettingsModel.description).where(SettingsModel.key.in_([s['key'] for s in settings_to_update]))
existing_desc = {row['key']: row['description'] for row in await database.fetch_all(query_existing)}
for setting_data in settings_to_update:
setting_data['description'] = existing_desc.get(setting_data['key'], setting_data['description'])
query_update = (
update(SettingsModel)
.where(SettingsModel.key == setting_data['key'])
.values(
value=setting_data['value'],
description=setting_data['description'],
updated_at=setting_data['updated_at']
)
)
await database.execute(query=query_update)
logger.info(f"Synced (updated) {len(settings_to_update)} settings to database.")
except Exception as e:
logger.error(f"Failed to sync settings to database during startup: {str(e)}")
else:
logger.info("No setting changes detected between memory and database during initial sync.")
except Exception as e:
logger.error(f"An unexpected error occurred during initial settings sync: {e}")
finally:
if database.is_connected:
try:
# Don't disconnect if it's managed elsewhere (e.g., FastAPI lifespan)
# await database.disconnect()
# logger.info("Database connection closed after initial sync.")
pass # Assume connection lifecycle is managed by the application lifespan
except Exception as e:
logger.error(f"Error disconnecting database after initial sync: {e}")
logger.info("Initial settings synchronization finished.")

View File

@@ -5,13 +5,15 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.config.config import settings
from app.config.config import settings, sync_initial_settings
from app.log.logger import get_application_logger
from app.middleware.middleware import setup_middlewares
from app.exception.exceptions import setup_exception_handlers
from app.router.routes import setup_routers
from app.service.key.key_manager import get_key_manager_instance
from app.core.initialization import initialize_app
from app.database.connection import connect_to_db, disconnect_from_db
from app.database.initialization import initialize_database
logger = get_application_logger()
@@ -26,17 +28,30 @@ async def lifespan(app: FastAPI):
# 启动事件
logger.info("Application starting up...")
try:
# 初始化KeyManager
# 初始化数据库
initialize_database()
logger.info("Database initialized successfully")
# 连接到数据库
await connect_to_db()
# 同步初始配置DB优先然后同步回DB
await sync_initial_settings()
# 初始化KeyManager (使用可能已从DB更新的settings)
await get_key_manager_instance(settings.API_KEYS)
logger.info("KeyManager initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize KeyManager: {str(e)}")
logger.error(f"Failed to initialize application: {str(e)}")
raise
yield # 应用程序运行期间
# 关闭事件
logger.info("Application shutting down...")
# 断开数据库连接
await disconnect_from_db()
def create_app() -> FastAPI:
"""

View File

@@ -37,4 +37,4 @@ def initialize_app() -> None:
]
ensure_directories_exist(required_directories)
logger.info("Application initialization completed")
logger.info("core initialization completed")

3
app/database/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
数据库模块
"""

View File

@@ -0,0 +1,49 @@
"""
数据库连接池模块
"""
from databases import Database
from sqlalchemy import create_engine, MetaData
from sqlalchemy.ext.declarative import declarative_base
from app.config.config import settings
from app.log.logger import get_database_logger
logger = get_database_logger()
# 数据库URL
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)
# 创建元数据对象
metadata = MetaData()
# 创建基类
Base = declarative_base(metadata=metadata)
# 创建数据库连接池
database = Database(DATABASE_URL)
async def connect_to_db():
"""
连接到数据库
"""
try:
await database.connect()
logger.info("Connected to database")
except Exception as e:
logger.error(f"Failed to connect to database: {str(e)}")
raise
async def disconnect_from_db():
"""
断开数据库连接
"""
try:
await database.disconnect()
logger.info("Disconnected from database")
except Exception as e:
logger.error(f"Failed to disconnect from database: {str(e)}")

View File

@@ -0,0 +1,77 @@
"""
数据库初始化模块
"""
from dotenv import dotenv_values
from sqlalchemy import inspect
from sqlalchemy.orm import Session
from app.database.connection import engine, Base
from app.database.models import Settings
from app.log.logger import get_database_logger
logger = get_database_logger()
def create_tables():
"""
创建数据库表
"""
try:
# 创建所有表
Base.metadata.create_all(engine)
logger.info("Database tables created successfully")
except Exception as e:
logger.error(f"Failed to create database tables: {str(e)}")
raise
def import_env_to_settings():
"""
将.env文件中的配置项导入到t_settings表中
"""
try:
# 获取.env文件中的所有配置项
env_values = dotenv_values(".env")
# 获取检查器
inspector = inspect(engine)
# 检查t_settings表是否存在
if "t_settings" in inspector.get_table_names():
# 使用Session进行数据库操作
with Session(engine) as session:
# 获取所有现有的配置项
current_settings = {setting.key: setting for setting in session.query(Settings).all()}
# 遍历所有配置项
for key, value in env_values.items():
# 检查配置项是否已存在
if key not in current_settings:
# 插入配置项
new_setting = Settings(key=key, value=value)
session.add(new_setting)
logger.info(f"Inserted setting: {key}")
# 提交事务
session.commit()
logger.info("Environment variables imported to settings table successfully")
except Exception as e:
logger.error(f"Failed to import environment variables to settings table: {str(e)}")
raise
def initialize_database():
"""
初始化数据库
"""
try:
# 创建表
create_tables()
# 导入环境变量
import_env_to_settings()
except Exception as e:
logger.error(f"Failed to initialize database: {str(e)}")
raise

40
app/database/models.py Normal file
View File

@@ -0,0 +1,40 @@
"""
数据库模型模块
"""
import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON
from app.database.connection import Base
class Settings(Base):
"""
设置表,对应.env中的配置项
"""
__tablename__ = "t_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(100), nullable=False, unique=True, comment="配置项键名")
value = Column(Text, nullable=True, comment="配置项值")
description = Column(String(255), nullable=True, comment="配置项描述")
created_at = Column(DateTime, default=datetime.datetime.now, comment="创建时间")
updated_at = Column(DateTime, default=datetime.datetime.now, onupdate=datetime.datetime.now, comment="更新时间")
def __repr__(self):
return f"<Settings(key='{self.key}', value='{self.value}')>"
class ErrorLog(Base):
"""
错误日志表
"""
__tablename__ = "t_error_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
gemini_key = Column(String(100), nullable=True, comment="Gemini API密钥")
error_log = Column(Text, nullable=True, comment="错误日志")
request_msg = Column(JSON, nullable=True, comment="请求消息")
request_time = Column(DateTime, default=datetime.datetime.now, comment="请求时间")
def __repr__(self):
return f"<ErrorLog(id='{self.id}', gemini_key='{self.gemini_key}')>"

165
app/database/services.py Normal file
View File

@@ -0,0 +1,165 @@
"""
数据库服务模块
"""
import datetime
import json
from typing import Dict, List, Optional, Any, Union
from sqlalchemy import select, insert, update
from app.database.connection import database
from app.database.models import Settings, ErrorLog
from app.log.logger import get_database_logger
logger = get_database_logger()
async def get_all_settings() -> List[Dict[str, Any]]:
"""
获取所有设置
Returns:
List[Dict[str, Any]]: 设置列表
"""
try:
query = select(Settings)
result = await database.fetch_all(query)
return [dict(row) for row in result]
except Exception as e:
logger.error(f"Failed to get all settings: {str(e)}")
raise
async def get_setting(key: str) -> Optional[Dict[str, Any]]:
"""
获取指定键的设置
Args:
key: 设置键名
Returns:
Optional[Dict[str, Any]]: 设置信息如果不存在则返回None
"""
try:
query = select(Settings).where(Settings.key == key)
result = await database.fetch_one(query)
return dict(result) if result else None
except Exception as e:
logger.error(f"Failed to get setting {key}: {str(e)}")
raise
async def update_setting(key: str, value: str, description: Optional[str] = None) -> bool:
"""
更新设置
Args:
key: 设置键名
value: 设置值
description: 设置描述
Returns:
bool: 是否更新成功
"""
try:
# 检查设置是否存在
setting = await get_setting(key)
if setting:
# 更新设置
query = (
update(Settings)
.where(Settings.key == key)
.values(
value=value,
description=description if description else setting["description"],
updated_at=datetime.datetime.now()
)
)
await database.execute(query)
logger.info(f"Updated setting: {key}")
return True
else:
# 插入设置
query = (
insert(Settings)
.values(
key=key,
value=value,
description=description,
created_at=datetime.datetime.now(),
updated_at=datetime.datetime.now()
)
)
await database.execute(query)
logger.info(f"Inserted setting: {key}")
return True
except Exception as e:
logger.error(f"Failed to update setting {key}: {str(e)}")
return False
async def add_error_log(
gemini_key: Optional[str] = None,
error_log: Optional[str] = None,
request_msg: Optional[Union[Dict[str, Any], str]] = None
) -> bool:
"""
添加错误日志
Args:
gemini_key: Gemini API密钥
error_log: 错误日志
request_msg: 请求消息
Returns:
bool: 是否添加成功
"""
try:
# 如果request_msg是字典则转换为JSON字符串
if isinstance(request_msg, dict):
request_msg_json = request_msg
elif isinstance(request_msg, str):
try:
request_msg_json = json.loads(request_msg)
except json.JSONDecodeError:
request_msg_json = {"message": request_msg}
else:
request_msg_json = None
# 插入错误日志
query = (
insert(ErrorLog)
.values(
gemini_key=gemini_key,
error_log=error_log,
request_msg=request_msg_json,
request_time=datetime.datetime.now()
)
)
await database.execute(query)
logger.info(f"Added error log for key: {gemini_key}")
return True
except Exception as e:
logger.error(f"Failed to add error log: {str(e)}")
return False
async def get_error_logs(limit: int = 100, offset: int = 0) -> List[Dict[str, Any]]:
"""
获取错误日志
Args:
limit: 限制数量
offset: 偏移量
Returns:
List[Dict[str, Any]]: 错误日志列表
"""
try:
query = select(ErrorLog).order_by(ErrorLog.request_time.desc()).limit(limit).offset(offset)
result = await database.fetch_all(query)
return [dict(row) for row in result]
except Exception as e:
logger.error(f"Failed to get error logs: {str(e)}")
raise

View File

@@ -156,4 +156,16 @@ def get_routes_logger():
def get_config_routes_logger():
return Logger.setup_logger("config_routes")
return Logger.setup_logger("config_routes")
def get_config_logger():
return Logger.setup_logger("config")
def get_database_logger():
return Logger.setup_logger("database")
def get_log_routes_logger():
return Logger.setup_logger("log_routes")

View File

@@ -21,7 +21,7 @@ async def get_config(request: Request):
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
return ConfigService.get_config()
return await ConfigService.get_config()
@router.put("", response_model=Dict[str, Any])
@@ -31,7 +31,7 @@ async def update_config(config_data: Dict[str, Any], request: Request):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
try:
return ConfigService.update_config(config_data)
return await ConfigService.update_config(config_data)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -43,6 +43,6 @@ async def reset_config(request: Request):
logger.warning("Unauthorized access attempt to config page")
return RedirectResponse(url="/", status_code=302)
try:
return ConfigService.reset_config()
return await ConfigService.reset_config()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

45
app/router/log_routes.py Normal file
View File

@@ -0,0 +1,45 @@
"""
日志路由模块
"""
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException, Request, Query
from fastapi.responses import RedirectResponse
from app.core.security import verify_auth_token
from app.log.logger import get_log_routes_logger
from app.database.services import get_error_logs
# 创建路由
router = APIRouter(prefix="/api/logs", tags=["logs"])
logger = get_log_routes_logger()
@router.get("/errors", response_model=List[Dict[str, Any]])
async def get_error_logs_api(
request: Request,
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0)
):
"""
获取错误日志
Args:
request: 请求对象
limit: 限制数量
offset: 偏移量
Returns:
List[Dict[str, Any]]: 错误日志列表
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to error logs")
return RedirectResponse(url="/", status_code=302)
try:
logs = await get_error_logs(limit, offset)
return logs
except Exception as e:
logger.error(f"Failed to get error logs: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get error logs: {str(e)}")

View File

@@ -8,7 +8,7 @@ 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
from app.router import gemini_routes, openai_routes, config_routes, log_routes
from app.service.key.key_manager import get_key_manager_instance
logger = get_routes_logger()
@@ -29,6 +29,7 @@ def setup_routers(app: FastAPI) -> None:
app.include_router(gemini_routes.router)
app.include_router(gemini_routes.router_v1beta)
app.include_router(config_routes.router)
app.include_router(log_routes.router)
# 添加页面路由
setup_page_routes(app)
@@ -113,6 +114,21 @@ def setup_page_routes(app: FastAPI) -> None:
except Exception as e:
logger.error(f"Error accessing config page: {str(e)}")
raise
@app.get("/logs", response_class=HTMLResponse)
async def logs_page(request: Request):
"""错误日志页面"""
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 logs page")
return RedirectResponse(url="/", status_code=302)
logger.info("Logs page accessed successfully")
return templates.TemplateResponse("error_logs.html", {"request": request})
except Exception as e:
logger.error(f"Error accessing logs page: {str(e)}")
raise
def setup_health_routes(app: FastAPI) -> None:

View File

@@ -1,57 +1,118 @@
"""
配置服务模块
"""
import os
from typing import Any, Dict
import datetime
import json
from dotenv import load_dotenv, set_key
import os
from typing import Any, Dict, List
from app.config.config import settings, Settings
from dotenv import load_dotenv
from sqlalchemy import insert, update
from app.config.config import settings, reload_settings
from app.database.connection import database
from app.database.models import Settings
from app.database.services import get_all_settings
from app.service.key.key_manager import get_key_manager_instance, reset_key_manager_instance
from app.log.logger import get_config_routes_logger
logger = get_config_routes_logger()
class ConfigService:
"""配置服务类,用于管理应用程序配置"""
@staticmethod
def get_config() -> Dict[str, Any]:
"""
获取当前配置
Returns:
Dict[str, Any]: 配置字典
"""
config_dict = {}
# 获取Settings类的所有字段
for field_name in settings.model_fields:
value = getattr(settings, field_name)
config_dict[field_name] = value
return config_dict
async def get_config() -> Dict[str, Any]:
return settings.model_dump()
@staticmethod
def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]:
"""
更新配置
Args:
config_data (Dict[str, Any]): 新的配置数据
Returns:
Dict[str, Any]: 更新后的配置字典
"""
# 更新settings对象
async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]:
for key, value in config_data.items():
if hasattr(settings, key):
setattr(settings, key, value)
logger.info(f"Updated setting in memory: {key}")
# 更新.env文件
ConfigService._update_env_file(config_data)
return ConfigService.get_config()
# 获取现有设置
existing_settings_raw: List[Dict[str, Any]] = await get_all_settings()
existing_settings_map: Dict[str, Dict[str, Any]] = {s['key']: s for s in existing_settings_raw}
existing_keys = set(existing_settings_map.keys())
settings_to_update: List[Dict[str, Any]] = []
settings_to_insert: List[Dict[str, Any]] = []
now = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=8)))
# 准备要更新或插入的数据
for key, value in config_data.items():
# 处理不同类型的值
if isinstance(value, list):
db_value = json.dumps(value)
elif isinstance(value, bool):
db_value = str(value).lower()
else:
db_value = str(value)
# 仅当值发生变化时才更新
if key in existing_keys and existing_settings_map[key]['value'] == db_value:
continue
description = f"{key}配置项"
data = {
'key': key,
'value': db_value,
'description': description,
'updated_at': now
}
if key in existing_keys:
# Preserve original description if not explicitly provided
data['description'] = existing_settings_map[key].get('description', description)
settings_to_update.append(data)
else:
data['created_at'] = now
settings_to_insert.append(data)
# 在事务中执行批量插入和更新
if settings_to_insert or settings_to_update:
try:
async with database.transaction():
if settings_to_insert:
query_insert = insert(Settings).values(settings_to_insert)
await database.execute(query=query_insert)
logger.info(f"Bulk inserted {len(settings_to_insert)} settings.")
if settings_to_update:
for setting_data in settings_to_update:
query_update = (
update(Settings)
.where(Settings.key == setting_data['key'])
.values(
value=setting_data['value'],
description=setting_data['description'],
updated_at=setting_data['updated_at']
)
)
await database.execute(query=query_update)
logger.info(f"Updated {len(settings_to_update)} settings.")
except Exception as e:
logger.error(f"Failed to bulk update/insert settings: {str(e)}")
raise # Re-raise the exception after logging
# 重置并重新初始化 KeyManager
try:
await reset_key_manager_instance()
await get_key_manager_instance(settings.API_KEYS)
logger.info("KeyManager instance re-initialized with updated settings.")
except Exception as e:
logger.error(f"Failed to re-initialize KeyManager: {str(e)}")
# Decide if this error should prevent returning the updated config
# For now, we log the error and continue
return await ConfigService.get_config()
@staticmethod
def reset_config() -> Dict[str, Any]:
async def reset_config() -> Dict[str, Any]:
"""
重置配置到默认值
@@ -60,45 +121,57 @@ class ConfigService:
"""
# 重新加载.env文件
load_dotenv(override=True)
# 重新创建settings对象
global settings
settings = Settings()
return ConfigService.get_config()
# 重新加载配置对象以反映最新的环境变量
reload_settings()
logger.info("Settings object reloaded from environment variables.")
# 同步数据库中的配置到settings对象
await ConfigService._sync_db_config()
return await ConfigService.get_config()
@staticmethod
def _update_env_file(config_data: Dict[str, Any]) -> None:
async def _sync_db_config() -> None:
"""
更新.env文件
Args:
config_data (Dict[str, Any]): 配置数据
.env文件中的配置项同步到数据库
"""
env_path = ".env"
# 确保.env文件存在
if not os.path.exists(env_path):
# 如果不存在,复制.env.example
if os.path.exists(".env.example"):
with open(".env.example", "r", encoding="utf-8") as example_file:
with open(env_path, "w", encoding="utf-8") as env_file:
env_file.write(example_file.read())
else:
# 创建空文件
open(env_path, "w", encoding="utf-8").close()
# 更新.env文件中的配置
for key, value in config_data.items():
# 处理不同类型的值
if isinstance(value, list):
# 将列表转换为JSON字符串
env_value = json.dumps(value)
elif isinstance(value, bool):
# 布尔值转换为小写字符串
env_value = str(value).lower()
else:
env_value = str(value)
try:
# 获取.env文件中的所有配置项
env_values = dotenv_values(".env")
await ConfigService.update_config(env_values)
# 更新.env文件
set_key(env_path, key, env_value)
logger.info("Synced configuration to database")
except Exception as e:
logger.error(f"Failed to sync configuration to database: {str(e)}")
# 添加dotenv_values函数
def dotenv_values(dotenv_path: str) -> Dict[str, str]:
"""
从.env文件中读取配置项
Args:
dotenv_path: .env文件路径
Returns:
Dict[str, str]: 配置项字典
"""
if not os.path.exists(dotenv_path):
return {}
result = {}
with open(dotenv_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip()
# 去除引号
if value and value[0] == value[-1] and value[0] in ["'", '"']:
value = value[1:-1]
result[key] = value
return result

View File

@@ -107,4 +107,12 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
if api_keys is None:
raise ValueError("API keys are required to initialize the KeyManager")
_singleton_instance = KeyManager(api_keys)
logger.info("KeyManager instance created.")
return _singleton_instance
async def reset_key_manager_instance():
"""重置 KeyManager 单例实例"""
global _singleton_instance
async with _singleton_lock:
if _singleton_instance:
_singleton_instance = None
logger.info("KeyManager instance reset.")

View File

@@ -0,0 +1,271 @@
/* 错误日志页面样式 */
/* 全局样式 */
body {
font-family: 'Roboto', sans-serif;
background-color: #f8f9fa;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
color: #764ba2;
font-weight: 700;
margin-bottom: 0;
}
/* 导航栏样式 */
.nav-tabs {
display: flex;
margin-bottom: 20px;
border-bottom: none;
}
.tab-link {
padding: 10px 20px;
margin-right: 5px;
color: #555;
text-decoration: none;
border-radius: 5px;
transition: all 0.3s ease;
display: flex;
align-items: center;
}
.tab-link i {
margin-right: 5px;
}
.tab-link:hover {
background-color: #f0f0f0;
color: #764ba2;
}
.tab-link.active {
background-color: #764ba2;
color: white;
}
/* 卡片样式 */
.card {
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
border: none;
}
.card-header {
background-color: #f8f9fa;
border-bottom: 1px solid #eee;
padding: 15px 20px;
border-radius: 10px 10px 0 0 !important;
}
.card-body {
padding: 20px;
}
.card-footer {
background-color: #f8f9fa;
border-top: 1px solid #eee;
padding: 15px 20px;
border-radius: 0 0 10px 10px !important;
}
/* 表格样式 */
.table {
font-size: 0.9rem;
}
.table th {
background-color: #f8f9fa;
font-weight: 600;
}
/* 错误日志内容截断 */
.error-log-content {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 模态框样式 */
.modal-dialog {
max-width: 800px;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
max-height: 300px;
overflow-y: auto;
}
/* 分页样式 */
.pagination .page-item.active .page-link {
background-color: #0d6efd;
border-color: #0d6efd;
}
.pagination .page-link {
color: #0d6efd;
}
/* 按钮样式 */
.btn-view-details {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
/* 加载指示器样式 */
#loadingIndicator .spinner-border {
width: 3rem;
height: 3rem;
}
/* 刷新按钮样式 */
.refresh-btn {
position: fixed;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #764ba2;
color: white;
border: none;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
z-index: 1000;
transition: all 0.3s ease;
}
.refresh-btn:hover {
background-color: #5d3b82;
transform: scale(1.1);
}
.refresh-btn i {
font-size: 18px;
}
.rotating {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 滚动按钮样式 */
.scroll-buttons {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 1000;
}
.scroll-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #764ba2;
color: white;
border: none;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all 0.3s ease;
}
.scroll-btn:hover {
background-color: #5d3b82;
transform: scale(1.1);
}
/* 版权信息样式 */
.copyright {
text-align: center;
margin-top: 30px;
padding: 20px 0;
color: #666;
font-size: 0.9rem;
border-top: 1px solid #eee;
}
.copyright a {
color: #764ba2;
text-decoration: none;
}
.copyright a:hover {
text-decoration: underline;
}
.copyright img {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
margin-right: 5px;
}
/* 复制状态提示 */
#copyStatus {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 10px 20px;
border-radius: 5px;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
}
#copyStatus.show {
opacity: 1;
}
/* 响应式调整 */
@media (max-width: 768px) {
.d-flex.justify-content-between {
flex-direction: column;
align-items: flex-start !important;
}
.d-flex.justify-content-between > div {
margin-top: 1rem;
width: 100%;
}
.card-header .d-flex {
flex-direction: column;
}
.card-header .input-group {
margin-bottom: 0.5rem;
margin-right: 0 !important;
}
#refreshBtn {
width: 100%;
}
}

255
app/static/js/error_logs.js Normal file
View File

@@ -0,0 +1,255 @@
// 错误日志页面JavaScript
// 页面滚动功能
function scrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
function scrollToBottom() {
window.scrollTo({
top: document.body.scrollHeight,
behavior: 'smooth'
});
}
// 刷新页面功能
function refreshPage(button) {
if (button) {
button.classList.add('rotating');
}
setTimeout(() => {
window.location.reload();
}, 500);
}
// 全局变量
let currentPage = 1;
let pageSize = 20;
let totalPages = 1;
let errorLogs = [];
// 页面加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
// 初始化页面大小选择器
const pageSizeSelector = document.getElementById('pageSize');
pageSizeSelector.value = pageSize;
pageSizeSelector.addEventListener('change', function() {
pageSize = parseInt(this.value);
currentPage = 1; // 重置到第一页
loadErrorLogs();
});
// 初始化刷新按钮
document.getElementById('refreshBtn').addEventListener('click', function() {
loadErrorLogs();
});
// 加载错误日志数据
loadErrorLogs();
});
// 加载错误日志数据
function loadErrorLogs() {
showLoading(true);
showError(false);
showNoData(false);
const offset = (currentPage - 1) * pageSize;
fetch(`/api/logs/errors?limit=${pageSize}&offset=${offset}`)
.then(response => {
if (!response.ok) {
throw new Error('网络响应异常');
}
return response.json();
})
.then(data => {
errorLogs = data;
renderErrorLogs();
showLoading(false);
if (errorLogs.length === 0) {
showNoData(true);
}
})
.catch(error => {
console.error('获取错误日志失败:', error);
showLoading(false);
showError(true);
});
}
// 渲染错误日志表格
function renderErrorLogs() {
const tableBody = document.getElementById('errorLogsTable');
tableBody.innerHTML = '';
errorLogs.forEach(log => {
const row = document.createElement('tr');
// 格式化日期
const requestTime = new Date(log.request_time);
const formattedTime = requestTime.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
// 截断错误日志内容
const errorLogContent = log.error_log ? log.error_log.substring(0, 100) + (log.error_log.length > 100 ? '...' : '') : '无';
row.innerHTML = `
<td>${log.id}</td>
<td>${log.gemini_key || '无'}</td>
<td class="error-log-content">${errorLogContent}</td>
<td>${formattedTime}</td>
<td>
<button class="btn btn-sm btn-primary btn-view-details" data-log-id="${log.id}">
查看详情
</button>
</td>
`;
tableBody.appendChild(row);
});
// 添加详情按钮事件监听
document.querySelectorAll('.btn-view-details').forEach(button => {
button.addEventListener('click', function() {
const logId = parseInt(this.getAttribute('data-log-id'));
showLogDetails(logId);
});
});
// 更新分页
updatePagination();
}
// 显示错误日志详情
function showLogDetails(logId) {
const log = errorLogs.find(log => log.id === logId);
if (!log) return;
// 格式化日期
const requestTime = new Date(log.request_time);
const formattedTime = requestTime.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
// 格式化请求消息
let formattedRequestMsg = '';
if (log.request_msg) {
try {
if (typeof log.request_msg === 'string') {
formattedRequestMsg = log.request_msg;
} else {
formattedRequestMsg = JSON.stringify(log.request_msg, null, 2);
}
} catch (e) {
formattedRequestMsg = String(log.request_msg);
}
} else {
formattedRequestMsg = '无';
}
// 填充模态框内容
document.getElementById('modalGeminiKey').textContent = log.gemini_key || '无';
document.getElementById('modalErrorLog').textContent = log.error_log || '无';
document.getElementById('modalRequestMsg').textContent = formattedRequestMsg;
document.getElementById('modalRequestTime').textContent = formattedTime;
// 显示模态框
const modal = new bootstrap.Modal(document.getElementById('logDetailModal'));
modal.show();
}
// 更新分页控件
function updatePagination() {
const paginationElement = document.getElementById('pagination');
paginationElement.innerHTML = '';
// 计算总页数
const totalCount = errorLogs.length;
totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
// 上一页按钮
const prevItem = document.createElement('li');
prevItem.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
prevItem.innerHTML = `<a class="page-link" href="#" aria-label="上一页"><span aria-hidden="true">&laquo;</span></a>`;
prevItem.addEventListener('click', function(e) {
e.preventDefault();
if (currentPage > 1) {
currentPage--;
loadErrorLogs();
}
});
paginationElement.appendChild(prevItem);
// 页码按钮
for (let i = 1; i <= totalPages; i++) {
const pageItem = document.createElement('li');
pageItem.className = `page-item ${i === currentPage ? 'active' : ''}`;
pageItem.innerHTML = `<a class="page-link" href="#">${i}</a>`;
pageItem.addEventListener('click', function(e) {
e.preventDefault();
currentPage = i;
loadErrorLogs();
});
paginationElement.appendChild(pageItem);
}
// 下一页按钮
const nextItem = document.createElement('li');
nextItem.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
nextItem.innerHTML = `<a class="page-link" href="#" aria-label="下一页"><span aria-hidden="true">&raquo;</span></a>`;
nextItem.addEventListener('click', function(e) {
e.preventDefault();
if (currentPage < totalPages) {
currentPage++;
loadErrorLogs();
}
});
paginationElement.appendChild(nextItem);
}
// 显示/隐藏加载指示器
function showLoading(show) {
const loadingIndicator = document.getElementById('loadingIndicator');
if (show) {
loadingIndicator.classList.remove('d-none');
} else {
loadingIndicator.classList.add('d-none');
}
}
// 显示/隐藏错误消息
function showError(show) {
const errorMessage = document.getElementById('errorMessage');
if (show) {
errorMessage.classList.remove('d-none');
} else {
errorMessage.classList.add('d-none');
}
}
// 显示/隐藏无数据消息
function showNoData(show) {
const noDataMessage = document.getElementById('noDataMessage');
if (show) {
noDataMessage.classList.remove('d-none');
} else {
noDataMessage.classList.add('d-none');
}
}

View File

@@ -27,6 +27,9 @@
<a href="/keys" class="tab-link">
<i class="fas fa-key"></i> 密钥管理
</a>
<a href="/logs" class="tab-link">
<i class="fas fa-exclamation-triangle"></i> 错误日志
</a>
</div>
<div class="config-tabs">

View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>错误日志管理</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#764ba2">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="GBalance">
<link rel="icon" href="/static/icons/icon-192x192.png">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', path='/css/error_logs.css') }}">
</head>
<body>
<div class="container">
<button class="refresh-btn" onclick="refreshPage(this)">
<i class="fas fa-sync-alt"></i>
</button>
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Gemini Balance</h1>
<div class="nav-tabs">
<a href="/config" class="tab-link">
<i class="fas fa-cog"></i> 配置编辑
</a>
<a href="/keys" class="tab-link">
<i class="fas fa-key"></i> 密钥管理
</a>
<a href="/logs" class="tab-link active">
<i class="fas fa-exclamation-triangle"></i> 错误日志
</a>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">错误日志列表</h5>
<div class="d-flex">
<div class="input-group me-2">
<span class="input-group-text">每页显示</span>
<select id="pageSize" class="form-select">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span class="input-group-text"></span>
</div>
<button id="refreshBtn" class="btn btn-primary">
<i class="bi bi-arrow-clockwise"></i> 刷新
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Gemini密钥</th>
<th>错误日志</th>
<th>请求时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="errorLogsTable">
<!-- 错误日志数据将通过JavaScript动态加载 -->
</tbody>
</table>
</div>
<div id="loadingIndicator" class="text-center my-4 d-none">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<p class="mt-2">加载中,请稍候...</p>
</div>
<div id="noDataMessage" class="text-center my-4 d-none">
<p class="text-muted">暂无错误日志数据</p>
</div>
<div id="errorMessage" class="alert alert-danger my-4 d-none">
加载错误日志失败,请稍后重试。
</div>
</div>
<div class="card-footer">
<nav aria-label="错误日志分页">
<ul class="pagination justify-content-center mb-0" id="pagination">
<!-- 分页控件将通过JavaScript动态加载 -->
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
<div class="scroll-buttons">
<button class="scroll-btn" onclick="scrollToTop()" title="回到顶部">
<i class="fas fa-chevron-up"></i>
</button>
<button class="scroll-btn" onclick="scrollToBottom()" title="滚动到底部">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div id="copyStatus"></div>
<div class="copyright">
© <script>document.write(new Date().getFullYear())</script> by <a href="https://linux.do/u/snaily" target="_blank"><img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily">snaily</a> |
<a href="https://github.com/snailyp/gemini-balance" target="_blank"><i class="fab fa-github"></i> GitHub</a>
</div>
<!-- 错误日志详情模态框 -->
<div class="modal fade" id="logDetailModal" tabindex="-1" aria-labelledby="logDetailModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="logDetailModalLabel">错误日志详情</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="关闭"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<h6>Gemini密钥:</h6>
<pre id="modalGeminiKey" class="bg-light p-2 rounded"></pre>
</div>
<div class="mb-3">
<h6>错误日志:</h6>
<pre id="modalErrorLog" class="bg-light p-2 rounded"></pre>
</div>
<div class="mb-3">
<h6>请求消息:</h6>
<pre id="modalRequestMsg" class="bg-light p-2 rounded"></pre>
</div>
<div>
<h6>请求时间:</h6>
<p id="modalRequestTime"></p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', path='/js/error_logs.js') }}"></script>
</body>
</html>

View File

@@ -27,6 +27,9 @@
<a href="/keys" class="tab-link active">
<i class="fas fa-key"></i> 密钥管理
</a>
<a href="/logs" class="tab-link">
<i class="fas fa-exclamation-triangle"></i> 错误日志
</a>
</div>
<div class="key-list">
<h2 onclick="toggleSection(this, 'validKeys')">

View File

@@ -144,3 +144,5 @@ def is_valid_api_key(key: str) -> bool:
return len(key) >= 30
return False

View File

@@ -9,3 +9,9 @@ uvicorn
google-genai
jinja2
python-multipart
# 数据库相关依赖
pymysql
sqlalchemy
aiomysql
databases
python-dotenv