From c254077a663f11b72fc05494c16b84d87bd1fe93 Mon Sep 17 00:00:00 2001 From: snaily Date: Sat, 19 Apr 2025 23:45:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(update):=20=E5=AE=9E=E7=8E=B0=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=86=85=E6=9B=B4=E6=96=B0=E6=A3=80=E6=9F=A5=E5=92=8C?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `VERSION` 文件用于跟踪当前应用版本 (当前为 2.0.7)。 - 创建 `app/service/update/update_service.py` 服务,用于: - 从 `VERSION` 文件读取当前版本。 - 通过 GitHub API 获取指定仓库 (`GITHUB_REPO_OWNER`/`GITHUB_REPO_NAME`) 的最新 Release Tag。 - 使用 `packaging` 库比较版本,判断是否有可用更新。 - 在应用启动 (`app/core/application.py`) 时异步调用更新检查服务。 - 将当前版本和更新检查结果(是否可用、最新版本号、错误信息)存储在 `app.state.update_info` 中,供模板使用。 - 在基础模板 (`app/templates/base.html`) 的页脚动态显示当前版本。 - 如果检测到新版本,在页脚显示更新提示和指向最新 Release 的链接。 - 如果更新检查失败,在页脚显示错误提示。 - 在 `app/config/config.py` 中添加 `GITHUB_REPO_OWNER` 和 `GITHUB_REPO_NAME` 配置项,并提供默认值。 - 在 `requirements.txt` 中添加 `packaging` 依赖。 - 添加 `update_service` 专用的 logger (`app/log/logger.py`)。 - 改进配置编辑器 (`config_editor.js`, `config_editor.html`): - 限制预算输入框 (`budget_map`) 的值在 0 到 24576 之间。 - 移除了预算映射项的删除按钮(预算项应随模型列表自动增删)。 - 更新了预算输入的提示文本。 --- VERSION | 1 + app/config/config.py | 5 +- app/core/application.py | 69 +++++++++++++++-- app/log/logger.py | 6 +- app/service/update/update_service.py | 108 +++++++++++++++++++++++++++ app/static/js/config_editor.js | 27 ++++--- app/templates/base.html | 14 ++++ app/templates/config_editor.html | 2 +- requirements.txt | 2 + 9 files changed, 217 insertions(+), 17 deletions(-) create mode 100644 VERSION create mode 100644 app/service/update/update_service.py diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6a0ca2d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.0.7 \ No newline at end of file diff --git a/app/config/config.py b/app/config/config.py index a2099b6..54d9ad0 100644 --- a/app/config/config.py +++ b/app/config/config.py @@ -21,7 +21,6 @@ from app.log.logger import Logger class Settings(BaseSettings): - """应用程序配置""" # 数据库配置 MYSQL_HOST: str MYSQL_PORT: int @@ -69,6 +68,10 @@ class Settings(BaseSettings): # 调度器配置 CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时 TIMEZONE: str = "Asia/Shanghai" # 默认时区 + + # github + GITHUB_REPO_OWNER: str = "snailyp" + GITHUB_REPO_NAME: str = "gemini-balance" # 日志配置 LOG_LEVEL: str = "INFO" # 默认日志级别 diff --git a/app/core/application.py b/app/core/application.py index d465306..ee3864a 100644 --- a/app/core/application.py +++ b/app/core/application.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager 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.log.logger import get_application_logger @@ -15,9 +16,41 @@ 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 from app.scheduler.key_checker import start_scheduler, stop_scheduler # 导入调度器函数 +from app.service.update.update_service import check_for_updates # 导入更新检查服务 logger = get_application_logger() +VERSION_FILE_PATH = "VERSION" # Path relative to project root + +def _get_current_version(default_version: str = "0.0.0") -> str: + """Reads the current version from the VERSION file.""" + try: + # Assuming execution from project root d:/develop/pythonProjects/gemini-balance + with open(VERSION_FILE_PATH, 'r', encoding='utf-8') as f: + version = f.read().strip() + if not version: + logger.warning(f"VERSION file ('{VERSION_FILE_PATH}') is empty. Using default version '{default_version}'.") + return default_version + return version + except FileNotFoundError: + logger.warning(f"VERSION file not found at '{VERSION_FILE_PATH}'. Using default version '{default_version}'.") + return default_version + except IOError as e: + logger.error(f"Error reading VERSION file ('{VERSION_FILE_PATH}'): {e}. Using default version '{default_version}'.") + return default_version + +# 初始化模板引擎,并添加全局变量 +templates = Jinja2Templates(directory="app/templates") + +# 定义一个函数来更新模板全局变量 +def update_template_globals(app: FastAPI, update_info: dict): + # Jinja2Templates 实例没有直接更新全局变量的方法 + # 我们需要在请求上下文中传递这些变量,或者修改 Jinja 环境 + # 更简单的方法是将其存储在 app.state 中,并在渲染时传递 + app.state.update_info = update_info + logger.info(f"Update info stored in app.state: {update_info}") + + @asynccontextmanager async def lifespan(app: FastAPI): """ @@ -44,11 +77,29 @@ async def lifespan(app: FastAPI): logger.info("KeyManager initialized successfully") except Exception as e: logger.error(f"Failed to initialize application: {str(e)}") - raise + # 不重新抛出,允许应用继续运行,但记录错误 + # raise # 取消注释以在初始化失败时停止应用 + + # 检查更新 (在核心初始化之后) + update_available, latest_version, error_message = await check_for_updates() + update_info = { + "update_available": update_available, + "latest_version": latest_version, + "error_message": error_message, + "current_version": _get_current_version() # Read from VERSION file + } + # 将更新信息存储在 app.state 中 + app.state.update_info = update_info + logger.info(f"Update check completed. Info: {update_info}") + + + # 启动调度器 (如果初始化成功) + try: + start_scheduler() + logger.info("Scheduler started successfully.") + except Exception as e: + logger.error(f"Failed to start scheduler: {e}") - # 启动调度器 - start_scheduler() - logger.info("Scheduler started successfully.") yield # 应用程序运行期间 @@ -79,7 +130,15 @@ def create_app() -> FastAPI: version="1.0.0", lifespan=lifespan ) - + + # 初始化 app.state (如果尚未存在) + if not hasattr(app, "state"): + from starlette.datastructures import State + app.state = State() + # 确保 update_info 即使在 lifespan 之前访问也不会出错 + app.state.update_info = {"update_available": False, "latest_version": None, "error_message": "Checking...", "current_version": _get_current_version()} # Read from VERSION file for initial state + + # 配置静态文件 app.mount("/static", StaticFiles(directory="app/static"), name="static") diff --git a/app/log/logger.py b/app/log/logger.py index 1ccdbb1..13fa35c 100644 --- a/app/log/logger.py +++ b/app/log/logger.py @@ -199,4 +199,8 @@ def get_log_routes_logger(): def get_stats_logger(): - return Logger.setup_logger("stats") \ No newline at end of file + return Logger.setup_logger("stats") + + +def get_update_logger(): + return Logger.setup_logger("update_service") \ No newline at end of file diff --git a/app/service/update/update_service.py b/app/service/update/update_service.py new file mode 100644 index 0000000..1181702 --- /dev/null +++ b/app/service/update/update_service.py @@ -0,0 +1,108 @@ +import httpx +from packaging import version +from typing import Optional, Tuple + +from app.config.config import settings +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 + +async def check_for_updates() -> Tuple[bool, Optional[str], Optional[str]]: + """ + 通过比较当前版本与最新的 GitHub release 来检查应用程序更新。 + + Returns: + Tuple[bool, Optional[str], Optional[str]]: 一个元组,包含: + - bool: 如果有可用更新则为 True,否则为 False。 + - Optional[str]: 如果有可用更新,则为最新的版本字符串,否则为 None。 + - 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: + logger.error(f"VERSION file ('{VERSION_FILE_PATH}') is empty.") + return False, None, f"VERSION file ('{VERSION_FILE_PATH}') is empty." + except FileNotFoundError: + logger.error(f"VERSION file not found at '{VERSION_FILE_PATH}'. Make sure it exists in the project root.") + return False, None, f"VERSION file not found at '{VERSION_FILE_PATH}'." + except IOError as e: + logger.error(f"Error reading VERSION file ('{VERSION_FILE_PATH}'): {e}") + return False, None, f"Error reading VERSION file ('{VERSION_FILE_PATH}')." + + 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 + + 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 + } + response = await client.get(github_api_url, headers=headers) # Use the locally constructed URL + response.raise_for_status() # 对错误的 HTTP 状态码(4xx 或 5xx)抛出异常 + + latest_release = response.json() + latest_v_str = latest_release.get("tag_name") + + if not latest_v_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:] + + logger.info(f"在 GitHub 上找到的最新版本: {latest_v_str}") + + # 比较版本 + current_version = version.parse(current_v) + latest_version = version.parse(latest_v_str) + + if latest_version > current_version: + logger.info(f"有可用更新: {current_v} -> {latest_v_str}") + return True, latest_v_str, None + else: + logger.info("应用程序已是最新版本。") + return False, None, None + + except httpx.HTTPStatusError as e: + logger.error(f"检查更新时发生 HTTP 错误: {e.response.status_code} - {e.response.text}") + # 避免向用户显示详细的错误文本 + error_msg = f"获取更新信息失败 (HTTP {e.response.status_code})。" + if e.response.status_code == 404: + error_msg += " 请检查仓库名称是否正确或仓库是否有发布版本。" + elif e.response.status_code == 403: + error_msg += " API 速率限制或权限问题。" + return False, None, error_msg + except httpx.RequestError as e: + 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, "遇到无效的版本格式。" + except Exception as e: + logger.error(f"更新检查期间发生意外错误: {e}", exc_info=True) + return False, None, "发生意外错误。" \ No newline at end of file diff --git a/app/static/js/config_editor.js b/app/static/js/config_editor.js index 91bcd7c..aa186e4 100644 --- a/app/static/js/config_editor.js +++ b/app/static/js/config_editor.js @@ -702,21 +702,30 @@ function createAndAppendBudgetMapItem(mapKey, mapValue, modelId) { valueInput.value = isNaN(intValue) ? 0 : intValue; valueInput.placeholder = '预算 (整数)'; valueInput.className = 'map-value-input w-24 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50'; + valueInput.min = 0; // 添加最小值 + valueInput.max = 24576; // 添加最大值 valueInput.addEventListener('input', function() { - this.value = this.value.replace(/[^0-9]/g, ''); // Remove non-digit characters + // 限制输入为0到24576之间的整数 + let value = this.value.replace(/[^0-9]/g, ''); + if (value !== '') { + value = parseInt(value, 10); + if (value < 0) value = 0; + if (value > 24576) value = 24576; + } + this.value = value; }); - // Remove Button - Disabled - const removeBtn = document.createElement('button'); - removeBtn.type = 'button'; - removeBtn.className = 'remove-btn text-gray-300 cursor-not-allowed focus:outline-none'; - removeBtn.innerHTML = ''; - removeBtn.title = '请从上方模型列表删除'; - removeBtn.disabled = true; + // Remove Button - Removed for budget map items + // const removeBtn = document.createElement('button'); + // removeBtn.type = 'button'; + // removeBtn.className = 'remove-btn text-gray-300 cursor-not-allowed focus:outline-none'; // Kept original class for reference + // removeBtn.innerHTML = ''; + // removeBtn.title = '请从上方模型列表删除'; + // removeBtn.disabled = true; mapItem.appendChild(keyInput); mapItem.appendChild(valueInput); - mapItem.appendChild(removeBtn); + // mapItem.appendChild(removeBtn); // Do not append the remove button container.appendChild(mapItem); } diff --git a/app/templates/base.html b/app/templates/base.html index 715693b..5e849d3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -196,6 +196,20 @@ GitHub + {% if request and request.app.state.update_info %} + {% set update_info = request.app.state.update_info %} + | + v{{ update_info.current_version }} + {% if update_info.update_available %} + | + + 新版本: v{{ update_info.latest_version }} + + {% elif update_info.error_message and update_info.error_message != 'Checking...' %} + | + 更新检查失败 + {% endif %} + {% endif %} diff --git a/app/templates/config_editor.html b/app/templates/config_editor.html index e34e9cd..4758a0b 100644 --- a/app/templates/config_editor.html +++ b/app/templates/config_editor.html @@ -293,7 +293,7 @@ 添加预算映射 --> - 为每个思考模型设置预算(整数),此项与上方模型列表自动关联。 + 为每个思考模型设置预算(整数,最大值 24576),此项与上方模型列表自动关联。 diff --git a/requirements.txt b/requirements.txt index 9adc719..2efdaee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,3 +17,5 @@ aiomysql databases python-dotenv apscheduler # 添加定时任务库 + +packaging