chore: initial import

This commit is contained in:
2026-04-19 14:05:40 +08:00
commit 89e9eae36e
81 changed files with 37363 additions and 0 deletions
+472
View File
@@ -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()