Files
2026-04-19 14:05:40 +08:00

473 lines
20 KiB
Python

import os
import sys
import shutil
import subprocess
import datetime
import zipfile
import json
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext
import threading
class ConfigManager:
def __init__(self):
self.config_file = "backup_config.json"
self.local_config = {
"project_root": "",
"pg_bin_path": "",
"last_backup_path": ""
}
self.env_config = {}
self.load_local_config()
# 如果有项目根目录,尝试加载 .env
if self.local_config["project_root"]:
self.load_env(os.path.join(self.local_config["project_root"], "server", ".env"))
def load_local_config(self):
"""加载本地工具配置"""
if os.path.exists(self.config_file):
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
saved = json.load(f)
self.local_config.update(saved)
except Exception as e:
print(f"加载配置文件失败: {e}")
def save_local_config(self):
"""保存本地工具配置"""
try:
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(self.local_config, f, indent=2, ensure_ascii=False)
except Exception as e:
print(f"保存配置文件失败: {e}")
def load_env(self, env_path):
"""解析 .env 文件"""
self.env_config = {}
if not os.path.exists(env_path):
return False
try:
with open(env_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
if (value.startswith('"') and value.endswith('"')) or \
(value.startswith("'") and value.endswith("'")):
value = value[1:-1]
self.env_config[key] = value
return True
except Exception:
return False
def get_env(self, key, default=None):
return self.env_config.get(key, default)
def get_upload_paths(self):
"""获取所有需要备份的文件路径"""
paths = []
server_root = os.path.join(self.local_config["project_root"], "server")
# 1. 主目录
main_dir = self.get_env('UPLOAD_DIR', 'uploads')
if os.path.isabs(main_dir):
paths.append(main_dir)
else:
paths.append(os.path.abspath(os.path.join(server_root, main_dir)))
# 2. 备用目录
fallback_str = self.get_env('UPLOAD_FALLBACK_DIRS')
if fallback_str:
fallbacks = [p.strip() for p in fallback_str.split(',') if p.strip()]
for p in fallbacks:
abs_path = p if os.path.isabs(p) else os.path.abspath(os.path.join(server_root, p))
if abs_path not in paths:
paths.append(abs_path)
# 3. 默认 uploads 目录兜底
default_uploads = os.path.abspath(os.path.join(server_root, 'uploads'))
if default_uploads not in paths:
paths.append(default_uploads)
return paths
class BackupApp:
def __init__(self, root):
self.root = root
self.root.title("ST系统 备份与恢复工具 (可配置版)")
self.root.geometry("700x600")
self.config_mgr = ConfigManager()
# 如果没有配置项目路径,尝试自动检测当前目录
if not self.config_mgr.local_config["project_root"]:
current_dir = os.path.dirname(os.path.abspath(__file__))
if os.path.exists(os.path.join(current_dir, "server", ".env")):
self.config_mgr.local_config["project_root"] = current_dir
self.config_mgr.load_env(os.path.join(current_dir, "server", ".env"))
self.config_mgr.save_local_config()
# 如果没有配置 PG 路径,尝试自动检测
if not self.config_mgr.local_config["pg_bin_path"]:
found_pg = self.find_pg_bin()
if found_pg:
self.config_mgr.local_config["pg_bin_path"] = found_pg
self.config_mgr.save_local_config()
self.setup_ui()
self.check_status()
def find_pg_bin(self):
"""尝试自动查找 PostgreSQL bin 目录"""
try:
subprocess.run(['pg_dump', '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return "SYSTEM_PATH" # 表示在系统环境变量中
except FileNotFoundError:
pass
common_paths = [
r"C:\Program Files\PostgreSQL",
r"C:\Program Files (x86)\PostgreSQL"
]
for base in common_paths:
if os.path.exists(base):
for version in os.listdir(base):
bin_path = os.path.join(base, version, "bin")
if os.path.exists(os.path.join(bin_path, "pg_dump.exe")):
return bin_path
return ""
def setup_ui(self):
# 1. 设置区域
frame_settings = tk.LabelFrame(self.root, text="环境配置", padx=10, pady=10)
frame_settings.pack(fill=tk.X, padx=10, pady=5)
# 项目路径
tk.Label(frame_settings, text="项目根目录:").grid(row=0, column=0, sticky="w")
self.entry_project = tk.Entry(frame_settings, width=50)
self.entry_project.grid(row=0, column=1, padx=5)
self.entry_project.insert(0, self.config_mgr.local_config["project_root"])
tk.Button(frame_settings, text="浏览...", command=self.browse_project).grid(row=0, column=2)
# PG 路径
tk.Label(frame_settings, text="PostgreSQL bin:").grid(row=1, column=0, sticky="w")
self.entry_pg = tk.Entry(frame_settings, width=50)
self.entry_pg.grid(row=1, column=1, padx=5)
self.entry_pg.insert(0, self.config_mgr.local_config["pg_bin_path"])
tk.Button(frame_settings, text="浏览...", command=self.browse_pg).grid(row=1, column=2)
tk.Button(frame_settings, text="保存并重新加载配置", command=self.save_settings, bg="#FF9800", fg="white").grid(row=2, column=1, pady=10)
# 2. 备份路径区域
frame_path = tk.LabelFrame(self.root, text="备份保存位置", padx=10, pady=5)
frame_path.pack(fill=tk.X, padx=10, pady=5)
default_backup = self.config_mgr.local_config["last_backup_path"] or os.getcwd()
self.lbl_backup_path = tk.Label(frame_path, text=default_backup, relief=tk.SUNKEN, anchor="w", bg="white")
self.lbl_backup_path.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10))
tk.Button(frame_path, text="更改路径...", command=self.choose_backup_dir).pack(side=tk.RIGHT)
# 3. 操作按钮
frame_actions = tk.Frame(self.root, pady=10)
frame_actions.pack(fill=tk.X)
self.btn_backup = tk.Button(frame_actions, text="一键备份", command=self.start_backup,
bg="#4CAF50", fg="white", font=("Microsoft YaHei", 12, "bold"), height=2, width=20)
self.btn_backup.pack(side=tk.LEFT, padx=40)
self.btn_restore = tk.Button(frame_actions, text="一键恢复", command=self.start_restore,
bg="#2196F3", fg="white", font=("Microsoft YaHei", 12, "bold"), height=2, width=20)
self.btn_restore.pack(side=tk.RIGHT, padx=40)
# 3.5 提示信息
frame_tips = tk.Frame(self.root)
frame_tips.pack(fill=tk.X, pady=5)
tk.Label(frame_tips, text="⚠️ 恢复注意事项:请确保数据库为空(不要运行 init-db),否则会因表已存在而失败。",
fg="red", font=("Microsoft YaHei", 9)).pack()
# 4. 信息显示
self.lbl_status = tk.Label(self.root, text="正在检查配置...", fg="blue")
self.lbl_status.pack(pady=5)
# 5. 日志
tk.Label(self.root, text="运行日志:").pack(anchor="w", padx=10)
self.log_area = scrolledtext.ScrolledText(self.root, height=12)
self.log_area.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10))
def log(self, message):
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
self.log_area.insert(tk.END, f"[{timestamp}] {message}\n")
self.log_area.see(tk.END)
def browse_project(self):
path = filedialog.askdirectory(title="选择项目根目录 (包含 server 文件夹)")
if path:
self.entry_project.delete(0, tk.END)
self.entry_project.insert(0, path)
def browse_pg(self):
path = filedialog.askdirectory(title="选择 PostgreSQL bin 目录")
if path:
self.entry_pg.delete(0, tk.END)
self.entry_pg.insert(0, path)
def save_settings(self):
project_root = self.entry_project.get().strip()
pg_bin = self.entry_pg.get().strip()
self.config_mgr.local_config["project_root"] = project_root
self.config_mgr.local_config["pg_bin_path"] = pg_bin
self.config_mgr.save_local_config()
# 重新加载 .env
env_path = os.path.join(project_root, "server", ".env")
if self.config_mgr.load_env(env_path):
self.log("配置保存成功,已加载 .env 文件")
else:
self.log("配置保存成功,但无法找到或解析 .env 文件!")
self.check_status()
def check_status(self):
ready = True
msg = []
if not self.config_mgr.env_config:
ready = False
msg.append("未加载 .env 配置")
else:
db = self.config_mgr.get_env('DB_DATABASE')
msg.append(f"数据库: {db}")
pg_path = self.config_mgr.local_config["pg_bin_path"]
if not pg_path and self.find_pg_bin() == "":
msg.append("PG工具: 未配置")
ready = False
elif pg_path == "SYSTEM_PATH":
msg.append("PG工具: 系统环境变量")
else:
msg.append(f"PG工具: {pg_path}")
if ready:
self.lbl_status.config(text=" | ".join(msg), fg="green")
self.btn_backup.config(state=tk.NORMAL)
self.btn_restore.config(state=tk.NORMAL)
else:
self.lbl_status.config(text="配置不完整: " + " | ".join(msg), fg="red")
self.btn_backup.config(state=tk.DISABLED)
self.btn_restore.config(state=tk.DISABLED)
def choose_backup_dir(self):
path = filedialog.askdirectory(initialdir=self.lbl_backup_path.cget("text"), title="选择备份保存位置")
if path:
self.lbl_backup_path.config(text=path)
self.config_mgr.local_config["last_backup_path"] = path
self.config_mgr.save_local_config()
self.log(f"备份保存路径已更改为: {path}")
def get_pg_cmd(self, cmd):
pg_path = self.config_mgr.local_config["pg_bin_path"]
if pg_path and pg_path != "SYSTEM_PATH":
return os.path.join(pg_path, cmd)
return cmd
def start_backup(self):
if not messagebox.askyesno("确认备份", "确定要开始备份吗?"):
return
threading.Thread(target=self.run_backup, daemon=True).start()
def run_backup(self):
try:
self.log("=== 开始备份 ===")
save_path = self.lbl_backup_path.cget("text")
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
# 临时目录
temp_dir = os.path.join(save_path, f"temp_backup_{timestamp}")
os.makedirs(temp_dir, exist_ok=True)
# 1. 数据库
self.log("正在导出数据库...")
db_file = os.path.join(temp_dir, "database.sql")
env = os.environ.copy()
env['PGPASSWORD'] = self.config_mgr.get_env('DB_PASSWORD', '')
cmd = [
self.get_pg_cmd('pg_dump'),
'-h', self.config_mgr.get_env('DB_HOST', 'localhost'),
'-p', self.config_mgr.get_env('DB_PORT', '5432'),
'-U', self.config_mgr.get_env('DB_USER', 'postgres'),
'-F', 'p', '-f', db_file,
self.config_mgr.get_env('DB_DATABASE', 'stsystem')
]
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo)
out, err = proc.communicate()
if proc.returncode != 0:
raise Exception(f"数据库备份失败: {err.decode('gbk', errors='ignore')}")
# 2. 文件
self.log("正在备份文件...")
files_dir = os.path.join(temp_dir, "files")
os.makedirs(files_dir)
paths = self.config_mgr.get_upload_paths()
for src in paths:
if os.path.exists(src):
dirname = os.path.basename(src)
dest = os.path.join(files_dir, dirname)
if os.path.exists(dest): dest += f"_{paths.index(src)}"
shutil.copytree(src, dest)
# 3. 打包
self.log("正在打包...")
zip_name = f"STSystem_Backup_{timestamp}.zip"
zip_full_path = os.path.join(save_path, zip_name)
with zipfile.ZipFile(zip_full_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(temp_dir):
for file in files:
fp = os.path.join(root, file)
arcname = os.path.relpath(fp, temp_dir)
zf.write(fp, arcname)
shutil.rmtree(temp_dir)
self.log(f"备份成功: {zip_full_path}")
messagebox.showinfo("成功", f"备份已保存:\n{zip_full_path}")
except Exception as e:
self.log(f"错误: {e}")
messagebox.showerror("错误", str(e))
def start_restore(self):
zip_path = filedialog.askopenfilename(filetypes=[("Zip", "*.zip")])
if not zip_path: return
msg = (
"⚠️ 高风险操作警告 ⚠️\n\n"
"1. 此操作将覆盖数据库和文件。\n"
"2. 请确保目标数据库是【空数据库】(不要运行 init-db)。\n"
" 如果数据库中已有表,恢复将失败!\n"
"3. 请确保已停止 Node 后端服务。\n\n"
"确定要继续吗?"
)
if not messagebox.askyesno("确认恢复", msg, icon='warning'):
return
threading.Thread(target=self.run_restore, args=(zip_path,), daemon=True).start()
def run_restore(self, zip_path):
try:
self.log("=== 开始恢复 ===")
temp_dir = os.path.join(os.path.dirname(zip_path), "temp_restore_extract")
if os.path.exists(temp_dir): shutil.rmtree(temp_dir)
os.makedirs(temp_dir)
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(temp_dir)
# 1. 数据库
self.log("恢复数据库...")
db_file = os.path.join(temp_dir, "database.sql")
if not os.path.exists(db_file): raise Exception("未找到 database.sql")
env = os.environ.copy()
env['PGPASSWORD'] = self.config_mgr.get_env('DB_PASSWORD', '')
cmd = [
self.get_pg_cmd('psql'),
'-h', self.config_mgr.get_env('DB_HOST', 'localhost'),
'-p', self.config_mgr.get_env('DB_PORT', '5432'),
'-U', self.config_mgr.get_env('DB_USER', 'postgres'),
'-d', self.config_mgr.get_env('DB_DATABASE', 'stsystem'),
'-v', 'ON_ERROR_STOP=1', # 遇到错误立即停止
'-f', db_file
]
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo)
out, err = proc.communicate()
# 记录输出以便调试
if out: self.log(f"PSQL OUT: {out.decode('gbk', errors='ignore')}")
if err: self.log(f"PSQL ERR: {err.decode('gbk', errors='ignore')}")
if proc.returncode != 0:
err_msg = err.decode('gbk', errors='ignore')
if "already exists" in err_msg or "已经存在" in err_msg:
raise Exception(f"恢复失败: 数据库中已存在表或数据。\n请先清空数据库,或使用 scripts 中的脚本重置数据库。\n详细错误: {err_msg}")
raise Exception(f"数据库恢复失败: {err_msg}")
# 1.5 修复序列 (防止主键冲突)
self.log("正在修复数据库序列...")
fix_seq_sql = """
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND column_default LIKE 'nextval%'
LOOP
EXECUTE 'SELECT setval(' || quote_literal(pg_get_serial_sequence(r.table_name, r.column_name)) || ', COALESCE((SELECT MAX(' || quote_ident(r.column_name) || ') FROM ' || quote_ident(r.table_name) || '), 1), false)';
END LOOP;
END $$;
"""
cmd_fix = [
self.get_pg_cmd('psql'),
'-h', self.config_mgr.get_env('DB_HOST', 'localhost'),
'-p', self.config_mgr.get_env('DB_PORT', '5432'),
'-U', self.config_mgr.get_env('DB_USER', 'postgres'),
'-d', self.config_mgr.get_env('DB_DATABASE', 'stsystem'),
'-c', fix_seq_sql
]
proc_fix = subprocess.Popen(cmd_fix, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo)
out_fix, err_fix = proc_fix.communicate()
if proc_fix.returncode != 0:
self.log(f"警告: 序列修复失败 (但这可能不影响使用): {err_fix.decode('gbk', errors='ignore')}")
else:
self.log("序列修复完成。")
# 2. 文件
self.log("恢复文件...")
# 恢复到主目录
server_root = os.path.join(self.config_mgr.local_config["project_root"], "server")
target_dir = self.config_mgr.get_env('UPLOAD_DIR', 'uploads')
if not os.path.isabs(target_dir):
target_dir = os.path.abspath(os.path.join(server_root, target_dir))
if not os.path.exists(target_dir): os.makedirs(target_dir)
files_src = os.path.join(temp_dir, "files")
if os.path.exists(files_src):
for root, dirs, files in os.walk(files_src):
for file in files:
shutil.copy2(os.path.join(root, file), os.path.join(target_dir, file))
shutil.rmtree(temp_dir)
self.log("恢复成功!")
messagebox.showinfo("成功", "恢复完成!请重启服务。")
except Exception as e:
self.log(f"错误: {e}")
messagebox.showerror("错误", str(e))
if __name__ == "__main__":
root = tk.Tk()
app = BackupApp(root)
root.mainloop()