diff --git a/app/config/config.py b/app/config/config.py index c6650e7..36a1b7a 100644 --- a/app/config/config.py +++ b/app/config/config.py @@ -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.") diff --git a/app/core/application.py b/app/core/application.py index d3aa02b..b63fe42 100644 --- a/app/core/application.py +++ b/app/core/application.py @@ -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: """ diff --git a/app/core/initialization.py b/app/core/initialization.py index 92bff80..3e970ee 100644 --- a/app/core/initialization.py +++ b/app/core/initialization.py @@ -37,4 +37,4 @@ def initialize_app() -> None: ] ensure_directories_exist(required_directories) - logger.info("Application initialization completed") + logger.info("core initialization completed") diff --git a/app/database/__init__.py b/app/database/__init__.py new file mode 100644 index 0000000..0c166ad --- /dev/null +++ b/app/database/__init__.py @@ -0,0 +1,3 @@ +""" +数据库模块 +""" diff --git a/app/database/connection.py b/app/database/connection.py new file mode 100644 index 0000000..1e52f8f --- /dev/null +++ b/app/database/connection.py @@ -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)}") diff --git a/app/database/initialization.py b/app/database/initialization.py new file mode 100644 index 0000000..a9e8863 --- /dev/null +++ b/app/database/initialization.py @@ -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 diff --git a/app/database/models.py b/app/database/models.py new file mode 100644 index 0000000..6a3bbd0 --- /dev/null +++ b/app/database/models.py @@ -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"" + + +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"" diff --git a/app/database/services.py b/app/database/services.py new file mode 100644 index 0000000..52e9f28 --- /dev/null +++ b/app/database/services.py @@ -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 diff --git a/app/log/logger.py b/app/log/logger.py index 551eb16..bf72872 100644 --- a/app/log/logger.py +++ b/app/log/logger.py @@ -156,4 +156,16 @@ def get_routes_logger(): def get_config_routes_logger(): - return Logger.setup_logger("config_routes") \ No newline at end of file + 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") diff --git a/app/router/config_routes.py b/app/router/config_routes.py index 5acdae4..4385260 100644 --- a/app/router/config_routes.py +++ b/app/router/config_routes.py @@ -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)) diff --git a/app/router/log_routes.py b/app/router/log_routes.py new file mode 100644 index 0000000..41eb759 --- /dev/null +++ b/app/router/log_routes.py @@ -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)}") diff --git a/app/router/routes.py b/app/router/routes.py index a2ec9f3..bc36739 100644 --- a/app/router/routes.py +++ b/app/router/routes.py @@ -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: diff --git a/app/service/config/config_service.py b/app/service/config/config_service.py index 198e294..f6f398b 100644 --- a/app/service/config/config_service.py +++ b/app/service/config/config_service.py @@ -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 diff --git a/app/service/key/key_manager.py b/app/service/key/key_manager.py index 096ac5d..1a77814 100644 --- a/app/service/key/key_manager.py +++ b/app/service/key/key_manager.py @@ -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.") diff --git a/app/static/css/error_logs.css b/app/static/css/error_logs.css new file mode 100644 index 0000000..dcdd01d --- /dev/null +++ b/app/static/css/error_logs.css @@ -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%; + } +} diff --git a/app/static/js/error_logs.js b/app/static/js/error_logs.js new file mode 100644 index 0000000..4c84828 --- /dev/null +++ b/app/static/js/error_logs.js @@ -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 = ` + ${log.id} + ${log.gemini_key || '无'} + ${errorLogContent} + ${formattedTime} + + + + `; + + 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 = ``; + 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 = `${i}`; + 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 = ``; + 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'); + } +} diff --git a/app/templates/config_editor.html b/app/templates/config_editor.html index fd29aca..fd93ddd 100644 --- a/app/templates/config_editor.html +++ b/app/templates/config_editor.html @@ -27,6 +27,9 @@ 密钥管理 + + 错误日志 +
diff --git a/app/templates/error_logs.html b/app/templates/error_logs.html new file mode 100644 index 0000000..5c6b20f --- /dev/null +++ b/app/templates/error_logs.html @@ -0,0 +1,156 @@ + + + + + + 错误日志管理 + + + + + + + + + + + + +
+ +
+
+ + +
+
+
错误日志列表
+
+
+ 每页显示 + + +
+ +
+
+
+
+ + + + + + + + + + + + + +
IDGemini密钥错误日志请求时间操作
+
+ +
+
+ 加载中... +
+

加载中,请稍候...

+
+ +
+

暂无错误日志数据

+
+ +
+ 加载错误日志失败,请稍后重试。 +
+
+ +
+
+
+
+ +
+ + +
+ +
+ + + + + + + + + + diff --git a/app/templates/keys_status.html b/app/templates/keys_status.html index 5aca554..7d7af3f 100644 --- a/app/templates/keys_status.html +++ b/app/templates/keys_status.html @@ -27,6 +27,9 @@ 密钥管理 + + 错误日志 +

diff --git a/app/utils/helpers.py b/app/utils/helpers.py index 957f177..fef7ed6 100644 --- a/app/utils/helpers.py +++ b/app/utils/helpers.py @@ -144,3 +144,5 @@ def is_valid_api_key(key: str) -> bool: return len(key) >= 30 return False + + diff --git a/requirements.txt b/requirements.txt index 3c44d94..b92c8a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,9 @@ uvicorn google-genai jinja2 python-multipart +# 数据库相关依赖 +pymysql +sqlalchemy +aiomysql +databases +python-dotenv