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
# Python
__pycache__/
*.pyc
*.pyo
*.backup
# Virtual environments
.venv/
venv/
# Build artifacts
build/
dist/
*.egg-info/
# IDE
.idea/
.vscode/
.claude/
# Runtime / user data
logs/
errorlog/
data/
images/
# OS
.DS_Store
Thumbs.db
"""
config.json 安全加载器。
公开接口:
- DEFAULT_CONFIG: 默认配置常量
- load_config_safe(path): 区分处理各类 IO / JSON 错误,返回 (config, error_message)
- get_config_dir(): 跨平台返回配置目录
- get_config_path(): 返回 config.json 绝对路径
"""
from __future__ import annotations
import json
import os
import shutil
import sys
import platform
from datetime import datetime
from pathlib import Path
from typing import Tuple
DEFAULT_CONFIG: dict = {
"api_key": "",
"saved_prompts": [],
"db_config": None,
"last_user": "",
"saved_password_hash": "",
"logging_config": {
"enabled": True,
"level": "INFO",
"log_to_console": True,
},
"history_config": {
"max_history_count": 100,
},
}
def get_config_dir() -> Path:
"""跨平台返回用户级配置目录(与 image_generator.py 中 get_config_dir 一致)。"""
if getattr(sys, "frozen", False):
system = platform.system()
if system == "Darwin":
d = Path.home() / "Library" / "Application Support" / "ZB100ImageGenerator"
elif system == "Windows":
d = Path(os.getenv("APPDATA", str(Path.home()))) / "ZB100ImageGenerator"
else:
d = Path.home() / ".config" / "zb100imagegenerator"
else:
d = Path(".").resolve()
d.mkdir(parents=True, exist_ok=True)
return d
def get_config_path() -> Path:
return get_config_dir() / "config.json"
def load_config_safe(config_path: Path) -> Tuple[dict, str]:
"""
安全加载 config.json。
返回 (config, error):
- 成功:(合并后的 config dict, "")
- 失败但可恢复:(DEFAULT_CONFIG 副本, 错误描述);preflight 会根据返回
的 error 和 config 内容共同决定是否拦截启动
- 不抛异常
行为:
- 文件不存在 → (DEFAULT_CONFIG 副本, "") # 让 preflight 判断是否允许
- 空文件 / JSON 损坏 → 备份为 .bak.<timestamp> + 返回默认值 + error
- PermissionError / OSError → 返回默认值 + error(不备份,读都读不到)
- 顶层不是 object → 返回默认值 + error
- 正常 → defaults.update(loaded) 合并后返回(保留未知字段以兼容)
"""
config_path = Path(config_path)
if not config_path.exists():
return dict(DEFAULT_CONFIG), ""
try:
content = config_path.read_text(encoding="utf-8")
except PermissionError as e:
return dict(DEFAULT_CONFIG), f"permission denied: {e}"
except OSError as e:
return dict(DEFAULT_CONFIG), f"IO error: {e}"
if not content.strip():
_backup(config_path, reason="empty")
return dict(DEFAULT_CONFIG), "config.json was empty, using defaults"
try:
loaded = json.loads(content)
except json.JSONDecodeError as e:
_backup(config_path, reason="parse-error")
return dict(DEFAULT_CONFIG), f"JSON parse error, backed up to .bak: {e}"
if not isinstance(loaded, dict):
return dict(DEFAULT_CONFIG), "config.json top-level is not an object"
merged = dict(DEFAULT_CONFIG)
merged.update(loaded)
return merged, ""
def _backup(src: Path, reason: str) -> None:
"""把损坏 / 空的 config 文件备份,不抛异常。"""
try:
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
dst = src.with_suffix(f".json.bak.{reason}.{ts}")
shutil.copy2(src, dst)
except Exception:
pass
-- Nano Banana App Database Schema
-- 包含用户登录日志表和使用日志表
--
-- 表说明:
-- 1. nano_banana_user_log: 记录用户登录日志(IP地址、设备名称、登录时间)
-- 2. nano_banana_user_use_log: 记录用户生图操作日志(prompt、结果、状态、错误信息)
-- 创建用户登录日志表
CREATE TABLE `nano_banana_user_log` (
`user_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户名',
`local_ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '局域网IP地址',
`public_ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '公网IP地址(可为空)',
`device_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备名称',
`login_time` datetime COLLATE utf8mb4_unicode_ci DEFAULT CURRENT_TIMESTAMP COMMENT '登录时间',
INDEX `idx_user_name` (`user_name`),
INDEX `idx_login_time` (`login_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录日志表';
-- 数据迁移脚本(如果表已存在)
ALTER TABLE `nano_banana_user_log`
ADD COLUMN `local_ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '局域网IP地址' AFTER `user_name`,
ADD COLUMN `public_ip` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '公网IP地址' AFTER `local_ip`,
MODIFY COLUMN `device_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备名称',
ADD INDEX `idx_user_name` (`user_name`),
ADD INDEX `idx_login_time` (`login_time`);
-- 创建用户使用日志表
CREATE TABLE `nano_banana_user_use_log` (
`id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
`record_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间',
`user_name` VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户名',
`device_name` VARCHAR(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '设备名称',
`prompt` TEXT COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户请求的 Prompt',
`result_path` VARCHAR(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '返回数据地址(成功时为图片路径)',
`status` ENUM('success', 'failure') COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作状态',
`error_message` TEXT COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '失败时的错误信息',
INDEX `idx_user_name` (`user_name`),
INDEX `idx_record_time` (`record_time`),
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户使用日志表';
\ No newline at end of file
-- =====================================================================
-- Migration: add audit log columns (2026-04-21)
-- Change: add-audit-log-reliability
-- Purpose: 为审计日志表新增 model / duration_ms / finish_reason 三列,
-- 便于按模型、耗时、失败原因做分析和定位。
--
-- 执行者:@柴进(在 RDS 上执行,必须早于新版本下发)
-- 执行后:客户端新版启动时 preflight 会校验这三列存在,否则拦截启动。
-- =====================================================================
USE `saas_user`;
ALTER TABLE `nano_banana_user_use_log`
ADD COLUMN `model` VARCHAR(64) NULL COMMENT '本次生成使用的 Gemini 模型 ID',
ADD COLUMN `duration_ms` INT NULL COMMENT 'Worker start 到 emit 的耗时(毫秒)',
ADD COLUMN `finish_reason` VARCHAR(64) NULL COMMENT 'Gemini 响应 candidates[0].finish_reason';
-- 回滚(仅开发 / 灰度回退场景,生产不建议删列):
-- ALTER TABLE `nano_banana_user_use_log`
-- DROP COLUMN `model`,
-- DROP COLUMN `duration_ms`,
-- DROP COLUMN `finish_reason`;
"""
启动门禁:保证审计日志上传的所有前置条件都成立。
任一失败即阻止应用进入主流程,对用户只显示一句"应用启动失败,请联系 @柴进"。
详细错误脱敏后写入 logs/preflight_error.log。
"""
from __future__ import annotations
import logging
import re
import sys
import traceback
from datetime import datetime
from pathlib import Path
from typing import Tuple
import pymysql
from config_util import load_config_safe
logger = logging.getLogger(__name__)
REQUIRED_DB_FIELDS = ("host", "port", "user", "password", "database")
REQUIRED_TABLES = ("nano_banana_user_use_log", "nano_banana_user_log")
REQUIRED_USE_LOG_COLUMNS = (
"user_name", "device_name", "prompt", "result_path", "status",
"error_message", "model", "duration_ms", "finish_reason",
)
REQUIRED_LOGIN_LOG_COLUMNS = (
"user_name", "local_ip", "public_ip", "device_name", "login_time",
)
def preflight_check(config_path: Path, audit_queue_path: Path) -> Tuple[bool, str, dict]:
"""
返回 (ok, error_detail, config)。
- ok=True: 一切就绪,调用方可以继续启动
- ok=False: error_detail 为详细错误描述(未脱敏;handle_preflight_failure 会脱敏后落盘)
- config: 成功时为可用 config dict;失败时可能为部分加载或 DEFAULT_CONFIG
"""
# 1. config.json
try:
config, load_err = load_config_safe(config_path)
except Exception as e:
return False, f"config load crashed:\n{traceback.format_exc()}", {}
if load_err:
return False, f"config load error: {load_err}", config
# 2. db_config 字段完整
db = config.get("db_config")
if not db or not isinstance(db, dict):
return False, "config.json 缺少 db_config 字段或格式错误", config
missing = [k for k in REQUIRED_DB_FIELDS if not db.get(k)]
if missing:
return False, f"db_config 缺少字段: {missing}", config
# 3. MySQL 连接 + SELECT 1
conn = None
try:
conn = pymysql.connect(
host=db["host"],
port=int(db["port"]),
user=db["user"],
password=db["password"],
database=db["database"],
connect_timeout=5,
read_timeout=5,
write_timeout=5,
charset="utf8mb4",
)
except Exception as e:
return False, f"MySQL connect 失败: {type(e).__name__}: {e}", config
try:
with conn.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
# 4. 表存在
for table in REQUIRED_TABLES:
try:
cur.execute(f"SELECT 1 FROM `{table}` LIMIT 1")
cur.fetchone()
except Exception as e:
return False, f"审计表 {table} 不可用: {type(e).__name__}: {e}", config
# 5. 必要列存在
ok, col_err = _check_columns(cur, db["database"], "nano_banana_user_use_log",
REQUIRED_USE_LOG_COLUMNS)
if not ok:
return False, col_err, config
ok, col_err = _check_columns(cur, db["database"], "nano_banana_user_log",
REQUIRED_LOGIN_LOG_COLUMNS)
if not ok:
return False, col_err, config
finally:
try:
conn.close()
except Exception:
pass
# 6. 本地队列目录可写
try:
audit_queue_path.parent.mkdir(parents=True, exist_ok=True)
probe = audit_queue_path.parent / ".preflight_probe"
probe.write_text("ok", encoding="utf-8")
probe.unlink()
except Exception as e:
return False, f"审计队列目录不可写 ({audit_queue_path.parent}): {e}", config
return True, "", config
def _check_columns(cur, db_name: str, table: str, required: tuple[str, ...]) -> Tuple[bool, str]:
cur.execute(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s",
(db_name, table),
)
existing = {row[0] for row in cur.fetchall()}
missing = [c for c in required if c not in existing]
if missing:
return False, f"表 {table} 缺少列: {missing}(请运行 migrations/2026-04-21_add_audit_log_columns.sql)"
return True, ""
def handle_preflight_failure(detail: str, logs_dir: Path) -> None:
"""
写入脱敏详情到 logs/preflight_error.log,显示单行对话框,sys.exit(1)。
调用此函数前必须已经创建 QApplication。
"""
from PySide6.QtWidgets import QMessageBox, QApplication
# 写日志(脱敏)
try:
logs_dir.mkdir(parents=True, exist_ok=True)
err_log = logs_dir / "preflight_error.log"
with open(err_log, "a", encoding="utf-8") as f:
f.write(f"\n===== {datetime.now().isoformat(timespec='seconds')} =====\n")
f.write(_scrub(detail))
f.write("\n")
except Exception:
pass
# 对用户:一句话
try:
app = QApplication.instance()
if app is None:
# preflight 失败比 QApplication 创建还早的极端情况(不应发生)
app = QApplication(sys.argv)
box = QMessageBox()
box.setIcon(QMessageBox.Critical)
box.setWindowTitle("启动失败")
box.setText("应用启动失败,请联系 @柴进")
box.setStandardButtons(QMessageBox.Ok)
box.exec()
except Exception:
# 最坏情况:连对话框都弹不出来
print("应用启动失败,请联系 @柴进", file=sys.stderr)
sys.exit(1)
_SCRUB_PATTERNS = [
(re.compile(r'("password"\s*:\s*)"[^"]*"'), r'\1"***"'),
(re.compile(r'("api_key"\s*:\s*)"[^"]*"'), r'\1"***"'),
(re.compile(r"(password\s*=\s*)\S+"), r"\1***"),
(re.compile(r"(api_key\s*=\s*)\S+"), r"\1***"),
]
def _scrub(detail: str) -> str:
"""从详情里擦除 password / api_key。"""
out = detail
for pat, repl in _SCRUB_PATTERNS:
out = pat.sub(repl, out)
return out