473 lines
20 KiB
Python
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()
|