8b5beb1d by 柴进

:bug: 补齐遗漏的启动必需模块 (修复 Mac 打包后启动失败)

根因: audit_logger.py / config_util.py / preflight.py 这三个启动
必需模块从未被 git 追踪过. Windows 构建机上这些文件在本地磁盘,
PyInstaller 能找到, 所以 Win 包正常; 但 Mac 拉代码后根目录缺这
三个文件, PyInstaller 的 Analysis 找不到 import 链, 构建要么失败
要么运行时 ImportError.

本次补齐:
- audit_logger.py — 审计日志单例 (NDJSON 本地队列 + 异步 MySQL 上传)
- config_util.py  — 跨平台配置路径解析与安全加载
- preflight.py    — 启动门禁 (config/DB/schema 校验)
- database_schema.sql — 运维参考
- migrations/2026-04-21_add_audit_log_columns.sql — 审计表迁移
- .gitignore      — 屏蔽 .venv/build/dist/logs/__pycache__ 等,
                    防止以后再漏推业务代码时被大量噪声淹没
1 parent 1bbb3c47
1 # Python
2 __pycache__/
3 *.pyc
4 *.pyo
5 *.backup
6
7 # Virtual environments
8 .venv/
9 venv/
10
11 # Build artifacts
12 build/
13 dist/
14 *.egg-info/
15
16 # IDE
17 .idea/
18 .vscode/
19 .claude/
20
21 # Runtime / user data
22 logs/
23 errorlog/
24 data/
25 images/
26
27 # OS
28 .DS_Store
29 Thumbs.db
1 """
2 config.json 安全加载器。
3
4 公开接口:
5 - DEFAULT_CONFIG: 默认配置常量
6 - load_config_safe(path): 区分处理各类 IO / JSON 错误,返回 (config, error_message)
7 - get_config_dir(): 跨平台返回配置目录
8 - get_config_path(): 返回 config.json 绝对路径
9 """
10 from __future__ import annotations
11
12 import json
13 import os
14 import shutil
15 import sys
16 import platform
17 from datetime import datetime
18 from pathlib import Path
19 from typing import Tuple
20
21
22 DEFAULT_CONFIG: dict = {
23 "api_key": "",
24 "saved_prompts": [],
25 "db_config": None,
26 "last_user": "",
27 "saved_password_hash": "",
28 "logging_config": {
29 "enabled": True,
30 "level": "INFO",
31 "log_to_console": True,
32 },
33 "history_config": {
34 "max_history_count": 100,
35 },
36 }
37
38
39 def get_config_dir() -> Path:
40 """跨平台返回用户级配置目录(与 image_generator.py 中 get_config_dir 一致)。"""
41 if getattr(sys, "frozen", False):
42 system = platform.system()
43 if system == "Darwin":
44 d = Path.home() / "Library" / "Application Support" / "ZB100ImageGenerator"
45 elif system == "Windows":
46 d = Path(os.getenv("APPDATA", str(Path.home()))) / "ZB100ImageGenerator"
47 else:
48 d = Path.home() / ".config" / "zb100imagegenerator"
49 else:
50 d = Path(".").resolve()
51 d.mkdir(parents=True, exist_ok=True)
52 return d
53
54
55 def get_config_path() -> Path:
56 return get_config_dir() / "config.json"
57
58
59 def load_config_safe(config_path: Path) -> Tuple[dict, str]:
60 """
61 安全加载 config.json。
62
63 返回 (config, error):
64 - 成功:(合并后的 config dict, "")
65 - 失败但可恢复:(DEFAULT_CONFIG 副本, 错误描述);preflight 会根据返回
66 的 error 和 config 内容共同决定是否拦截启动
67 - 不抛异常
68
69 行为:
70 - 文件不存在 → (DEFAULT_CONFIG 副本, "") # 让 preflight 判断是否允许
71 - 空文件 / JSON 损坏 → 备份为 .bak.<timestamp> + 返回默认值 + error
72 - PermissionError / OSError → 返回默认值 + error(不备份,读都读不到)
73 - 顶层不是 object → 返回默认值 + error
74 - 正常 → defaults.update(loaded) 合并后返回(保留未知字段以兼容)
75 """
76 config_path = Path(config_path)
77
78 if not config_path.exists():
79 return dict(DEFAULT_CONFIG), ""
80
81 try:
82 content = config_path.read_text(encoding="utf-8")
83 except PermissionError as e:
84 return dict(DEFAULT_CONFIG), f"permission denied: {e}"
85 except OSError as e:
86 return dict(DEFAULT_CONFIG), f"IO error: {e}"
87
88 if not content.strip():
89 _backup(config_path, reason="empty")
90 return dict(DEFAULT_CONFIG), "config.json was empty, using defaults"
91
92 try:
93 loaded = json.loads(content)
94 except json.JSONDecodeError as e:
95 _backup(config_path, reason="parse-error")
96 return dict(DEFAULT_CONFIG), f"JSON parse error, backed up to .bak: {e}"
97
98 if not isinstance(loaded, dict):
99 return dict(DEFAULT_CONFIG), "config.json top-level is not an object"
100
101 merged = dict(DEFAULT_CONFIG)
102 merged.update(loaded)
103 return merged, ""
104
105
106 def _backup(src: Path, reason: str) -> None:
107 """把损坏 / 空的 config 文件备份,不抛异常。"""
108 try:
109 ts = datetime.now().strftime("%Y%m%d_%H%M%S")
110 dst = src.with_suffix(f".json.bak.{reason}.{ts}")
111 shutil.copy2(src, dst)
112 except Exception:
113 pass
1 -- Nano Banana App Database Schema
2 -- 包含用户登录日志表和使用日志表
3 --
4 -- 表说明:
5 -- 1. nano_banana_user_log: 记录用户登录日志(IP地址、设备名称、登录时间)
6 -- 2. nano_banana_user_use_log: 记录用户生图操作日志(prompt、结果、状态、错误信息)
7
8 -- 创建用户登录日志表
9 CREATE TABLE `nano_banana_user_log` (
10 `user_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户名',
11 `local_ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '局域网IP地址',
12 `public_ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '公网IP地址(可为空)',
13 `device_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备名称',
14 `login_time` datetime COLLATE utf8mb4_unicode_ci DEFAULT CURRENT_TIMESTAMP COMMENT '登录时间',
15 INDEX `idx_user_name` (`user_name`),
16 INDEX `idx_login_time` (`login_time`)
17 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录日志表';
18
19 -- 数据迁移脚本(如果表已存在)
20 ALTER TABLE `nano_banana_user_log`
21 ADD COLUMN `local_ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '局域网IP地址' AFTER `user_name`,
22 ADD COLUMN `public_ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '公网IP地址' AFTER `local_ip`,
23 MODIFY COLUMN `device_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备名称',
24 ADD INDEX `idx_user_name` (`user_name`),
25 ADD INDEX `idx_login_time` (`login_time`);
26
27 -- 创建用户使用日志表
28 CREATE TABLE `nano_banana_user_use_log` (
29 `id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
30 `record_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间',
31 `user_name` VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户名',
32 `device_name` VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备名称',
33 `prompt` TEXT COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户请求的 Prompt',
34 `result_path` VARCHAR(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '返回数据地址(成功时为图片路径)',
35 `status` ENUM('success', 'failure') COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作状态',
36 `error_message` TEXT COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '失败时的错误信息',
37 INDEX `idx_user_name` (`user_name`),
38 INDEX `idx_record_time` (`record_time`),
39 INDEX `idx_status` (`status`)
40 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户使用日志表';
...\ No newline at end of file ...\ No newline at end of file
1 -- =====================================================================
2 -- Migration: add audit log columns (2026-04-21)
3 -- Change: add-audit-log-reliability
4 -- Purpose: 为审计日志表新增 model / duration_ms / finish_reason 三列,
5 -- 便于按模型、耗时、失败原因做分析和定位。
6 --
7 -- 执行者:@柴进(在 RDS 上执行,必须早于新版本下发)
8 -- 执行后:客户端新版启动时 preflight 会校验这三列存在,否则拦截启动。
9 -- =====================================================================
10
11 USE `saas_user`;
12
13 ALTER TABLE `nano_banana_user_use_log`
14 ADD COLUMN `model` VARCHAR(64) NULL COMMENT '本次生成使用的 Gemini 模型 ID',
15 ADD COLUMN `duration_ms` INT NULL COMMENT 'Worker start 到 emit 的耗时(毫秒)',
16 ADD COLUMN `finish_reason` VARCHAR(64) NULL COMMENT 'Gemini 响应 candidates[0].finish_reason';
17
18 -- 回滚(仅开发 / 灰度回退场景,生产不建议删列):
19 -- ALTER TABLE `nano_banana_user_use_log`
20 -- DROP COLUMN `model`,
21 -- DROP COLUMN `duration_ms`,
22 -- DROP COLUMN `finish_reason`;
1 """
2 启动门禁:保证审计日志上传的所有前置条件都成立。
3 任一失败即阻止应用进入主流程,对用户只显示一句"应用启动失败,请联系 @柴进"。
4 详细错误脱敏后写入 logs/preflight_error.log。
5 """
6 from __future__ import annotations
7
8 import logging
9 import re
10 import sys
11 import traceback
12 from datetime import datetime
13 from pathlib import Path
14 from typing import Tuple
15
16 import pymysql
17
18 from config_util import load_config_safe
19
20
21 logger = logging.getLogger(__name__)
22
23
24 REQUIRED_DB_FIELDS = ("host", "port", "user", "password", "database")
25 REQUIRED_TABLES = ("nano_banana_user_use_log", "nano_banana_user_log")
26 REQUIRED_USE_LOG_COLUMNS = (
27 "user_name", "device_name", "prompt", "result_path", "status",
28 "error_message", "model", "duration_ms", "finish_reason",
29 )
30 REQUIRED_LOGIN_LOG_COLUMNS = (
31 "user_name", "local_ip", "public_ip", "device_name", "login_time",
32 )
33
34
35 def preflight_check(config_path: Path, audit_queue_path: Path) -> Tuple[bool, str, dict]:
36 """
37 返回 (ok, error_detail, config)。
38 - ok=True: 一切就绪,调用方可以继续启动
39 - ok=False: error_detail 为详细错误描述(未脱敏;handle_preflight_failure 会脱敏后落盘)
40 - config: 成功时为可用 config dict;失败时可能为部分加载或 DEFAULT_CONFIG
41 """
42 # 1. config.json
43 try:
44 config, load_err = load_config_safe(config_path)
45 except Exception as e:
46 return False, f"config load crashed:\n{traceback.format_exc()}", {}
47
48 if load_err:
49 return False, f"config load error: {load_err}", config
50
51 # 2. db_config 字段完整
52 db = config.get("db_config")
53 if not db or not isinstance(db, dict):
54 return False, "config.json 缺少 db_config 字段或格式错误", config
55
56 missing = [k for k in REQUIRED_DB_FIELDS if not db.get(k)]
57 if missing:
58 return False, f"db_config 缺少字段: {missing}", config
59
60 # 3. MySQL 连接 + SELECT 1
61 conn = None
62 try:
63 conn = pymysql.connect(
64 host=db["host"],
65 port=int(db["port"]),
66 user=db["user"],
67 password=db["password"],
68 database=db["database"],
69 connect_timeout=5,
70 read_timeout=5,
71 write_timeout=5,
72 charset="utf8mb4",
73 )
74 except Exception as e:
75 return False, f"MySQL connect 失败: {type(e).__name__}: {e}", config
76
77 try:
78 with conn.cursor() as cur:
79 cur.execute("SELECT 1")
80 cur.fetchone()
81
82 # 4. 表存在
83 for table in REQUIRED_TABLES:
84 try:
85 cur.execute(f"SELECT 1 FROM `{table}` LIMIT 1")
86 cur.fetchone()
87 except Exception as e:
88 return False, f"审计表 {table} 不可用: {type(e).__name__}: {e}", config
89
90 # 5. 必要列存在
91 ok, col_err = _check_columns(cur, db["database"], "nano_banana_user_use_log",
92 REQUIRED_USE_LOG_COLUMNS)
93 if not ok:
94 return False, col_err, config
95 ok, col_err = _check_columns(cur, db["database"], "nano_banana_user_log",
96 REQUIRED_LOGIN_LOG_COLUMNS)
97 if not ok:
98 return False, col_err, config
99 finally:
100 try:
101 conn.close()
102 except Exception:
103 pass
104
105 # 6. 本地队列目录可写
106 try:
107 audit_queue_path.parent.mkdir(parents=True, exist_ok=True)
108 probe = audit_queue_path.parent / ".preflight_probe"
109 probe.write_text("ok", encoding="utf-8")
110 probe.unlink()
111 except Exception as e:
112 return False, f"审计队列目录不可写 ({audit_queue_path.parent}): {e}", config
113
114 return True, "", config
115
116
117 def _check_columns(cur, db_name: str, table: str, required: tuple[str, ...]) -> Tuple[bool, str]:
118 cur.execute(
119 "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
120 "WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s",
121 (db_name, table),
122 )
123 existing = {row[0] for row in cur.fetchall()}
124 missing = [c for c in required if c not in existing]
125 if missing:
126 return False, f"表 {table} 缺少列: {missing}(请运行 migrations/2026-04-21_add_audit_log_columns.sql)"
127 return True, ""
128
129
130 def handle_preflight_failure(detail: str, logs_dir: Path) -> None:
131 """
132 写入脱敏详情到 logs/preflight_error.log,显示单行对话框,sys.exit(1)。
133 调用此函数前必须已经创建 QApplication。
134 """
135 from PySide6.QtWidgets import QMessageBox, QApplication
136
137 # 写日志(脱敏)
138 try:
139 logs_dir.mkdir(parents=True, exist_ok=True)
140 err_log = logs_dir / "preflight_error.log"
141 with open(err_log, "a", encoding="utf-8") as f:
142 f.write(f"\n===== {datetime.now().isoformat(timespec='seconds')} =====\n")
143 f.write(_scrub(detail))
144 f.write("\n")
145 except Exception:
146 pass
147
148 # 对用户:一句话
149 try:
150 app = QApplication.instance()
151 if app is None:
152 # preflight 失败比 QApplication 创建还早的极端情况(不应发生)
153 app = QApplication(sys.argv)
154 box = QMessageBox()
155 box.setIcon(QMessageBox.Critical)
156 box.setWindowTitle("启动失败")
157 box.setText("应用启动失败,请联系 @柴进")
158 box.setStandardButtons(QMessageBox.Ok)
159 box.exec()
160 except Exception:
161 # 最坏情况:连对话框都弹不出来
162 print("应用启动失败,请联系 @柴进", file=sys.stderr)
163
164 sys.exit(1)
165
166
167 _SCRUB_PATTERNS = [
168 (re.compile(r'("password"\s*:\s*)"[^"]*"'), r'\1"***"'),
169 (re.compile(r'("api_key"\s*:\s*)"[^"]*"'), r'\1"***"'),
170 (re.compile(r"(password\s*=\s*)\S+"), r"\1***"),
171 (re.compile(r"(api_key\s*=\s*)\S+"), r"\1***"),
172 ]
173
174
175 def _scrub(detail: str) -> str:
176 """从详情里擦除 password / api_key。"""
177 out = detail
178 for pat, repl in _SCRUB_PATTERNS:
179 out = pat.sub(repl, out)
180 return out