feat: 改进错误日志功能并优化应用初始化流程

本次提交主要包含以下更新:

- **错误日志页面增强**:
    - 重构了 [`app/static/js/error_logs.js`](app/static/js/error_logs.js) 中的分页逻辑,将样式控制移至 CSS,简化了 JavaScript 代码。
    - 更新了 [`app/templates/error_logs.html`](app/templates/error_logs.html) 中的分页样式,使其与 `keys_status.html` 保持一致,提升了视觉统一性。
    - 在错误日志页面新增了“清空全部”按钮,方便用户一键清除所有错误记录。
    - 调整了错误日志表格头部的文本颜色为白色,以改善深色主题下的可读性。

- **应用初始化与配置优化**:
    - 调整了 [`app/config/config.py`](app/config/config.py) 中日志记录器的获取方式,确保在配置加载早期即可用。
    - 在 [`app/core/application.py`](app/core/application.py) 中引入了更明确的数据库连接管理(连接、断开、初始化)逻辑。
    - 优化了 [`app/utils/helpers.py`](app/utils/helpers.py) 中项目路径和版本文件路径的定义方式,使其在模块级别初始化。

- **依赖清理**:
    - 从 [`requirements.txt`](requirements.txt) 中移除了不必要的注释。

这些更改旨在提升错误日志模块的用户体验和功能性,并优化应用程序的启动和配置管理流程。
This commit is contained in:
snaily
2025-05-14 14:25:04 +08:00
parent 67f87989db
commit 4becc8d4d4
27 changed files with 1116 additions and 1000 deletions

View File

@@ -121,9 +121,9 @@ settings = Settings()
def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
"""尝试将数据库字符串值解析为目标 Python 类型"""
from app.log.logger import get_config_logger # 函数内导入
from app.log.logger import get_config_logger
logger = get_config_logger() # 函数内初始化
logger = get_config_logger()
try:
# 处理 List[str]
if target_type == List[str]:
@@ -234,9 +234,9 @@ async def sync_initial_settings():
2. 将数据库设置合并到内存 settings (数据库优先)。
3. 将最终的内存 settings 同步回数据库。
"""
from app.log.logger import get_config_logger # 函数内导入
from app.log.logger import get_config_logger
logger = get_config_logger() # 函数内初始化
logger = get_config_logger()
# 延迟导入以避免循环依赖和确保数据库连接已初始化
from app.database.connection import database
from app.database.models import Settings as SettingsModel
@@ -360,14 +360,14 @@ async def sync_initial_settings():
continue
# 序列化值为字符串或 JSON 字符串
if isinstance(value, (list, dict)): # 处理列表和字典
if isinstance(value, (list, dict)):
db_value = json.dumps(
value, ensure_ascii=False
) # 使用 ensure_ascii=False 以支持非 ASCII 字符
)
elif isinstance(value, bool):
db_value = str(value).lower()
elif value is None: # 处理 None 值
db_value = "" # 或者根据需要设为 NULL 或其他标记
elif value is None:
db_value = ""
else:
db_value = str(value)

View File

@@ -1,35 +1,32 @@
from contextlib import asynccontextmanager
from pathlib import Path # Add pathlib import
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config.config import settings, sync_initial_settings
from app.database.connection import connect_to_db, disconnect_from_db
from app.database.initialization import initialize_database
from app.exception.exceptions import setup_exception_handlers
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.database.connection import connect_to_db, disconnect_from_db
from app.utils.helpers import get_current_version # Import from helpers
from app.database.initialization import initialize_database
from app.scheduler.scheduled_tasks import start_scheduler, stop_scheduler
from app.service.key.key_manager import get_key_manager_instance
from app.service.update.update_service import check_for_updates
from app.utils.helpers import get_current_version # Import from helpers
logger = get_application_logger()
# Define project paths using pathlib
# Assuming this file is at app/core/application.py
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# VERSION_FILE_PATH = PROJECT_ROOT / "VERSION" # Removed: Defined in helpers.py
STATIC_DIR = PROJECT_ROOT / "app" / "static"
TEMPLATES_DIR = PROJECT_ROOT / "app" / "templates"
# Removed _get_current_version function definition, moved to helpers.py
# 初始化模板引擎,并添加全局变量
templates = Jinja2Templates(directory="app/templates")
# 定义一个函数来更新模板全局变量
def update_template_globals(app: FastAPI, update_info: dict):
# Jinja2Templates 实例没有直接更新全局变量的方法
@@ -40,114 +37,105 @@ def update_template_globals(app: FastAPI, update_info: dict):
# --- Helper functions for lifespan ---
async def _setup_database_and_config(app_settings):
"""Initializes database, syncs settings, and initializes KeyManager."""
initialize_database()
await initialize_database()
logger.info("Database initialized successfully")
await connect_to_db()
await sync_initial_settings()
# Initialize KeyManager using potentially updated settings
await get_key_manager_instance(app_settings.API_KEYS)
logger.info("Database, config sync, and KeyManager initialized successfully")
async def _shutdown_database():
"""Disconnects from the database."""
await disconnect_from_db()
def _start_scheduler():
"""Starts the background scheduler."""
try:
start_scheduler()
logger.info("Scheduler started successfully.")
except Exception as e:
logger.error(f"Failed to start scheduler: {e}")
logger.error(f"Failed to start scheduler: {e}")
def _stop_scheduler():
"""Stops the background scheduler."""
stop_scheduler()
async def _perform_update_check(app: FastAPI):
"""Checks for updates and stores the info in app.state."""
update_available, latest_version, error_message = await check_for_updates()
current_version = get_current_version() # Use imported function
current_version = get_current_version()
update_info = {
"update_available": update_available,
"latest_version": latest_version,
"error_message": error_message,
"current_version": current_version
"current_version": current_version,
}
# Ensure app.state exists and store update info
if not hasattr(app, "state"):
from starlette.datastructures import State
app.state = State()
app.state.update_info = update_info
logger.info(f"Update check completed. Info: {update_info}")
# --- Application Lifespan ---
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Manages the application startup and shutdown events.
Args:
app: FastAPI应用实例
"""
# Startup events
logger.info("Application starting up...")
try:
# Setup database, config, and KeyManager
await _setup_database_and_config(settings) # Pass settings object
# Perform update check after core components are ready
# await _perform_update_check(app) # Removed: Version check moved to frontend API call
# Start the scheduler
await _setup_database_and_config(settings)
await _perform_update_check(app)
_start_scheduler()
except Exception as e:
logger.critical(f"Critical error during application startup: {str(e)}", exc_info=True)
# Depending on the severity, you might want to prevent the app from fully starting
# For now, we log critically and let it yield, potentially in a broken state.
# Consider adding more robust error handling here if startup failures should halt the app.
logger.critical(
f"Critical error during application startup: {str(e)}", exc_info=True
)
yield # Application runs
yield
# Shutdown events
logger.info("Application shutting down...")
_stop_scheduler()
await _shutdown_database()
def create_app() -> FastAPI:
"""
创建并配置FastAPI应用程序实例
Returns:
FastAPI: 配置好的FastAPI应用程序实例
"""
# Removed: initialize_app() call
# 创建FastAPI应用
# Read version from file for consistency
current_version = get_current_version() # Use imported function
current_version = get_current_version()
app = FastAPI(
title="Gemini Balance API",
description="Gemini API代理服务支持负载均衡和密钥管理",
version=current_version,
lifespan=lifespan
lifespan=lifespan,
)
# Initialize app.state early to ensure it exists before lifespan potentially uses it
if not hasattr(app, "state"):
from starlette.datastructures import State
app.state = State()
# Set a default/initial state for update_info
app.state.update_info = {
"update_available": False,
"latest_version": None,
"error_message": "Initializing...",
"current_version": current_version # Use version read earlier
"current_version": current_version,
}
# 配置静态文件
@@ -155,11 +143,11 @@ def create_app() -> FastAPI:
# 配置中间件
setup_middlewares(app)
# 配置异常处理器
setup_exception_handlers(app)
# 配置路由
setup_routers(app)
return app

