6b7f0fa8 by 柴进

:sparkles: 启动时同步打包 api_key 到用户 config + 轮换新 Gemini Key

机制:
config_util.sync_bundled_api_key(user_config_path) 启动时调用:
- frozen 态才生效 (开发态不干涉)
- 打包 config.json 的 api_key 和用户目录的比较
- 不一致时只覆盖 api_key 这一个字段
- saved_prompts / last_user / saved_password_hash / db_config 全部保留
- 多重 fail-safe: 打包 config 缺失/解析失败/api_key 空/用户 config 解析失败 → 全都不动

新 Gemini Key 已写入 repo 的 config.json。两周过渡计划:
- D0 (本次发布): 新 Key 随 bundled config 分发,升级用户启动瞬间自动切到新 Key;
  老用户暂不升级,config.json 里还是老 Key,Google Cloud 两把 Key 并行
- D14: Google Cloud Console 作废老 Key;
  已升级用户零感知,没升级用户此时才 401,被迫升级

注: 企业内部软件,有用户名密码保护,Key 内置到 repo 可接受
1 parent 99a88a64
1 { 1 {
2 "api_key": "AIzaSyDKfEF-yHbKxGirdHgaGa7jNSzxyWfReus", 2 "api_key": "AIzaSyC7WJxhuEiUuzGEJPcWU7RpXgN2ScH7E9Y",
3 "saved_prompts": [ 3 "saved_prompts": [
4 "主石换成闪耀的祖母绿", 4 "主石换成闪耀的祖母绿",
5 "主石换成闪耀的钻石", 5 "主石换成闪耀的钻石",
......
...@@ -10,13 +10,17 @@ config.json 安全加载器。 ...@@ -10,13 +10,17 @@ config.json 安全加载器。
10 from __future__ import annotations 10 from __future__ import annotations
11 11
12 import json 12 import json
13 import logging
13 import os 14 import os
14 import shutil 15 import shutil
15 import sys 16 import sys
16 import platform 17 import platform
17 from datetime import datetime 18 from datetime import datetime
18 from pathlib import Path 19 from pathlib import Path
19 from typing import Tuple 20 from typing import Optional, Tuple
21
22
23 logger = logging.getLogger(__name__)
20 24
21 25
22 DEFAULT_CONFIG: dict = { 26 DEFAULT_CONFIG: dict = {
...@@ -103,6 +107,89 @@ def load_config_safe(config_path: Path) -> Tuple[dict, str]: ...@@ -103,6 +107,89 @@ def load_config_safe(config_path: Path) -> Tuple[dict, str]:
103 return merged, "" 107 return merged, ""
104 108
105 109
110 def find_bundled_config() -> Optional[Path]:
111 """
112 定位打包进二进制的 config.json (只在 frozen 态有意义)。
113 查找顺序: macOS app bundle Resources/ → exe 同目录 → PyInstaller _MEIPASS/
114 都找不到返回 None。
115 """
116 if not getattr(sys, "frozen", False):
117 return None
118 candidates = []
119 if platform.system() == "Darwin":
120 candidates.append(Path(sys.executable).parent.parent / "Resources" / "config.json")
121 else:
122 candidates.append(Path(sys.executable).parent / "config.json")
123 if hasattr(sys, "_MEIPASS"):
124 candidates.append(Path(sys._MEIPASS) / "config.json")
125 for p in candidates:
126 if p.exists():
127 return p
128 return None
129
130
131 def sync_bundled_api_key(user_config_path: Path) -> None:
132 """
133 如果打包 config.json 里的 api_key 和用户目录的不一致,用打包的值覆盖那一个字段。
134 其他字段 (saved_prompts / last_user / saved_password_hash / db_config ...) 全部保留。
135
136 只在 frozen (打包态) 下生效 —— 开发态不干涉本地 config.json。
137
138 保护策略 (避免把用户 api_key 清零):
139 - 找不到打包 config → 不动
140 - 打包 config 解析失败 → 不动
141 - 打包 api_key 为空 → 不动
142 - 用户 config 不存在 → 不动 (外层的 "从 bundled 拷贝整个文件" 逻辑会处理)
143 - 用户 config 解析失败 → 不动 (load_config_safe 会走损坏分支备份)
144
145 写入失败不抛异常,记错误日志。
146 """
147 if not getattr(sys, "frozen", False):
148 return
149
150 bundled = find_bundled_config()
151 if bundled is None:
152 return
153
154 try:
155 bundled_data = json.loads(bundled.read_text(encoding="utf-8"))
156 except Exception as e:
157 logger.warning(f"打包 config 解析失败, 跳过 api_key 同步: {e}")
158 return
159
160 bundled_key = str(bundled_data.get("api_key", "")).strip()
161 if not bundled_key:
162 logger.warning("打包 config 的 api_key 为空, 跳过同步 (避免清零用户 key)")
163 return
164
165 if not user_config_path.exists():
166 # 用户 config 还不存在 → 外层的整文件拷贝逻辑会处理
167 return
168
169 try:
170 user_data = json.loads(user_config_path.read_text(encoding="utf-8"))
171 except Exception as e:
172 logger.warning(f"用户 config 解析失败, 跳过 api_key 同步 (load_config_safe 会处理): {e}")
173 return
174
175 if not isinstance(user_data, dict):
176 return
177
178 if user_data.get("api_key") == bundled_key:
179 return # 一致,无需动作
180
181 # 只覆盖 api_key 一个字段, 其他原样保留
182 user_data["api_key"] = bundled_key
183 try:
184 user_config_path.write_text(
185 json.dumps(user_data, ensure_ascii=False, indent=2),
186 encoding="utf-8",
187 )
188 logger.info(f"[BOOT] api_key 已从 bundled 同步到用户 config: {user_config_path}")
189 except Exception as e:
190 logger.error(f"[BOOT] 写回用户 config 失败, api_key 未同步: {e}")
191
192
106 def _backup(src: Path, reason: str) -> None: 193 def _backup(src: Path, reason: str) -> None:
107 """把损坏 / 空的 config 文件备份,不抛异常。""" 194 """把损坏 / 空的 config 文件备份,不抛异常。"""
108 try: 195 try:
......
...@@ -4660,6 +4660,15 @@ def main(): ...@@ -4660,6 +4660,15 @@ def main():
4660 except Exception as e: 4660 except Exception as e:
4661 logger.error(f"[BOOT] 从 bundled 拷贝 config 失败: {e}") 4661 logger.error(f"[BOOT] 从 bundled 拷贝 config 失败: {e}")
4662 4662
4663 # 第 3.5 步:把打包 config 里的 api_key 同步到用户目录 (仅当不一致时)
4664 # 场景: 老用户升级新版, 用户目录已有 config.json (里面是老 Key), 打包 config 带着新 Key
4665 # 作用: 只更新 api_key 这一个字段, 用户的 saved_prompts/last_user/saved_password_hash 等全部保留
4666 try:
4667 from config_util import sync_bundled_api_key
4668 sync_bundled_api_key(config_path)
4669 except Exception as e:
4670 logger.warning(f"[BOOT] api_key 同步异常,继续启动: {e}")
4671
4663 # 第4步:创建 QApplication(preflight 对话框需要) 4672 # 第4步:创建 QApplication(preflight 对话框需要)
4664 logger.info("[BOOT] Phase 4: 创建 QApplication...") 4673 logger.info("[BOOT] Phase 4: 创建 QApplication...")
4665 app = QApplication(sys.argv) 4674 app = QApplication(sys.argv)
......