feat(update): 实现应用内更新检查和版本显示

- 新增 `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 之间。
    - 移除了预算映射项的删除按钮(预算项应随模型列表自动增删)。
    - 更新了预算输入的提示文本。
This commit is contained in:
snaily
2025-04-19 23:45:33 +08:00
parent ef4a528611
commit c254077a66
9 changed files with 217 additions and 17 deletions
+1
View File
@@ -0,0 +1 @@
2.0.7
+4 -1
View File
@@ -21,7 +21,6 @@ from app.log.logger import Logger
class Settings(BaseSettings): class Settings(BaseSettings):
"""应用程序配置"""
# 数据库配置 # 数据库配置
MYSQL_HOST: str MYSQL_HOST: str
MYSQL_PORT: int MYSQL_PORT: int
@@ -70,6 +69,10 @@ class Settings(BaseSettings):
CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时 CHECK_INTERVAL_HOURS: int = 1 # 默认检查间隔为1小时
TIMEZONE: str = "Asia/Shanghai" # 默认时区 TIMEZONE: str = "Asia/Shanghai" # 默认时区
# github
GITHUB_REPO_OWNER: str = "snailyp"
GITHUB_REPO_NAME: str = "gemini-balance"
# 日志配置 # 日志配置
LOG_LEVEL: str = "INFO" # 默认日志级别 LOG_LEVEL: str = "INFO" # 默认日志级别
+63 -4
View File
@@ -4,6 +4,7 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config.config import settings, sync_initial_settings from app.config.config import settings, sync_initial_settings
from app.log.logger import get_application_logger 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.connection import connect_to_db, disconnect_from_db
from app.database.initialization import initialize_database from app.database.initialization import initialize_database
from app.scheduler.key_checker import start_scheduler, stop_scheduler # 导入调度器函数 from app.scheduler.key_checker import start_scheduler, stop_scheduler # 导入调度器函数
from app.service.update.update_service import check_for_updates # 导入更新检查服务
logger = get_application_logger() 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
""" """
@@ -44,11 +77,29 @@ async def lifespan(app: FastAPI):
logger.info("KeyManager initialized successfully") logger.info("KeyManager initialized successfully")
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize application: {str(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 # 应用程序运行期间 yield # 应用程序运行期间
@@ -80,6 +131,14 @@ def create_app() -> FastAPI:
lifespan=lifespan 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") app.mount("/static", StaticFiles(directory="app/static"), name="static")
+4
View File
@@ -200,3 +200,7 @@ def get_log_routes_logger():
def get_stats_logger(): def get_stats_logger():
return Logger.setup_logger("stats") return Logger.setup_logger("stats")
def get_update_logger():
return Logger.setup_logger("update_service")
+108
View File
@@ -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, "发生意外错误。"
+18 -9
View File
@@ -702,21 +702,30 @@ function createAndAppendBudgetMapItem(mapKey, mapValue, modelId) {
valueInput.value = isNaN(intValue) ? 0 : intValue; valueInput.value = isNaN(intValue) ? 0 : intValue;
valueInput.placeholder = '预算 (整数)'; 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.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() { 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 // Remove Button - Removed for budget map items
const removeBtn = document.createElement('button'); // const removeBtn = document.createElement('button');
removeBtn.type = 'button'; // removeBtn.type = 'button';
removeBtn.className = 'remove-btn text-gray-300 cursor-not-allowed focus:outline-none'; // removeBtn.className = 'remove-btn text-gray-300 cursor-not-allowed focus:outline-none'; // Kept original class for reference
removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>'; // removeBtn.innerHTML = '<i class="fas fa-trash-alt"></i>';
removeBtn.title = '请从上方模型列表删除'; // removeBtn.title = '请从上方模型列表删除';
removeBtn.disabled = true; // removeBtn.disabled = true;
mapItem.appendChild(keyInput); mapItem.appendChild(keyInput);
mapItem.appendChild(valueInput); mapItem.appendChild(valueInput);
mapItem.appendChild(removeBtn); // mapItem.appendChild(removeBtn); // Do not append the remove button
container.appendChild(mapItem); container.appendChild(mapItem);
} }
+14
View File
@@ -196,6 +196,20 @@
<a href="https://github.com/snailyp/gemini-balance" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300"> <a href="https://github.com/snailyp/gemini-balance" target="_blank" class="text-primary-600 hover:text-primary-800 transition duration-300">
<i class="fab fa-github"></i> GitHub <i class="fab fa-github"></i> GitHub
</a> </a>
{% if request and request.app.state.update_info %}
{% set update_info = request.app.state.update_info %}
<span class="mx-1">|</span>
<span class="text-xs text-gray-500">v{{ update_info.current_version }}</span>
{% if update_info.update_available %}
<span class="mx-1">|</span>
<a href="https://github.com/snailyp/gemini-balance/releases/latest" target="_blank" class="text-yellow-600 hover:text-yellow-800 transition duration-300 animate-pulse">
<i class="fas fa-arrow-up"></i> 新版本: v{{ update_info.latest_version }}
</a>
{% elif update_info.error_message and update_info.error_message != 'Checking...' %}
<span class="mx-1">|</span>
<span class="text-xs text-red-500" title="{{ update_info.error_message }}">更新检查失败</span>
{% endif %}
{% endif %}
</div> </div>
<!-- 通用JS --> <!-- 通用JS -->
+1 -1
View File
@@ -293,7 +293,7 @@
<i class="fas fa-plus"></i> 添加预算映射 <i class="fas fa-plus"></i> 添加预算映射
</button> </button>
</div> --> </div> -->
<small class="text-gray-500 mt-1 block">为每个思考模型设置预算(整数),此项与上方模型列表自动关联。</small> <small class="text-gray-500 mt-1 block">为每个思考模型设置预算(整数,最大值 24576),此项与上方模型列表自动关联。</small>
</div> </div>
</div> </div>
+2
View File
@@ -17,3 +17,5 @@ aiomysql
databases databases
python-dotenv python-dotenv
apscheduler # 添加定时任务库 apscheduler # 添加定时任务库
packaging