引入版本门禁: 服务端可控的客户端最低版本
机制: - 新建 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 (一次性核爆 -> 精细版本控制)
Showing
4 changed files
with
158 additions
and
3 deletions
| ... | @@ -4668,7 +4668,12 @@ def main(): | ... | @@ -4668,7 +4668,12 @@ def main(): |
| 4668 | # 第 4.5 步:启动门禁 preflight | 4668 | # 第 4.5 步:启动门禁 preflight |
| 4669 | # 任一检查失败 → 弹"应用启动失败,请联系 @柴进" → sys.exit(1) | 4669 | # 任一检查失败 → 弹"应用启动失败,请联系 @柴进" → sys.exit(1) |
| 4670 | logger.info("[BOOT] Phase 4.5: 启动门禁 preflight...") | 4670 | logger.info("[BOOT] Phase 4.5: 启动门禁 preflight...") |
| 4671 | from preflight import preflight_check, handle_preflight_failure | 4671 | from preflight import ( |
| 4672 | preflight_check, | ||
| 4673 | handle_preflight_failure, | ||
| 4674 | handle_version_too_old, | ||
| 4675 | is_version_error, | ||
| 4676 | ) | ||
| 4672 | from audit_logger import init_audit_logger | 4677 | from audit_logger import init_audit_logger |
| 4673 | 4678 | ||
| 4674 | audit_queue_path = config_dir / 'audit_queue.ndjson' | 4679 | audit_queue_path = config_dir / 'audit_queue.ndjson' |
| ... | @@ -4687,8 +4692,14 @@ def main(): | ... | @@ -4687,8 +4692,14 @@ def main(): |
| 4687 | preflight_ok, preflight_err, loaded_config = preflight_check(config_path, audit_queue_path) | 4692 | preflight_ok, preflight_err, loaded_config = preflight_check(config_path, audit_queue_path) |
| 4688 | if not preflight_ok: | 4693 | if not preflight_ok: |
| 4689 | logger.error(f"[BOOT] preflight 失败: {preflight_err}") | 4694 | logger.error(f"[BOOT] preflight 失败: {preflight_err}") |
| 4695 | # 版本过旧 vs 其他错误分两条路径: | ||
| 4696 | # - 版本过旧: 明文提示 + 打开下载页 | ||
| 4697 | # - 其他: 脱敏日志 + "请联系 @柴进" (避免泄漏 DB 细节) | ||
| 4698 | if is_version_error(preflight_err): | ||
| 4699 | handle_version_too_old(preflight_err, logs_dir) | ||
| 4700 | else: | ||
| 4690 | handle_preflight_failure(preflight_err, logs_dir) | 4701 | handle_preflight_failure(preflight_err, logs_dir) |
| 4691 | return # handle_preflight_failure 内部会 sys.exit(1) | 4702 | return # handle_* 内部会 sys.exit(1) |
| 4692 | 4703 | ||
| 4693 | # preflight 通过后,db_config 必定存在且可用 | 4704 | # preflight 通过后,db_config 必定存在且可用 |
| 4694 | db_config = loaded_config["db_config"] | 4705 | db_config = loaded_config["db_config"] | ... | ... |
| 1 | -- 应用级 KV 配置表 —— 服务端动态控制客户端行为的统一入口 | ||
| 2 | -- 本次用来存: 最低客户端版本号 + 升级下载链接 | ||
| 3 | -- 未来想加别的运行时开关(公告/灰度/限流)也往这里扔,不用每次 ALTER TABLE | ||
| 4 | |||
| 5 | CREATE TABLE IF NOT EXISTS `nano_banana_app_config` ( | ||
| 6 | `config_key` VARCHAR(64) NOT NULL PRIMARY KEY, | ||
| 7 | `config_value` VARCHAR(512) NOT NULL, | ||
| 8 | `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | ||
| 9 | `description` VARCHAR(256) DEFAULT NULL | ||
| 10 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端运行时配置 KV 表'; | ||
| 11 | |||
| 12 | -- 初始化两条必要记录 | ||
| 13 | -- min_client_version 初始设为 1.0.0 (低于本次发布的 1.1.0),新版本自己不卡自己; | ||
| 14 | -- 未来想强制淘汰老版本,只需 UPDATE 这一条记录即可。 | ||
| 15 | INSERT INTO `nano_banana_app_config` (config_key, config_value, description) VALUES | ||
| 16 | ('min_client_version', '1.0.0', '最低允许运行的客户端版本号 (semver: major.minor.patch)'), | ||
| 17 | ('download_url', 'https://v261hkpd8n.feishu.cn/docx/NROhduUvRonF7XxoznncDuQ0nkA', '新版本下载/说明链接') | ||
| 18 | ON DUPLICATE KEY UPDATE | ||
| 19 | config_value = VALUES(config_value), | ||
| 20 | description = VALUES(description); |
| ... | @@ -16,13 +16,17 @@ from typing import Tuple | ... | @@ -16,13 +16,17 @@ from typing import Tuple |
| 16 | import pymysql | 16 | import pymysql |
| 17 | 17 | ||
| 18 | from config_util import load_config_safe | 18 | from config_util import load_config_safe |
| 19 | from version import APP_VERSION | ||
| 19 | 20 | ||
| 20 | 21 | ||
| 21 | logger = logging.getLogger(__name__) | 22 | logger = logging.getLogger(__name__) |
| 22 | 23 | ||
| 23 | 24 | ||
| 25 | # 版本过旧错误的 detail 前缀 —— 主程序据此分发到 handle_version_too_old 而不是 handle_preflight_failure | ||
| 26 | VERSION_ERROR_PREFIX = "VERSION_TOO_OLD::" | ||
| 27 | |||
| 24 | REQUIRED_DB_FIELDS = ("host", "port", "user", "password", "database") | 28 | REQUIRED_DB_FIELDS = ("host", "port", "user", "password", "database") |
| 25 | REQUIRED_TABLES = ("nano_banana_user_use_log", "nano_banana_user_log") | 29 | REQUIRED_TABLES = ("nano_banana_user_use_log", "nano_banana_user_log", "nano_banana_app_config") |
| 26 | REQUIRED_USE_LOG_COLUMNS = ( | 30 | REQUIRED_USE_LOG_COLUMNS = ( |
| 27 | "user_name", "device_name", "prompt", "result_path", "status", | 31 | "user_name", "device_name", "prompt", "result_path", "status", |
| 28 | "error_message", "model", "duration_ms", "finish_reason", | 32 | "error_message", "model", "duration_ms", "finish_reason", |
| ... | @@ -96,6 +100,11 @@ def preflight_check(config_path: Path, audit_queue_path: Path) -> Tuple[bool, st | ... | @@ -96,6 +100,11 @@ def preflight_check(config_path: Path, audit_queue_path: Path) -> Tuple[bool, st |
| 96 | REQUIRED_LOGIN_LOG_COLUMNS) | 100 | REQUIRED_LOGIN_LOG_COLUMNS) |
| 97 | if not ok: | 101 | if not ok: |
| 98 | return False, col_err, config | 102 | return False, col_err, config |
| 103 | |||
| 104 | # 5.5. 版本门禁: 本地 APP_VERSION >= MySQL 里的 min_client_version | ||
| 105 | ok, ver_err = _check_version(cur) | ||
| 106 | if not ok: | ||
| 107 | return False, ver_err, config | ||
| 99 | finally: | 108 | finally: |
| 100 | try: | 109 | try: |
| 101 | conn.close() | 110 | conn.close() |
| ... | @@ -114,6 +123,59 @@ def preflight_check(config_path: Path, audit_queue_path: Path) -> Tuple[bool, st | ... | @@ -114,6 +123,59 @@ def preflight_check(config_path: Path, audit_queue_path: Path) -> Tuple[bool, st |
| 114 | return True, "", config | 123 | return True, "", config |
| 115 | 124 | ||
| 116 | 125 | ||
| 126 | def _parse_version(v: str) -> Tuple[int, int, int]: | ||
| 127 | """语义化版本解析。非法值抛 ValueError,调用方 catch 后 fail-safe 放行。""" | ||
| 128 | parts = v.strip().split(".") | ||
| 129 | out = [int(p) for p in parts[:3]] | ||
| 130 | while len(out) < 3: | ||
| 131 | out.append(0) | ||
| 132 | return (out[0], out[1], out[2]) | ||
| 133 | |||
| 134 | |||
| 135 | def _check_version(cur) -> Tuple[bool, str]: | ||
| 136 | """ | ||
| 137 | 读 nano_banana_app_config 的 min_client_version 和 download_url, | ||
| 138 | 对比本地 APP_VERSION。 | ||
| 139 | |||
| 140 | fail-safe 策略: 读不到配置 / 解析失败 → 记 WARNING 后放行。 | ||
| 141 | 避免 DBA 一次误删记录让全体用户挂掉。 | ||
| 142 | """ | ||
| 143 | try: | ||
| 144 | cur.execute( | ||
| 145 | "SELECT config_key, config_value FROM nano_banana_app_config " | ||
| 146 | "WHERE config_key IN ('min_client_version', 'download_url')" | ||
| 147 | ) | ||
| 148 | rows = {r[0]: r[1] for r in cur.fetchall()} | ||
| 149 | except Exception as e: | ||
| 150 | logger.warning(f"读取 app_config 失败, 放行: {e}") | ||
| 151 | return True, "" | ||
| 152 | |||
| 153 | min_ver = rows.get("min_client_version") | ||
| 154 | url = rows.get("download_url", "") | ||
| 155 | if not min_ver: | ||
| 156 | logger.warning("app_config 缺少 min_client_version 配置, 放行") | ||
| 157 | return True, "" | ||
| 158 | |||
| 159 | try: | ||
| 160 | local_t = _parse_version(APP_VERSION) | ||
| 161 | min_t = _parse_version(min_ver) | ||
| 162 | except Exception as e: | ||
| 163 | logger.warning( | ||
| 164 | f"版本号解析失败 local={APP_VERSION!r} min={min_ver!r}: {e}, 放行" | ||
| 165 | ) | ||
| 166 | return True, "" | ||
| 167 | |||
| 168 | if local_t < min_t: | ||
| 169 | # detail 格式: "VERSION_TOO_OLD::<min_ver>|<url>" | ||
| 170 | return False, f"{VERSION_ERROR_PREFIX}{min_ver}|{url}" | ||
| 171 | return True, "" | ||
| 172 | |||
| 173 | |||
| 174 | def is_version_error(detail: str) -> bool: | ||
| 175 | """主程序据此判断 preflight 失败类型,分发到不同的 handle_* 函数。""" | ||
| 176 | return detail.startswith(VERSION_ERROR_PREFIX) | ||
| 177 | |||
| 178 | |||
| 117 | def _check_columns(cur, db_name: str, table: str, required: tuple[str, ...]) -> Tuple[bool, str]: | 179 | def _check_columns(cur, db_name: str, table: str, required: tuple[str, ...]) -> Tuple[bool, str]: |
| 118 | cur.execute( | 180 | cur.execute( |
| 119 | "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " | 181 | "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS " |
| ... | @@ -164,6 +226,60 @@ def handle_preflight_failure(detail: str, logs_dir: Path) -> None: | ... | @@ -164,6 +226,60 @@ def handle_preflight_failure(detail: str, logs_dir: Path) -> None: |
| 164 | sys.exit(1) | 226 | sys.exit(1) |
| 165 | 227 | ||
| 166 | 228 | ||
| 229 | def handle_version_too_old(detail: str, logs_dir: Path) -> None: | ||
| 230 | """ | ||
| 231 | 版本过旧: 明文弹窗 + "打开下载页"按钮, sys.exit(1)。 | ||
| 232 | 不脱敏 —— min_ver 和 download_url 都是对外公开的,给用户最清晰的升级指引。 | ||
| 233 | """ | ||
| 234 | from PySide6.QtWidgets import QMessageBox, QApplication | ||
| 235 | from PySide6.QtCore import QUrl | ||
| 236 | from PySide6.QtGui import QDesktopServices | ||
| 237 | |||
| 238 | # 解析 detail: "VERSION_TOO_OLD::<min>|<url>" | ||
| 239 | payload = detail[len(VERSION_ERROR_PREFIX):] | ||
| 240 | min_ver, _, url = payload.partition("|") | ||
| 241 | |||
| 242 | # 记日志 (不脱敏,版本号和 URL 都是公开信息) | ||
| 243 | try: | ||
| 244 | logs_dir.mkdir(parents=True, exist_ok=True) | ||
| 245 | err_log = logs_dir / "preflight_error.log" | ||
| 246 | with open(err_log, "a", encoding="utf-8") as f: | ||
| 247 | f.write(f"\n===== {datetime.now().isoformat(timespec='seconds')} =====\n") | ||
| 248 | f.write(f"版本过旧: local={APP_VERSION}, required>={min_ver}, url={url}\n") | ||
| 249 | except Exception: | ||
| 250 | pass | ||
| 251 | |||
| 252 | try: | ||
| 253 | app = QApplication.instance() | ||
| 254 | if app is None: | ||
| 255 | app = QApplication(sys.argv) | ||
| 256 | box = QMessageBox() | ||
| 257 | box.setIcon(QMessageBox.Information) | ||
| 258 | box.setWindowTitle("需要升级") | ||
| 259 | box.setText( | ||
| 260 | f"当前版本 {APP_VERSION} 已不再支持,请升级到 {min_ver} 或更高版本后继续使用。" | ||
| 261 | ) | ||
| 262 | if url: | ||
| 263 | open_btn = box.addButton("打开下载页", QMessageBox.AcceptRole) | ||
| 264 | else: | ||
| 265 | open_btn = None | ||
| 266 | quit_btn = box.addButton("退出", QMessageBox.RejectRole) | ||
| 267 | if open_btn is not None: | ||
| 268 | box.setDefaultButton(open_btn) | ||
| 269 | else: | ||
| 270 | box.setDefaultButton(quit_btn) | ||
| 271 | box.exec() | ||
| 272 | if open_btn is not None and box.clickedButton() is open_btn and url: | ||
| 273 | QDesktopServices.openUrl(QUrl(url)) | ||
| 274 | except Exception: | ||
| 275 | print( | ||
| 276 | f"版本过旧,请升级到 {min_ver},下载: {url}", | ||
| 277 | file=sys.stderr, | ||
| 278 | ) | ||
| 279 | |||
| 280 | sys.exit(1) | ||
| 281 | |||
| 282 | |||
| 167 | _SCRUB_PATTERNS = [ | 283 | _SCRUB_PATTERNS = [ |
| 168 | (re.compile(r'("password"\s*:\s*)"[^"]*"'), r'\1"***"'), | 284 | (re.compile(r'("password"\s*:\s*)"[^"]*"'), r'\1"***"'), |
| 169 | (re.compile(r'("api_key"\s*:\s*)"[^"]*"'), r'\1"***"'), | 285 | (re.compile(r'("api_key"\s*:\s*)"[^"]*"'), r'\1"***"'), | ... | ... |
-
Please register or sign in to post a comment