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
{
"api_key": "AIzaSyDKfEF-yHbKxGirdHgaGa7jNSzxyWfReus",
"api_key": "AIzaSyC7WJxhuEiUuzGEJPcWU7RpXgN2ScH7E9Y",
"saved_prompts": [
"主石换成闪耀的祖母绿",
"主石换成闪耀的钻石",
......
......@@ -10,13 +10,17 @@ config.json 安全加载器。
from __future__ import annotations
import json
import logging
import os
import shutil
import sys
import platform
from datetime import datetime
from pathlib import Path
from typing import Tuple
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
DEFAULT_CONFIG: dict = {
......@@ -103,6 +107,89 @@ def load_config_safe(config_path: Path) -> Tuple[dict, str]:
return merged, ""
def find_bundled_config() -> Optional[Path]:
"""
定位打包进二进制的 config.json (只在 frozen 态有意义)。
查找顺序: macOS app bundle Resources/ → exe 同目录 → PyInstaller _MEIPASS/
都找不到返回 None。
"""
if not getattr(sys, "frozen", False):
return None
candidates = []
if platform.system() == "Darwin":
candidates.append(Path(sys.executable).parent.parent / "Resources" / "config.json")
else:
candidates.append(Path(sys.executable).parent / "config.json")
if hasattr(sys, "_MEIPASS"):
candidates.append(Path(sys._MEIPASS) / "config.json")
for p in candidates:
if p.exists():
return p
return None
def sync_bundled_api_key(user_config_path: Path) -> None:
"""
如果打包 config.json 里的 api_key 和用户目录的不一致,用打包的值覆盖那一个字段。
其他字段 (saved_prompts / last_user / saved_password_hash / db_config ...) 全部保留。
只在 frozen (打包态) 下生效 —— 开发态不干涉本地 config.json。
保护策略 (避免把用户 api_key 清零):
- 找不到打包 config → 不动
- 打包 config 解析失败 → 不动
- 打包 api_key 为空 → 不动
- 用户 config 不存在 → 不动 (外层的 "从 bundled 拷贝整个文件" 逻辑会处理)
- 用户 config 解析失败 → 不动 (load_config_safe 会走损坏分支备份)
写入失败不抛异常,记错误日志。
"""
if not getattr(sys, "frozen", False):
return
bundled = find_bundled_config()
if bundled is None:
return
try:
bundled_data = json.loads(bundled.read_text(encoding="utf-8"))
except Exception as e:
logger.warning(f"打包 config 解析失败, 跳过 api_key 同步: {e}")
return
bundled_key = str(bundled_data.get("api_key", "")).strip()
if not bundled_key:
logger.warning("打包 config 的 api_key 为空, 跳过同步 (避免清零用户 key)")
return
if not user_config_path.exists():
# 用户 config 还不存在 → 外层的整文件拷贝逻辑会处理
return
try:
user_data = json.loads(user_config_path.read_text(encoding="utf-8"))
except Exception as e:
logger.warning(f"用户 config 解析失败, 跳过 api_key 同步 (load_config_safe 会处理): {e}")
return
if not isinstance(user_data, dict):
return
if user_data.get("api_key") == bundled_key:
return # 一致,无需动作
# 只覆盖 api_key 一个字段, 其他原样保留
user_data["api_key"] = bundled_key
try:
user_config_path.write_text(
json.dumps(user_data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
logger.info(f"[BOOT] api_key 已从 bundled 同步到用户 config: {user_config_path}")
except Exception as e:
logger.error(f"[BOOT] 写回用户 config 失败, api_key 未同步: {e}")
def _backup(src: Path, reason: str) -> None:
"""把损坏 / 空的 config 文件备份,不抛异常。"""
try:
......
......@@ -4660,6 +4660,15 @@ def main():
except Exception as e:
logger.error(f"[BOOT] 从 bundled 拷贝 config 失败: {e}")
# 第 3.5 步:把打包 config 里的 api_key 同步到用户目录 (仅当不一致时)
# 场景: 老用户升级新版, 用户目录已有 config.json (里面是老 Key), 打包 config 带着新 Key
# 作用: 只更新 api_key 这一个字段, 用户的 saved_prompts/last_user/saved_password_hash 等全部保留
try:
from config_util import sync_bundled_api_key
sync_bundled_api_key(config_path)
except Exception as e:
logger.warning(f"[BOOT] api_key 同步异常,继续启动: {e}")
# 第4步:创建 QApplication(preflight 对话框需要)
logger.info("[BOOT] Phase 4: 创建 QApplication...")
app = QApplication(sys.argv)
......