chore: initial import
This commit is contained in:
+472
@@ -0,0 +1,472 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user