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(): ...@@ -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}")
4690 handle_preflight_failure(preflight_err, logs_dir) 4695 # 版本过旧 vs 其他错误分两条路径:
4691 return # handle_preflight_failure 内部会 sys.exit(1) 4696 # - 版本过旧: 明文提示 + 打开下载页
4697 # - 其他: 脱敏日志 + "请联系 @柴进" (避免泄漏 DB 细节)
4698 if is_version_error(preflight_err):
4699 handle_version_too_old(preflight_err, logs_dir)
4700 else:
4701 handle_preflight_failure(preflight_err, logs_dir)
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"***"'),
......
1 """
2 应用版本号 —— 单一真相源。
3
4 语义化版本 major.minor.patch。
5 preflight 启动门禁会把这个值和 MySQL 里的 min_client_version 做 tuple 比较,
6 本地低于最低要求时拦截启动并弹升级提示。
7 """
8 APP_VERSION = "1.1.0"