99a88a64 by 柴进

:sparkles: 引入版本门禁: 服务端可控的客户端最低版本

机制:
- 新建 version.py (APP_VERSION = 1.1.0) 作为单一真相源
- 新建 migration: nano_banana_app_config KV 表
  初始化 min_client_version=1.0.0, download_url=飞书文档
- preflight 在 DB 表/字段校验通过后加一步版本校验:
  读 app_config -> 对比本地 APP_VERSION
  过旧返回 VERSION_TOO_OLD::<min>|<url> 前缀
  fail-safe: 读不到配置/解析失败 -> 放行 (避免 DBA 误操作全体挂掉)
- 新增 handle_version_too_old: 明文弹窗 + "打开下载页" 按钮
  用 QDesktopServices.openUrl 调系统默认浏览器 (跨 Win/Mac)
- image_generator 启动处按 is_version_error 分发:
  版本过旧走明文升级提示, 其他错误保留原脱敏路径

Why:
以后想淘汰任一老版本,只需:
  UPDATE nano_banana_app_config SET config_value='X.Y.Z'
   WHERE config_key='min_client_version'
不再需要轮换 API Key (一次性核爆 -> 精细版本控制)
1 parent 8b5beb1d
......@@ -4668,7 +4668,12 @@ def main():
# 第 4.5 步:启动门禁 preflight
# 任一检查失败 → 弹"应用启动失败,请联系 @柴进" → sys.exit(1)
logger.info("[BOOT] Phase 4.5: 启动门禁 preflight...")
from preflight import preflight_check, handle_preflight_failure
from preflight import (
preflight_check,
handle_preflight_failure,
handle_version_too_old,
is_version_error,
)
from audit_logger import init_audit_logger
audit_queue_path = config_dir / 'audit_queue.ndjson'
......@@ -4687,8 +4692,14 @@ def main():
preflight_ok, preflight_err, loaded_config = preflight_check(config_path, audit_queue_path)
if not preflight_ok:
logger.error(f"[BOOT] preflight 失败: {preflight_err}")
handle_preflight_failure(preflight_err, logs_dir)
return # handle_preflight_failure 内部会 sys.exit(1)
# 版本过旧 vs 其他错误分两条路径:
# - 版本过旧: 明文提示 + 打开下载页
# - 其他: 脱敏日志 + "请联系 @柴进" (避免泄漏 DB 细节)
if is_version_error(preflight_err):
handle_version_too_old(preflight_err, logs_dir)
else:
handle_preflight_failure(preflight_err, logs_dir)
return # handle_* 内部会 sys.exit(1)
# preflight 通过后,db_config 必定存在且可用
db_config = loaded_config["db_config"]
......
-- 应用级 KV 配置表 —— 服务端动态控制客户端行为的统一入口
-- 本次用来存: 最低客户端版本号 + 升级下载链接
-- 未来想加别的运行时开关(公告/灰度/限流)也往这里扔,不用每次 ALTER TABLE
CREATE TABLE IF NOT EXISTS `nano_banana_app_config` (
`config_key` VARCHAR(64) NOT NULL PRIMARY KEY,
`config_value` VARCHAR(512) NOT NULL,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`description` VARCHAR(256) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端运行时配置 KV 表';
-- 初始化两条必要记录
-- min_client_version 初始设为 1.0.0 (低于本次发布的 1.1.0),新版本自己不卡自己;
-- 未来想强制淘汰老版本,只需 UPDATE 这一条记录即可。
INSERT INTO `nano_banana_app_config` (config_key, config_value, description) VALUES
('min_client_version', '1.0.0', '最低允许运行的客户端版本号 (semver: major.minor.patch)'),
('download_url', 'https://v261hkpd8n.feishu.cn/docx/NROhduUvRonF7XxoznncDuQ0nkA', '新版本下载/说明链接')
ON DUPLICATE KEY UPDATE
config_value = VALUES(config_value),
description = VALUES(description);
......@@ -16,13 +16,17 @@ from typing import Tuple
import pymysql
from config_util import load_config_safe
from version import APP_VERSION
logger = logging.getLogger(__name__)
# 版本过旧错误的 detail 前缀 —— 主程序据此分发到 handle_version_too_old 而不是 handle_preflight_failure
VERSION_ERROR_PREFIX = "VERSION_TOO_OLD::"
REQUIRED_DB_FIELDS = ("host", "port", "user", "password", "database")
REQUIRED_TABLES = ("nano_banana_user_use_log", "nano_banana_user_log")
REQUIRED_TABLES = ("nano_banana_user_use_log", "nano_banana_user_log", "nano_banana_app_config")
REQUIRED_USE_LOG_COLUMNS = (
"user_name", "device_name", "prompt", "result_path", "status",
"error_message", "model", "duration_ms", "finish_reason",
......@@ -96,6 +100,11 @@ def preflight_check(config_path: Path, audit_queue_path: Path) -> Tuple[bool, st
REQUIRED_LOGIN_LOG_COLUMNS)
if not ok:
return False, col_err, config
# 5.5. 版本门禁: 本地 APP_VERSION >= MySQL 里的 min_client_version
ok, ver_err = _check_version(cur)
if not ok:
return False, ver_err, config
finally:
try:
conn.close()
......@@ -114,6 +123,59 @@ def preflight_check(config_path: Path, audit_queue_path: Path) -> Tuple[bool, st
return True, "", config
def _parse_version(v: str) -> Tuple[int, int, int]:
"""语义化版本解析。非法值抛 ValueError,调用方 catch 后 fail-safe 放行。"""
parts = v.strip().split(".")
out = [int(p) for p in parts[:3]]
while len(out) < 3:
out.append(0)
return (out[0], out[1], out[2])
def _check_version(cur) -> Tuple[bool, str]:
"""
读 nano_banana_app_config 的 min_client_version 和 download_url,
对比本地 APP_VERSION。
fail-safe 策略: 读不到配置 / 解析失败 → 记 WARNING 后放行。
避免 DBA 一次误删记录让全体用户挂掉。
"""
try:
cur.execute(
"SELECT config_key, config_value FROM nano_banana_app_config "
"WHERE config_key IN ('min_client_version', 'download_url')"
)
rows = {r[0]: r[1] for r in cur.fetchall()}
except Exception as e:
logger.warning(f"读取 app_config 失败, 放行: {e}")
return True, ""
min_ver = rows.get("min_client_version")
url = rows.get("download_url", "")
if not min_ver:
logger.warning("app_config 缺少 min_client_version 配置, 放行")
return True, ""
try:
local_t = _parse_version(APP_VERSION)
min_t = _parse_version(min_ver)
except Exception as e:
logger.warning(
f"版本号解析失败 local={APP_VERSION!r} min={min_ver!r}: {e}, 放行"
)
return True, ""
if local_t < min_t:
# detail 格式: "VERSION_TOO_OLD::<min_ver>|<url>"
return False, f"{VERSION_ERROR_PREFIX}{min_ver}|{url}"
return True, ""
def is_version_error(detail: str) -> bool:
"""主程序据此判断 preflight 失败类型,分发到不同的 handle_* 函数。"""
return detail.startswith(VERSION_ERROR_PREFIX)
def _check_columns(cur, db_name: str, table: str, required: tuple[str, ...]) -> Tuple[bool, str]:
cur.execute(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
......@@ -164,6 +226,60 @@ def handle_preflight_failure(detail: str, logs_dir: Path) -> None:
sys.exit(1)
def handle_version_too_old(detail: str, logs_dir: Path) -> None:
"""
版本过旧: 明文弹窗 + "打开下载页"按钮, sys.exit(1)。
不脱敏 —— min_ver 和 download_url 都是对外公开的,给用户最清晰的升级指引。
"""
from PySide6.QtWidgets import QMessageBox, QApplication
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
# 解析 detail: "VERSION_TOO_OLD::<min>|<url>"
payload = detail[len(VERSION_ERROR_PREFIX):]
min_ver, _, url = payload.partition("|")
# 记日志 (不脱敏,版本号和 URL 都是公开信息)
try:
logs_dir.mkdir(parents=True, exist_ok=True)
err_log = logs_dir / "preflight_error.log"
with open(err_log, "a", encoding="utf-8") as f:
f.write(f"\n===== {datetime.now().isoformat(timespec='seconds')} =====\n")
f.write(f"版本过旧: local={APP_VERSION}, required>={min_ver}, url={url}\n")
except Exception:
pass
try:
app = QApplication.instance()
if app is None:
app = QApplication(sys.argv)
box = QMessageBox()
box.setIcon(QMessageBox.Information)
box.setWindowTitle("需要升级")
box.setText(
f"当前版本 {APP_VERSION} 已不再支持,请升级到 {min_ver} 或更高版本后继续使用。"
)
if url:
open_btn = box.addButton("打开下载页", QMessageBox.AcceptRole)
else:
open_btn = None
quit_btn = box.addButton("退出", QMessageBox.RejectRole)
if open_btn is not None:
box.setDefaultButton(open_btn)
else:
box.setDefaultButton(quit_btn)
box.exec()
if open_btn is not None and box.clickedButton() is open_btn and url:
QDesktopServices.openUrl(QUrl(url))
except Exception:
print(
f"版本过旧,请升级到 {min_ver},下载: {url}",
file=sys.stderr,
)
sys.exit(1)
_SCRUB_PATTERNS = [
(re.compile(r'("password"\s*:\s*)"[^"]*"'), r'\1"***"'),
(re.compile(r'("api_key"\s*:\s*)"[^"]*"'), r'\1"***"'),
......
"""
应用版本号 —— 单一真相源。
语义化版本 major.minor.patch。
preflight 启动门禁会把这个值和 MySQL 里的 min_client_version 做 tuple 比较,
本地低于最低要求时拦截启动并弹升级提示。
"""
APP_VERSION = "1.1.0"