View File

@@ -4,7 +4,6 @@
from pathlib import Path
from databases import Database
from sqlalchemy import create_engine, MetaData
# from sqlalchemy.orm import sessionmaker # 不再需要
from sqlalchemy.ext.declarative import declarative_base
from app.config.config import settings

View File

@@ -42,11 +42,12 @@ class ErrorLog(Base):
def __repr__(self):
return f"<ErrorLog(id='{self.id}', gemini_key='{self.gemini_key}')>"
# 新增 RequestLog 模型
class RequestLog(Base):
"""
API 请求日志表
"""
__tablename__ = "t_request_log"
id = Column(Integer, primary_key=True, autoincrement=True)

View File

@@ -71,7 +71,7 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
.values(
value=value,
description=description if description else setting["description"],
updated_at=datetime.now() # Use datetime.now()
updated_at=datetime.now()
)
)
await database.execute(query)
@@ -85,8 +85,8 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
key=key,
value=value,
description=description,
created_at=datetime.now(), # Use datetime.now()
updated_at=datetime.now() # Use datetime.now()
created_at=datetime.now(),
updated_at=datetime.now()
)
)
await database.execute(query)
@@ -158,8 +158,8 @@ async def get_error_logs(
error_code_search: Optional[str] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
sort_by: str = 'id', # 新增排序字段
sort_order: str = 'desc' # 新增排序顺序 ('asc' or 'desc')
sort_by: str = 'id',
sort_order: str = 'desc'
) -> List[Dict[str, Any]]:
"""
获取错误日志,支持搜索、日期过滤和排序
@@ -200,28 +200,20 @@ async def get_error_logs(
if start_date:
query = query.where(ErrorLog.request_time >= start_date)
if end_date:
# Use the datetime object directly for comparison
query = query.where(ErrorLog.request_time < end_date)
if error_code_search:
try:
# Attempt to convert search string to integer for exact match
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
# If conversion fails, log a warning and potentially skip this filter
# or handle as needed (e.g., return no results for invalid code format)
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
# Optionally, force no results if the format is invalid:
# query = query.where(False) # This ensures no rows are returned
# 添加排序逻辑
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id) # 获取排序字段,默认为 id
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id)
if sort_order.lower() == 'asc':
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
# Apply limit and offset
query = query.limit(limit).offset(offset)
result = await database.fetch_all(query)
@@ -254,7 +246,6 @@ async def get_error_logs_count(
try:
query = select(func.count()).select_from(ErrorLog)
# Apply the same filters as get_error_logs
if key_search:
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
if error_search:
@@ -265,23 +256,19 @@ async def get_error_logs_count(
if start_date:
query = query.where(ErrorLog.request_time >= start_date)
if end_date:
# Use the datetime object directly for comparison
query = query.where(ErrorLog.request_time < end_date)
if error_code_search:
try:
# Attempt to convert search string to integer for exact match
error_code_int = int(error_code_search)
query = query.where(ErrorLog.error_code == error_code_int)
except ValueError:
# If conversion fails, log a warning and potentially skip this filter
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
# Optionally, force count to 0 if the format is invalid:
# return 0 # Or query = query.where(False) before fetching
count_result = await database.fetch_one(query)
return count_result[0] if count_result else 0
except Exception as e:
logger.exception(f"Failed to count error logs with filters: {str(e)}") # Use exception for stack trace
logger.exception(f"Failed to count error logs with filters: {str(e)}")
raise
@@ -315,7 +302,6 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
logger.exception(f"Failed to get error log details for ID {log_id}: {str(e)}")
raise
# --- 异步删除函数 (使用 databases 库) ---
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
"""
@@ -345,7 +331,7 @@ async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
except Exception as e:
# 数据库连接或执行错误
logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True)
raise # Re-raise the exception for the router to handle
raise
async def delete_error_log_by_id(log_id: int) -> bool:
"""
@@ -364,7 +350,7 @@ async def delete_error_log_by_id(log_id: int) -> bool:
if not exists:
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
return False # 或者可以抛出 404 异常,由路由处理
return False
# 执行删除
delete_query = delete(ErrorLog).where(ErrorLog.id == log_id)
@@ -373,9 +359,7 @@ async def delete_error_log_by_id(log_id: int) -> bool:
return True
except Exception as e:
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
raise # Re-raise the exception for the router to handle
# --- RequestLog Services (保持异步) ---
raise
# 新增函数:添加请求日志
async def add_request_log(
@@ -412,7 +396,6 @@ async def add_request_log(
latency_ms=latency_ms
)
await database.execute(query)
# logger.debug(f"Added request log: key={api_key[:4]}..., success={is_success}, model={model_name}") # Use debug level
return True
except Exception as e:
logger.error(f"Failed to add request log: {str(e)}")

View File

@@ -128,12 +128,7 @@ class OpenAIMessageConverter(MessageConverter):
raise ValueError(f"Unsupported media format: {format}")
try:
# Decode Base64 to check size
# Be careful with memory usage for very large files
# Consider streaming decoding or checking length heuristic first if memory is a concern
decoded_data = base64.b64decode(
data, validate=True
) # Use validate=True for stricter check
decoded_data = base64.b64decode(data, validate=True)
if len(decoded_data) > max_size:
logger.error(
f"Media data size ({len(decoded_data)} bytes) exceeds limit ({max_size} bytes)."
@@ -141,7 +136,6 @@ class OpenAIMessageConverter(MessageConverter):
raise ValueError(
f"Media data size exceeds limit of {max_size // 1024 // 1024}MB"
)
# No need to return decoded_data, just the original base64 if valid
return data
except base64.binascii.Error as e:
logger.error(f"Invalid Base64 data provided: {e}")
@@ -163,7 +157,6 @@ class OpenAIMessageConverter(MessageConverter):
if "content" in msg and isinstance(msg["content"], list):
for content_item in msg["content"]:
if not isinstance(content_item, dict):
# Skip non-dict items if any unexpected format appears
logger.warning(
f"Skipping unexpected content item format: {type(content_item)}"
)
@@ -184,13 +177,11 @@ class OpenAIMessageConverter(MessageConverter):
logger.error(
f"Failed to convert image URL {content_item['image_url']['url']}: {e}"
)
# Decide how to handle: skip part, add error text, etc.
parts.append(
{
"text": f"[Error processing image: {content_item['image_url']['url']}]"
}
)
# --- Add handling for input_audio ---
elif content_type == "input_audio" and content_item.get(
"input_audio"
):
@@ -205,7 +196,6 @@ class OpenAIMessageConverter(MessageConverter):
continue
try:
# Validate size and format
validated_data = self._validate_media_data(
audio_format,
audio_data,

View File

@@ -223,3 +223,6 @@ def get_openai_compatible_logger():
def get_error_log_logger():
return Logger.setup_logger("error_log")
def get_request_log_logger():
return Logger.setup_logger("request_log")

View File

@@ -55,7 +55,6 @@ async def reset_config(request: Request):
raise HTTPException(status_code=400, detail=str(e))
# Pydantic model for bulk delete request
class DeleteKeysRequest(BaseModel):
keys: List[str] = Field(..., description="List of API keys to delete")
@@ -70,9 +69,6 @@ async def delete_single_key(key_to_delete: str, request: Request):
logger.info(f"Attempting to delete key: {key_to_delete}")
result = await ConfigService.delete_key(key_to_delete)
if not result.get("success"):
# Optionally, translate specific errors to HTTP status codes
# For now, let's assume 400 for any failure from service if not found,
# or 500 if it was an unexpected error (though service should handle that)
raise HTTPException(
status_code=(
404 if "not found" in result.get("message", "").lower() else 400
@@ -81,7 +77,6 @@ async def delete_single_key(key_to_delete: str, request: Request):
)
return result
except HTTPException as e:
# Re-raise HTTPExceptions directly
raise e
except Exception as e:
logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True)
@@ -104,14 +99,10 @@ async def delete_selected_keys_route(
try:
logger.info(f"Attempting to bulk delete {len(delete_request.keys)} keys.")
result = await ConfigService.delete_selected_keys(delete_request.keys)
# Similar to single delete, we can check result["success"]
if not result.get("success") and result.get("deleted_count", 0) == 0:
# If no keys were actually deleted, it might be a client error (e.g., all keys not found)
# or an empty list was somehow passed despite the check above.
raise HTTPException(
status_code=400, detail=result.get("message", "Failed to delete keys.")
)
# If some keys were deleted but others not found, it's still a partial success, return 200 with details.
return result
except HTTPException as e:
raise e

View File

@@ -209,3 +209,25 @@ async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
raise HTTPException(
status_code=500, detail="Internal server error during deletion"
)
@router.delete("/errors/all", status_code=status.HTTP_204_NO_CONTENT)
async def delete_all_error_logs_api(request: Request):
"""
删除所有错误日志 (异步)
"""
auth_token = request.cookies.get("auth_token")
if not auth_token or not verify_auth_token(auth_token):
logger.warning("Unauthorized access attempt to delete all error logs")
raise HTTPException(status_code=401, detail="Not authenticated")
try:
deleted_count = await error_log_service.process_delete_all_error_logs()
logger.info(f"Successfully deleted all {deleted_count} error logs.")
# No body needed for 204 response
return Response(status_code=status.HTTP_204_NO_CONTENT)
except Exception as e:
logger.exception(f"Error deleting all error logs: {str(e)}")
raise HTTPException(
status_code=500, detail="Internal server error during deletion of all logs"
)

View File

@@ -58,21 +58,18 @@ async def check_failed_keys():
contents=[
GeminiContent(
role="user",
parts=[{"text": "hi"}], # 使用简单的文本进行验证
parts=[{"text": "hi"}],
)
]
)
# 调用 generate_content 进行验证
await chat_service.generate_content(
settings.TEST_MODEL, gemini_request, key # 使用配置中定义的测试模型
settings.TEST_MODEL, gemini_request, key
)
# 如果没有抛出异常,说明 key 有效
logger.info(
f"Key {log_key} verification successful. Resetting failure count."
)
await key_manager.reset_key_failure_count(key)
except Exception as e:
# 验证失败,增加失败计数
logger.warning(
f"Key {log_key} verification failed: {str(e)}. Incrementing failure count."
)

View File

@@ -144,7 +144,7 @@ class GeminiChatService:
"""生成内容"""
payload = _build_payload(model, request)
start_time = time.perf_counter()
request_datetime = datetime.datetime.now() # Record request time
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
@@ -152,20 +152,18 @@ class GeminiChatService:
try:
response = await self.api_client.generate_content(payload, model, api_key)
is_success = True
status_code = 200 # Assume 200 on success
status_code = 200
return self.response_handler.handle_response(response, model, stream=False)
except Exception as e:
is_success = False
error_log_msg = str(e)
logger.error(f"Normal API call failed with error: {error_log_msg}")
# Try to parse status code from exception
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default to 500 if parsing fails
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=api_key,
model_name=model,
@@ -174,11 +172,10 @@ class GeminiChatService:
error_code=status_code,
request_msg=payload
)
raise e # Re-throw exception for upstream handling
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
# Log request to request log table
await add_request_log(
model_name=model,
api_key=api_key,
@@ -240,16 +237,14 @@ class GeminiChatService:
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key, # Log key used for this failed attempt
gemini_key=current_attempt_key,
model_name=model,
error_type="gemini-chat-stream",
error_log=error_log_msg,
@@ -257,28 +252,26 @@ class GeminiChatService:
request_msg=payload
)
# Attempt to switch API Key
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
if api_key:
logger.info(f"Switched to new API key: {api_key}")
else: # No more keys or retries exceeded by handle_api_failure logic
logger.error(f"No valid API key available after {retries} retries.")
break # Exit loop if no key available
else:
logger.error(f"No valid API key available after {retries} retries.")
break
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
break # Exit loop after max retries
break
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
model_name=model,
api_key=final_api_key, # Log the last key used
is_success=is_success, # Log the final success status
status_code=status_code, # Log the last known status code
latency_ms=latency_ms, # Log total time including retries
api_key=final_api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)

View File

@@ -173,7 +173,7 @@ class OpenAIChatService:
self, original_chunk: Dict[str, Any], text: str
) -> Dict[str, Any]:
"""创建包含指定文本的OpenAI响应块"""
chunk_copy = json.loads(json.dumps(original_chunk)) # 深拷贝
chunk_copy = json.loads(json.dumps(original_chunk))
if chunk_copy.get("choices") and "delta" in chunk_copy["choices"][0]:
chunk_copy["choices"][0]["delta"]["content"] = text
return chunk_copy
@@ -184,10 +184,8 @@ class OpenAIChatService:
api_key: str,
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
"""创建聊天完成"""
# 转换消息格式
messages, instruction = self.message_converter.convert(request.messages)
# 构建请求payload
payload = _build_payload(request, messages, instruction)
if request.stream:
@@ -219,7 +217,6 @@ class OpenAIChatService:
is_success = False
error_log_msg = str(e)
logger.error(f"Normal API call failed with error: {error_log_msg}")
# Try to parse status code from exception
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
@@ -587,7 +584,6 @@ class OpenAIChatService:
error_code=status_code,
request_msg={"image_data_truncated": image_data[:1000]},
)
# Re-raise the exception so the caller knows about the failure
raise e
finally:
end_time = time.perf_counter()

View File

@@ -53,16 +53,14 @@ class GeminiApiClient(ApiClient):
url = f"{self.base_url}/models?key={api_key}"
try:
response = await client.get(url)
response.raise_for_status() # 如果状态码不是 2xx则引发 HTTPStatusError
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"获取模型列表失败: {e.response.status_code}")
logger.error(e.response.text)
# 返回 None 而不是抛出异常,以便上层处理
return None
except httpx.RequestError as e:
logger.error(f"请求模型列表失败: {e}")
# 返回 None 而不是抛出异常
return None
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:

View File

@@ -76,7 +76,6 @@ class ConfigService:
}
if key in existing_keys:
# Preserve original description if not explicitly provided
data["description"] = existing_settings_map[key].get(
"description", description
)
@@ -111,7 +110,7 @@ class ConfigService:
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
raise
# 重置并重新初始化 KeyManager
try:
@@ -120,8 +119,6 @@ class ConfigService:
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()
@@ -244,13 +241,11 @@ class ConfigService:
models = await model_service.get_gemini_openai_models(api_key)
return models
except HTTPException as e:
# Re-raise HTTPExceptions directly if they are already specific
raise e
except Exception as e:
logger.error(
f"Failed to fetch models for UI in ConfigService: {e}", exc_info=True
)
# Raise a generic HTTPException for other errors
raise HTTPException(
status_code=500, detail=f"Failed to fetch models for UI: {str(e)}"
)

View File

@@ -1,15 +1,15 @@
import datetime
import time
import re # For potential status code parsing from generic errors
import re
from typing import List, Union
import openai
from openai import APIStatusError # Import specific error type
from openai import APIStatusError
from openai.types import CreateEmbeddingResponse
from app.config.config import settings
from app.log.logger import get_embeddings_logger
from app.database.services import add_error_log, add_request_log # Import DB logging functions
from app.database.services import add_error_log, add_request_log
logger = get_embeddings_logger()
@@ -26,7 +26,6 @@ class EmbeddingService:
status_code = None
response = None
error_log_msg = ""
# Prepare request message for logging (truncate if list or long string)
if isinstance(input_text, list):
request_msg_log = {"input_truncated": [str(item)[:100] + "..." if len(str(item)) > 100 else str(item) for item in input_text[:5]]}
if len(input_text) > 5:
@@ -46,32 +45,29 @@ class EmbeddingService:
status_code = e.status_code
error_log_msg = f"OpenAI API error: {e}"
logger.error(f"Error creating embedding (APIStatusError): {error_log_msg}")
raise e # Re-raise the specific error
raise e
except Exception as e:
is_success = False
error_log_msg = f"Generic error: {e}"
logger.error(f"Error creating embedding (Exception): {error_log_msg}")
# Try to parse status code from generic error (less reliable)
match = re.search(r"status code (\d+)", str(e))
if match:
status_code = int(match.group(1))
else:
status_code = 500 # Default if parsing fails
raise e # Re-raise the generic error
status_code = 500
raise e
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
if not is_success:
# Log error to database if it failed
await add_error_log(
gemini_key=api_key, # Using gemini_key parameter name for consistency
model_name=model,
error_type="openai-embedding",
error_log=error_log_msg,
error_code=status_code,
request_msg=request_msg_log
await add_error_log(
gemini_key=api_key,
model_name=model,
error_type="openai-embedding",
error_log=error_log_msg,
error_code=status_code,
request_msg=request_msg_log
)
# Log request outcome to database regardless of success/failure
await add_request_log(
model_name=model,
api_key=api_key,

View File

@@ -153,3 +153,27 @@ async def process_delete_error_log_by_id(log_id: int) -> bool:
exc_info=True,
)
raise
async def process_delete_all_error_logs() -> int:
"""
处理删除所有错误日志的请求。
返回删除的日志数量。
"""
try:
# 确保数据库已连接 (如果适用,类似于 delete_old_error_logs)
# if not database.is_connected:
# await database.connect()
# logger.info("Database connection established for deleting all error logs.")
deleted_count = await db_services.delete_all_error_logs()
logger.info(
f"Successfully processed request to delete all error logs. Count: {deleted_count}"
)
return deleted_count
except Exception as e:
logger.error(
f"Service error in process_delete_all_error_logs: {e}",
exc_info=True,
)
raise

View File

@@ -137,7 +137,7 @@ class ImageCreateService:
)
response_data = {
"created": int(time.time()), # Current timestamp
"created": int(time.time()),
"data": images_data,
}
return response_data

View File

@@ -60,7 +60,6 @@ class KeyManager:
current_key = await self.get_next_key()
if current_key == initial_key:
# await self.reset_failure_counts() 取消重置
return current_key
async def handle_api_failure(self, api_key: str, retries: int) -> str:
@@ -101,25 +100,12 @@ class KeyManager:
for key in self.key_failure_counts:
if self.key_failure_counts[key] < self.MAX_FAILURES:
return key
# 如果所有 key 都无效,或者列表为空,则尝试返回第一个(如果列表不为空)
# 或者根据具体逻辑处理,这里保持原样,可能在空列表或全无效时需要调整
if self.api_keys:
return self.api_keys[0]
# 如果 api_keys 为空,这里会出问题。实际应用中应有非空保证或更好处理。
# 为了保持接口一致性,如果列表为空,可能应该抛出异常或返回特定值。
# 暂且假设 api_keys 不会为空,或者调用者处理后续的空 key 问题。
# 根据现有代码如果api_keys为空self.api_keys[0]会报错。
# 如果没有有效key且列表不空返回第一个。若列表为空这里会出IndexError。
# 更安全的做法是:
if not self.api_keys:
logger.warning("API key list is empty, cannot get first valid key.")
# Depending on desired behavior, either raise error or return an indicator like "" or None
# For now, let's allow it to potentially fail if a key is expected by caller
# but it's better to be explicit. Let's return empty string for consistency with handle_api_failure
return ""
return self.api_keys[
0
] # Fallback to the first key if no key is "valid" but list is not empty
return self.api_keys[0]
_singleton_instance = None
@@ -142,20 +128,13 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
async with _singleton_lock:
if _singleton_instance is None:
if api_keys is None:
# This case needs careful handling. If it's the very first call, api_keys are required.
# If it's after a reset and no api_keys are provided, what should happen?
# The original ValueError was "API keys are required to initialize the KeyManager".
# Let's assume if api_keys is None here, it's an error unless we are restoring from non-None _preserved_old_api_keys_for_reset.
# However, the user's request implies new api_keys will be part of the reset flow.
# For now, stick to a strict requirement for api_keys if _singleton_instance is None.
raise ValueError(
"API keys are required to initialize or re-initialize the KeyManager instance."
)
if not api_keys: # Handle case where api_keys is an empty list
if not api_keys:
logger.warning(
"Initializing KeyManager with an empty list of API keys."
)
# Consider if this should be an error or allowed. Current KeyManager supports it.
_singleton_instance = KeyManager(api_keys)
logger.info(
@@ -164,33 +143,28 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
# 1. 恢复失败计数
if _preserved_failure_counts:
# Initialize new instance's failure_counts for all new keys to 0
current_failure_counts = {
key: 0 for key in _singleton_instance.api_keys
}
# Inherit counts for keys that exist in both old and new lists
for key, count in _preserved_failure_counts.items():
if key in current_failure_counts:
current_failure_counts[key] = count
_singleton_instance.key_failure_counts = current_failure_counts
logger.info("Inherited failure counts for applicable keys.")
_preserved_failure_counts = None # Clear after use
_preserved_failure_counts = None
# 2. 调整 key_cycle 的起始点
start_key_for_new_cycle = None
if (
_preserved_old_api_keys_for_reset
and _preserved_next_key_in_cycle
and _singleton_instance.api_keys # Ensure new api_keys list is not empty
and _singleton_instance.api_keys
):
try:
# Find the index of the preserved next key in the *old* list
start_idx_in_old = _preserved_old_api_keys_for_reset.index(
_preserved_next_key_in_cycle
)
# Iterate through the old key list (circularly) starting from _preserved_next_key_in_cycle
# Find the first key that also exists in the new api_keys list
for i in range(len(_preserved_old_api_keys_for_reset)):
current_old_key_idx = (start_idx_in_old + i) % len(
_preserved_old_api_keys_for_reset
@@ -214,26 +188,22 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
if start_key_for_new_cycle and _singleton_instance.api_keys:
try:
# Find the index of the determined start_key in the new api_keys list
target_idx = _singleton_instance.api_keys.index(
start_key_for_new_cycle
)
# Advance the new cycle by calling next() target_idx times
# This positions the cycle so that the *next* call to next() will yield start_key_for_new_cycle
for _ in range(target_idx):
next(_singleton_instance.key_cycle)
logger.info(
f"Key cycle in new instance advanced. Next call to get_next_key() will yield: {start_key_for_new_cycle}"
)
except ValueError:
# This should not happen if start_key_for_new_cycle was correctly found in api_keys
logger.warning(
f"Determined start key '{start_key_for_new_cycle}' not found in new API keys during cycle advancement. "
"New cycle will start from the beginning."
)
except (
StopIteration
): # Should not happen with cycle unless api_keys is empty, handled by _singleton_instance.api_keys check
):
logger.error(
"StopIteration while advancing key cycle, implies empty new API key list previously missed."
)
@@ -254,7 +224,6 @@ async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
# 清理所有保存的状态
_preserved_old_api_keys_for_reset = None
_preserved_next_key_in_cycle = None
# _preserved_failure_counts already cleared
return _singleton_instance
@@ -275,21 +244,16 @@ async def reset_key_manager_instance():
_preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
# 3. 保存 key_cycle 的下一个 key 提示
# This should be the key that get_next_key() would return next.
try:
if (
_singleton_instance.api_keys
): # Only if there are keys to cycle through
# Calling get_next_key() consumes one key and returns it. This is the key
# we want the new cycle to effectively start with.
if _singleton_instance.api_keys:
_preserved_next_key_in_cycle = (
await _singleton_instance.get_next_key()
)
else:
_preserved_next_key_in_cycle = None # No keys, so no next key
_preserved_next_key_in_cycle = None
except (
StopIteration
): # Should be caught by "if _singleton_instance.api_keys"
):
logger.warning(
"Could not preserve next key hint: key cycle was empty or exhausted in old instance."
)

View File

@@ -10,8 +10,7 @@ logger = get_model_logger()
class ModelService:
async def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
"""使用 GeminiApiClient 获取并过滤模型列表"""
api_client = GeminiApiClient(base_url=settings.BASE_URL) # 实例化客户端
api_client = GeminiApiClient(base_url=settings.BASE_URL)
gemini_models = await api_client.get_models(api_key)
if gemini_models is None:

View File

@@ -79,7 +79,6 @@ class OpenAICompatiableService:
is_success = False
error_log_msg = str(e)
logger.error(f"Normal API call failed with error: {error_log_msg}")
# Try to parse status code from exception
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
@@ -140,14 +139,12 @@ class OpenAICompatiableService:
logger.warning(
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
)
# Parse error code for logging
match = re.search(r"status code (\d+)", error_log_msg)
if match:
status_code = int(match.group(1))
else:
status_code = 500
# Log error to error log table
await add_error_log(
gemini_key=current_attempt_key,
model_name=model,
@@ -157,8 +154,6 @@ class OpenAICompatiableService:
request_msg=payload,
)
# Attempt to switch API Key
# Ensure key_manager is available (might need adjustment if not always passed)
if self.key_manager:
api_key = await self.key_manager.handle_api_failure(
current_attempt_key, retries
@@ -178,7 +173,6 @@ class OpenAICompatiableService:
logger.error(f"Max retries ({max_retries}) reached for streaming.")
break
finally:
# Log the final outcome of the streaming request
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
await add_request_log(
@@ -189,7 +183,6 @@ class OpenAICompatiableService:
latency_ms=latency_ms,
request_time=request_datetime,
)
# If the loop finished due to failure, yield error and DONE
if not is_success and retries >= max_retries:
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
yield "data: [DONE]\n\n"

View File

@@ -6,12 +6,12 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import delete
from app import database
from app.database.connection import database
from app.config.config import settings
from app.database.models import RequestLog
from app.log.logger import Logger
from app.log.logger import get_request_log_logger
logger = Logger.setup_logger("request_log_service")
logger = get_request_log_logger()
async def delete_old_request_logs_task():

View File

@@ -41,7 +41,7 @@ class StatsService:
),
1,
),
(RequestLog.status_code is None, 1), # type: ignore
(RequestLog.status_code is None, 1),
else_=0,
)
).label("failure"),
@@ -96,7 +96,7 @@ class StatsService:
),
1,
),
(RequestLog.status_code is None, 1), # type: ignore
(RequestLog.status_code is None, 1),
else_=0,
)
).label("failure"),
@@ -166,25 +166,24 @@ class StatsService:
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
RequestLog.status_code,
)
.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 = "failure" # 默认状态为 failure如果 status_code 有效且在 200-299 范围内则更新为 success
if row["status_code"] is not None: # 检查 status_code 是否为空
status = "failure"
if row["status_code"] is not None:
status = "success" if 200 <= row["status_code"] < 300 else "failure"
details.append(
{
"timestamp": row[
"timestamp"
].isoformat(), # Use ISO format for JS compatibility
].isoformat(),
"key": row["key"],
"model": row["model"],
"status": status,
@@ -197,7 +196,6 @@ class StatsService:
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
async def get_key_usage_details_last_24h(self, key: str) -> dict | None:
@@ -225,10 +223,10 @@ class StatsService:
.where(
RequestLog.api_key == key,
RequestLog.request_time >= cutoff_time,
RequestLog.model_name.isnot(None), # Ensure model_name is not null
RequestLog.model_name.isnot(None),
)
.group_by(RequestLog.model_name)
.order_by(func.count(RequestLog.id).desc()) # Order by count descending
.order_by(func.count(RequestLog.id).desc())
)
results = await database.fetch_all(query)
@@ -237,7 +235,7 @@ class StatsService:
logger.info(
f"No usage details found for key ending in ...{key[-4:]} in the last 24h."
)
return {} # Return empty dict if no records found
return {}
usage_details = {row["model_name"]: row["call_count"] for row in results}
logger.info(
@@ -250,6 +248,4 @@ class StatsService:
f"Failed to get key usage details for key ending in ...{key[-4:]}: {e}",
exc_info=True,
)
# Depending on requirements, you might return None or raise the exception
# Raising allows the route handler to return a 500 error.
raise # Re-raise the exception
raise

View File

@@ -7,11 +7,7 @@ from app.log.logger import get_update_logger
logger = get_update_logger()
# GitHub repository details are read from settings (defined in app/config/config.py or environment variables)
# GITHUB_API_URL will be constructed inside the function to ensure settings are loaded
VERSION_FILE_PATH = "VERSION" # Path relative to project root
VERSION_FILE_PATH = "VERSION"
async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
"""
@@ -24,9 +20,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
- Optional[str]: 如果检查失败,则为错误消息,否则为 None。
"""
try:
# Read current version from VERSION file
# Ensure the path is correct relative to the execution context or use absolute path if needed
# Assuming execution from project root d:/develop/pythonProjects/gemini-balance
with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f:
current_v = f.read().strip()
if not current_v:
@@ -41,25 +34,22 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
logger.info(f"当前应用程序版本 (from {VERSION_FILE_PATH}): {current_v}")
# Check if repository details are configured in settings
if not settings.GITHUB_REPO_OWNER or not settings.GITHUB_REPO_NAME or \
settings.GITHUB_REPO_OWNER == "your_owner" or settings.GITHUB_REPO_NAME == "your_repo":
logger.warning("GitHub repository owner/name not configured in settings. Skipping update check.")
return False, None, "Update check skipped: Repository not configured in settings."
# Construct the API URL inside the function to ensure settings are loaded
github_api_url = f"https://api.github.com/repos/{settings.GITHUB_REPO_OWNER}/{settings.GITHUB_REPO_NAME}/releases/latest"
logger.debug(f"Checking for updates at URL: {github_api_url}") # Log the URL for debugging
logger.debug(f"Checking for updates at URL: {github_api_url}")
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# 添加 User-Agent 头GitHub API 可能需要
headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": f"{settings.GITHUB_REPO_NAME}-UpdateChecker/1.0" # Use repo name from settings for User-Agent
"User-Agent": f"{settings.GITHUB_REPO_NAME}-UpdateChecker/1.0"
}
response = await client.get(github_api_url, headers=headers) # Use the locally constructed URL
response.raise_for_status() # 对错误的 HTTP 状态码4xx 或 5xx抛出异常
response = await client.get(github_api_url, headers=headers)
response.raise_for_status()
latest_release = response.json()
latest_v_str = latest_release.get("tag_name")
@@ -68,7 +58,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
logger.warning("在最新的 GitHub release 响应中找不到 'tag_name'")
return False, None, "无法从 GitHub 解析最新版本。"
# 移除 tag 名称中可能存在的 'v' 前缀
if latest_v_str.startswith('v'):
latest_v_str = latest_v_str[1:]
@@ -98,8 +87,6 @@ async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]:
logger.error(f"检查更新时发生网络错误: {e}")
return False, None, "更新检查期间发生网络错误。"
except version.InvalidVersion:
# Note: latest_v_str might not be defined if the error occurs before fetching it.
# Consider adding a check or default value for logging.
latest_v_str_for_log = latest_v_str if 'latest_v_str' in locals() else 'N/A'
logger.error(f"发现无效的版本格式。当前 (from {VERSION_FILE_PATH}): '{current_v}', 最新: '{latest_v_str_for_log}'")
return False, None, "遇到无效的版本格式。"

File diff suppressed because it is too large Load Diff

View File

@@ -133,41 +133,69 @@ endblock %} {% block head_extra_styles %}
font-weight: 500;
}
/* Pagination custom styles */
.pagination li a, .pagination li span { /* Assuming 'span' might be used for non-clickable items like '...' */
display: flex; /* For centering content if icons are used */
/* New Pagination Styles (inspired by keys_status.html) */
ul.pagination a {
/* Targets the <a> tags directly within ul.pagination */
display: inline-flex; /* Consistent with flex from addPaginationLink */
align-items: center;
justify-content: center;
padding: 0.5rem 0.75rem; /* Adjust padding as needed */
line-height: 1.25;
color: #e2e8f0; /* Light gray/white text */
background-color: rgba(107, 70, 193, 0.4); /* Consistent with other buttons */
border: 1px solid rgba(120, 100, 200, 0.6); /* Consistent with other buttons */
border-radius: 0.375rem; /* Tailwind's rounded-md */
transition: all 0.2s ease-in-out;
min-width: 36px; /* Ensure minimum width for small numbers */
text-align: center;
}
.pagination li a:hover, .pagination li span:hover:not(.disabled) { /* Avoid hover on disabled spans */
/* Tailwind classes from JS will handle padding, border-radius, font-size, transition */
/* Defaults for non-active, non-disabled, non-hover buttons */
background-color: rgba(80, 60, 160, 0.8);
color: #ffffff;
background-color: rgba(120, 100, 200, 0.6); /* Consistent with other button hovers */
border-color: rgba(167, 139, 250, 0.8);
border: 1px solid rgba(120, 100, 200, 0.4);
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
min-width: 36px; /* Retain from original error_logs for consistency */
text-align: center; /* Retain from original error_logs for consistency */
/* Ensure base transition if not fully handled by JS's Tailwind classes */
transition: background-color 0.15s ease-in-out,
border-color 0.15s ease-in-out, color 0.15s ease-in-out;
}
.pagination li.active a, .pagination li.active span { /* Assuming 'active' class for current page */
color: #ffffff !important;
background-color: #7c3aed !important; /* Violet-600, ensure it overrides */
border-color: #7c3aed !important;
ul.pagination a:hover:not(.active):not(.disabled) {
/* Hover for non-active, non-disabled */
background-color: rgba(
100,
80,
180,
0.9
); /* Slightly lighter/more interactive purple */
border-color: rgba(140, 120, 220, 0.7);
color: #ffffff;
}
ul.pagination a.active {
/* Active state */
background-color: rgba(120, 100, 200, 0.9);
border-color: rgba(150, 130, 230, 0.7);
color: #ffffff; /* Ensure text is white */
font-weight: 600; /* Make active page number bolder */
cursor: default;
}
.pagination li.disabled a, .pagination li.disabled span { /* Assuming 'disabled' class */
color: rgba(226, 232, 240, 0.6) !important;
background-color: rgba(80, 60, 160, 0.3) !important; /* Slightly more visible than pure disabled */
border-color: rgba(120, 100, 200, 0.4) !important;
ul.pagination a.disabled {
/* Disabled state for '...' or prev/next unavailable */
background-color: rgba(
80,
60,
160,
0.3
) !important; /* Use existing disabled bg */
color: rgba(
226,
232,
240,
0.6
) !important; /* Use existing disabled text color */
border-color: rgba(
120,
100,
200,
0.4
) !important; /* Use existing disabled border color */
cursor: not-allowed;
pointer-events: none;
text-shadow: none;
}
</style>
{% endblock %} {% block content %}
@@ -303,6 +331,13 @@ endblock %} {% block head_extra_styles %}
>
<i class="fas fa-trash-alt mr-1.5"></i>删除
</button>
<button
id="deleteAllLogsBtn"
class="flex items-center justify-center px-4 py-1.5 bg-red-700 hover:bg-red-800 text-white rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md whitespace-nowrap"
title="清空所有错误日志"
>
<i class="fas fa-dumpster-fire mr-1.5"></i>清空全部
</button>
</div>
</div>
@@ -323,15 +358,20 @@ endblock %} {% block head_extra_styles %}
class="form-checkbox h-4 w-4 text-violet-500 border-gray-500 rounded focus:ring-violet-500 bg-transparent"
/>
</th>
<th class="px-5 py-3 font-semibold cursor-pointer" id="sortById">
<th
class="px-5 py-3 font-semibold text-white cursor-pointer"
id="sortById"
>
ID <i class="fas fa-sort ml-1"></i>
</th>
<th class="px-5 py-3 font-semibold">Gemini密钥</th>
<th class="px-5 py-3 font-semibold">错误类型</th>
<th class="px-5 py-3 font-semibold">错误码</th>
<th class="px-5 py-3 font-semibold">模型名称</th>
<th class="px-5 py-3 font-semibold">请求时间</th>
<th class="px-5 py-3 font-semibold rounded-tr-lg text-center">
<th class="px-5 py-3 font-semibold text-white">Gemini密钥</th>
<th class="px-5 py-3 font-semibold text-white">错误类型</th>
<th class="px-5 py-3 font-semibold text-white">错误码</th>
<th class="px-5 py-3 font-semibold text-white">模型名称</th>
<th class="px-5 py-3 font-semibold text-white">请求时间</th>
<th
class="px-5 py-3 font-semibold text-white rounded-tr-lg text-center"
>
操作
</th>
</tr>

View File

@@ -7,15 +7,12 @@ import base64
import requests
from typing import Dict, Any, List, Optional, Tuple
from pathlib import Path
import logging # Import logging
import logging
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
# Define logger for helper functions if needed, or use specific loggers
helper_logger = logging.getLogger("app.utils") # Or use a more specific logger if available
helper_logger = logging.getLogger("app.utils")
# Define project root and version file path here for get_current_version
# Assuming this file is at app/utils/helpers.py
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
@@ -159,9 +156,8 @@ def is_valid_api_key(key: str) -> bool:
def get_current_version(default_version: str = "0.0.0") -> str:
"""Reads the current version from the VERSION file."""
version_file = VERSION_FILE_PATH # Use Path object defined above
version_file = VERSION_FILE_PATH
try:
# Use Path object's open method
with version_file.open('r', encoding='utf-8') as f:
version = f.read().strip()
if not version:

View File

@@ -9,8 +9,7 @@ uvicorn
google-genai
jinja2
python-multipart
cryptography # 支持 MySQL 8+ caching_sha2_password 验证
# 数据库相关依赖
cryptography
pymysql
sqlalchemy
aiomysql