From 52ebbfbeae4d90a314bdebbf64cbbeaeb845546e Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 2 Aug 2026 09:07:00 +0800 Subject: [PATCH] feat: add database backup and restore in system settings Co-authored-by: Cursor --- .env.example | 1 + apps/api/routes/settings.py | 84 +++++++++++- deploy/lib/common.sh | 1 + docker-compose.yml | 2 + packages/config/settings.py | 5 + packages/db/backup.py | 87 ++++++++++++ tests/test_backup.py | 46 +++++++ web/dist/index.html | 119 ++++++++++++++++- web/src/api/client.ts | 41 ++++++ web/src/pages/Settings.tsx | 259 +++++++++++++++++++++++++++--------- web/src/styles.css | 14 ++ 11 files changed, 594 insertions(+), 65 deletions(-) create mode 100644 packages/db/backup.py create mode 100644 tests/test_backup.py diff --git a/.env.example b/.env.example index fc08cd6..a205fc5 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ AUTH_SECRET=change-me ADMIN_USERNAME=admin ADMIN_PASSWORD=admin123 ENV_FILE=/app/.env +BACKUP_DIR=/root/market_intel_backups # ---- 采集(OKX 只读;公开行情可留空 Key)---- OKX_API_KEY= diff --git a/apps/api/routes/settings.py b/apps/api/routes/settings.py index 3021bf5..f54b59e 100644 --- a/apps/api/routes/settings.py +++ b/apps/api/routes/settings.py @@ -1,4 +1,4 @@ -"""系统设置:管理员账号。""" +"""系统设置:管理员账号、数据备份与恢复。""" from __future__ import annotations @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field from apps.api.auth import auth_disabled, require_auth, verify_credentials from packages.config import get_settings, reload_settings from packages.config.env_file import update_env_file +from packages.db.backup import backup_db, ensure_backup_dir, list_backups, resolve_backup_file, restore_db router = APIRouter( prefix="/settings", @@ -26,6 +27,87 @@ class UpdateAccountBody(BaseModel): new_password: str | None = Field(default=None, max_length=256) +class BackupActionBody(BaseModel): + current_password: str = Field(min_length=1, max_length=256) + + +class RestoreBody(BaseModel): + current_password: str = Field(min_length=1, max_length=256) + backup_name: str = Field(min_length=1, max_length=128) + + +def _verify_password_or_401(current_password: str) -> None: + s = get_settings() + if auth_disabled(): + raise HTTPException(status_code=400, detail="auth disabled") + if not verify_credentials(s.admin_username, current_password): + raise HTTPException(status_code=401, detail="current password incorrect") + + +@router.get("/backup") +def get_backup_info() -> dict: + s = get_settings() + backup_dir = s.backup_dir_path + writable = False + try: + ensure_backup_dir(backup_dir) + probe = backup_dir / ".write_probe" + probe.write_text("", encoding="utf-8") + probe.unlink(missing_ok=True) + writable = True + items = list_backups(backup_dir) + except OSError as e: + items = list_backups(backup_dir) if backup_dir.is_dir() else [] + if not items: + raise HTTPException(status_code=500, detail=f"backup dir error: {e}") from e + return { + "backup_dir": str(backup_dir), + "db_path": str(s.db_path), + "writable": writable, + "backups": items, + } + + +@router.post("/backup") +def create_backup(body: BackupActionBody) -> dict: + _verify_password_or_401(body.current_password) + s = get_settings() + try: + dest = backup_db(s.db_path, s.backup_dir_path, tz=s.tz) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except OSError as e: + raise HTTPException(status_code=500, detail=f"backup failed: {e}") from e + st = dest.stat() + return { + "ok": True, + "message": "备份完成", + "name": dest.name, + "path": str(dest), + "size_bytes": st.st_size, + } + + +@router.post("/restore") +def restore_backup(body: RestoreBody) -> dict: + _verify_password_or_401(body.current_password) + s = get_settings() + try: + backup_path = resolve_backup_file(s.backup_dir_path, body.backup_name.strip()) + restore_db(backup_path, s.db_path) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except OSError as e: + raise HTTPException(status_code=500, detail=f"restore failed: {e}") from e + return { + "ok": True, + "message": f"已从 {body.backup_name} 恢复数据库", + "restored_from": body.backup_name, + } + + @router.get("/account") def get_account() -> dict: s = get_settings() diff --git a/deploy/lib/common.sh b/deploy/lib/common.sh index f16559d..3fa16ad 100644 --- a/deploy/lib/common.sh +++ b/deploy/lib/common.sh @@ -281,6 +281,7 @@ compose() { compose_up_build() { step "docker compose up -d --build" + mkdir -p /root/market_intel_backups 2>/dev/null || true compose up -d --build } diff --git a/docker-compose.yml b/docker-compose.yml index 6ed0238..5163ce9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,11 +24,13 @@ services: TZ: Asia/Shanghai MI_DB_PATH: /app/data/market_intel.db ENV_FILE: /app/.env + BACKUP_DIR: /root/market_intel_backups ports: - "${MI_PORT:-5170}:5170" volumes: - mi_data:/app/data - ./.env:/app/.env + - /root/market_intel_backups:/root/market_intel_backups depends_on: - collector restart: unless-stopped diff --git a/packages/config/settings.py b/packages/config/settings.py index 10020c8..b61e752 100644 --- a/packages/config/settings.py +++ b/packages/config/settings.py @@ -23,6 +23,7 @@ class Settings(BaseSettings): admin_username: str = Field(default="admin", alias="ADMIN_USERNAME") admin_password: str = Field(default="admin123", alias="ADMIN_PASSWORD") env_file: str = Field(default=".env", alias="ENV_FILE") + backup_dir: str = Field(default="/root/market_intel_backups", alias="BACKUP_DIR") # OKX okx_api_key: str = Field(default="", alias="OKX_API_KEY") @@ -62,6 +63,10 @@ class Settings(BaseSettings): def env_file_path(self) -> Path: return Path(self.env_file) + @property + def backup_dir_path(self) -> Path: + return Path(self.backup_dir) + def reload_settings() -> Settings: get_settings.cache_clear() diff --git a/packages/db/backup.py b/packages/db/backup.py new file mode 100644 index 0000000..dc33b2a --- /dev/null +++ b/packages/db/backup.py @@ -0,0 +1,87 @@ +"""SQLite 在线备份与恢复。""" + +from __future__ import annotations + +import re +import sqlite3 +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +_BACKUP_NAME_RE = re.compile(r"^market_intel_\d{8}_\d{6}\.db$") + + +def _now_stamp(tz: str = "Asia/Shanghai") -> str: + return datetime.now(ZoneInfo(tz)).strftime("%Y%m%d_%H%M%S") + + +def ensure_backup_dir(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + return path + + +def is_valid_backup_name(name: str) -> bool: + return bool(_BACKUP_NAME_RE.fullmatch(name)) + + +def resolve_backup_file(backup_dir: Path, name: str) -> Path: + if not is_valid_backup_name(name): + raise ValueError("invalid backup filename") + path = (backup_dir / name).resolve() + root = backup_dir.resolve() + if not str(path).startswith(str(root)): + raise ValueError("invalid backup path") + if not path.is_file(): + raise FileNotFoundError(name) + return path + + +def backup_db(db_path: Path, backup_dir: Path, tz: str = "Asia/Shanghai") -> Path: + ensure_backup_dir(backup_dir) + if not db_path.is_file(): + raise FileNotFoundError(f"database not found: {db_path}") + + dest = backup_dir / f"market_intel_{_now_stamp(tz)}.db" + src = sqlite3.connect(str(db_path)) + try: + dst = sqlite3.connect(str(dest)) + try: + src.backup(dst) + finally: + dst.close() + finally: + src.close() + return dest + + +def restore_db(backup_path: Path, db_path: Path) -> None: + if not backup_path.is_file(): + raise FileNotFoundError(f"backup not found: {backup_path}") + + src = sqlite3.connect(str(backup_path)) + try: + dst = sqlite3.connect(str(db_path)) + try: + src.backup(dst) + finally: + dst.close() + finally: + src.close() + + +def list_backups(backup_dir: Path) -> list[dict]: + if not backup_dir.is_dir(): + return [] + items: list[dict] = [] + for p in sorted(backup_dir.glob("market_intel_*.db"), reverse=True): + if not p.is_file() or not is_valid_backup_name(p.name): + continue + st = p.stat() + items.append( + { + "name": p.name, + "size_bytes": st.st_size, + "modified_ms": int(st.st_mtime * 1000), + } + ) + return items diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..0016cda --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import pytest + +from packages.db.backup import ( + backup_db, + is_valid_backup_name, + list_backups, + resolve_backup_file, + restore_db, +) + + +def test_backup_roundtrip(tmp_path: Path): + db = tmp_path / "live.db" + backup_dir = tmp_path / "backups" + conn = __import__("sqlite3").connect(str(db)) + conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)") + conn.execute("INSERT INTO t(v) VALUES ('hello')") + conn.commit() + conn.close() + + dest = backup_db(db, backup_dir, tz="UTC") + assert dest.is_file() + assert is_valid_backup_name(dest.name) + + conn = __import__("sqlite3").connect(str(db)) + conn.execute("DELETE FROM t") + conn.commit() + conn.close() + + restore_db(dest, db) + conn = __import__("sqlite3").connect(str(db)) + row = conn.execute("SELECT v FROM t").fetchone() + conn.close() + assert row[0] == "hello" + + items = list_backups(backup_dir) + assert len(items) == 1 + assert items[0]["name"] == dest.name + assert resolve_backup_file(backup_dir, dest.name) == dest.resolve() + + +def test_invalid_backup_name(tmp_path: Path): + with pytest.raises(ValueError): + resolve_backup_file(tmp_path, "../etc/passwd") diff --git a/web/dist/index.html b/web/dist/index.html index 8fe6e82..d733ed8 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -42,11 +42,16 @@ pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; } .center-page { display: flex; justify-content: center; align-items: flex-start; min-height: 50vh; padding: 1.5rem 0 3rem; } .settings-card { width: 100%; max-width: 420px; } + .settings-stack { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 1.25rem; } .settings-title { font-size: 1.15rem; font-weight: 650; margin-bottom: 0.75rem; } + .settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; } .field { display: block; margin-bottom: 1rem; } .field-input { width: 100%; margin-top: 0.4rem; padding: 0.55rem 0.65rem; border-radius: 6px; border: 1px solid #243041; background: #0c1117; color: var(--text); } .btn-primary { margin-top: 0.5rem; padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: var(--accent); color: #fff; cursor: pointer; } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } + .btn-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; } + .btn-danger { padding: 0.55rem 1.2rem; border-radius: 6px; border: 0; background: #c45656; color: #fff; cursor: pointer; } + .btn-danger:disabled { opacity: 0.5; cursor: not-allowed; } .form-err { color: var(--bad); font-size: 0.85rem; } .form-ok { color: var(--ok); font-size: 0.85rem; } @@ -126,9 +131,10 @@ @@ -233,6 +257,50 @@ } }); + function fmtBytes(n) { + if (n < 1024) return n + " B"; + if (n < 1048576) return (n / 1024).toFixed(1) + " KB"; + return (n / 1048576).toFixed(2) + " MB"; + } + function fmtBackupTime(ms) { + try { return new Date(ms).toLocaleString("zh-CN", { hour12: false }); } catch { return "—"; } + } + function renderBackupSelect(backups) { + const sel = document.getElementById("backupSelect"); + sel.innerHTML = ""; + if (!backups || !backups.length) { + sel.innerHTML = ''; + return; + } + backups.forEach(b => { + const opt = document.createElement("option"); + opt.value = b.name; + opt.textContent = b.name + " · " + fmtBytes(b.size_bytes) + " · " + fmtBackupTime(b.modified_ms); + sel.appendChild(opt); + }); + } + async function loadBackupInfo() { + document.getElementById("backupErr").textContent = ""; + document.getElementById("backupOk").textContent = ""; + const r = await apiFetch("/api/settings/backup"); + if (!r.ok) throw new Error("backup info " + r.status); + const info = await r.json(); + state.backupWritable = !!info.writable; + document.getElementById("backupDir").textContent = info.backup_dir || "/root/market_intel_backups"; + const dirErr = document.getElementById("backupDirErr"); + if (!info.writable) { + dirErr.textContent = "备份目录不可写,请检查 /root/market_intel_backups 权限与 Docker 挂载"; + dirErr.classList.remove("hidden"); + } else { + dirErr.classList.add("hidden"); + } + renderBackupSelect(info.backups || []); + const disabled = !state.authRequired || !info.writable; + ["backupPass","backupSelect","backupBtn","restoreBtn"].forEach(id => { + document.getElementById(id).disabled = disabled; + }); + } + async function loadSettings() { document.getElementById("settingsErr").textContent = ""; document.getElementById("settingsOk").textContent = ""; @@ -249,6 +317,7 @@ ["setUsername","setCurPass","setNewPass","setNewPass2","settingsSaveBtn"].forEach(id => { document.getElementById(id).disabled = disabled; }); + await loadBackupInfo(); } catch (e) { document.getElementById("settingsErr").textContent = String(e); } @@ -292,6 +361,50 @@ } }); + document.getElementById("backupBtn").addEventListener("click", async () => { + const pwd = document.getElementById("backupPass").value; + document.getElementById("backupErr").textContent = ""; + document.getElementById("backupOk").textContent = ""; + if (!pwd) { document.getElementById("backupErr").textContent = "请输入当前密码"; return; } + try { + const r = await apiFetch("/api/settings/backup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ current_password: pwd }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.detail || "backup failed"); + document.getElementById("backupOk").textContent = (d.message || "备份完成") + ":" + d.name; + document.getElementById("backupPass").value = ""; + await loadBackupInfo(); + } catch (err) { + document.getElementById("backupErr").textContent = String(err.message || err); + } + }); + + document.getElementById("restoreBtn").addEventListener("click", async () => { + const pwd = document.getElementById("backupPass").value; + const name = document.getElementById("backupSelect").value; + document.getElementById("backupErr").textContent = ""; + document.getElementById("backupOk").textContent = ""; + if (!pwd) { document.getElementById("backupErr").textContent = "请输入当前密码"; return; } + if (!name) { document.getElementById("backupErr").textContent = "请选择要恢复的备份"; return; } + if (!confirm("确认从 " + name + " 恢复数据库?当前数据将被覆盖。")) return; + try { + const r = await apiFetch("/api/settings/restore", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ current_password: pwd, backup_name: name }), + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.detail || "restore failed"); + document.getElementById("backupOk").textContent = d.message || "恢复完成"; + document.getElementById("backupPass").value = ""; + } catch (err) { + document.getElementById("backupErr").textContent = String(err.message || err); + } + }); + document.querySelectorAll("#rangeSeg button").forEach(b => b.addEventListener("click", () => { state.range = b.dataset.range; document.querySelectorAll("#rangeSeg button").forEach(x => x.classList.toggle("active", x === b)); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 3a827cf..25355ae 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -86,6 +86,47 @@ export async function updateAccountSettings(body: { return r.json(); } +export type BackupItem = { + name: string; + size_bytes: number; + modified_ms: number; +}; + +export type BackupInfo = { + backup_dir: string; + db_path: string; + writable: boolean; + backups: BackupItem[]; +}; + +export async function fetchBackupInfo(): Promise { + const r = await apiFetch("/api/settings/backup"); + if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "backup info failed"); + return r.json(); +} + +export async function createBackup(current_password: string): Promise<{ ok: boolean; message: string; name: string; path: string }> { + const r = await apiFetch("/api/settings/backup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ current_password }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.detail || "backup failed"); + return d; +} + +export async function restoreBackup(current_password: string, backup_name: string): Promise<{ ok: boolean; message: string }> { + const r = await apiFetch("/api/settings/restore", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ current_password, backup_name }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.detail || "restore failed"); + return d; +} + export async function logout(): Promise { setToken(null); await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 46206ec..1cbb9c3 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -1,12 +1,30 @@ -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useCallback, useEffect, useState } from "react"; import { + BackupInfo, + createBackup, fetchAccountSettings, + fetchBackupInfo, logout, + restoreBackup, updateAccountSettings, } from "../api/client"; import AppNav from "../components/AppNav"; import LoginGate from "../components/LoginGate"; +function fmtBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(2)} MB`; +} + +function fmtTime(ms: number): string { + try { + return new Date(ms).toLocaleString("zh-CN", { hour12: false }); + } catch { + return "—"; + } +} + export default function SettingsPage() { const [needLogin, setNeedLogin] = useState(false); const [ready, setReady] = useState(false); @@ -20,12 +38,27 @@ export default function SettingsPage() { const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); + const [backupInfo, setBackupInfo] = useState(null); + const [backupPassword, setBackupPassword] = useState(""); + const [selectedBackup, setSelectedBackup] = useState(""); + const [backupMsg, setBackupMsg] = useState(null); + const [backupErr, setBackupErr] = useState(null); + const [backupBusy, setBackupBusy] = useState(false); + + const loadBackupInfo = useCallback(async () => { + const info = await fetchBackupInfo(); + setBackupInfo(info); + setSelectedBackup((prev) => prev || info.backups[0]?.name || ""); + }, []); + useEffect(() => { - fetchAccountSettings() - .then((d) => { - setUsername(d.username); - setNewUsername(d.username); - setAuthRequired(d.auth_required); + Promise.all([fetchAccountSettings(), fetchBackupInfo()]) + .then(([account, backup]) => { + setUsername(account.username); + setNewUsername(account.username); + setAuthRequired(account.auth_required); + setBackupInfo(backup); + setSelectedBackup(backup.backups[0]?.name || ""); setNeedLogin(false); setReady(true); }) @@ -71,6 +104,42 @@ export default function SettingsPage() { } }; + const runBackup = async () => { + setBackupBusy(true); + setBackupErr(null); + setBackupMsg(null); + try { + const res = await createBackup(backupPassword); + setBackupMsg(`${res.message}:${res.name}`); + setBackupPassword(""); + await loadBackupInfo(); + } catch (ex) { + setBackupErr(String(ex)); + } finally { + setBackupBusy(false); + } + }; + + const runRestore = async () => { + if (!selectedBackup) { + setBackupErr("请选择要恢复的备份"); + return; + } + if (!window.confirm(`确认从 ${selectedBackup} 恢复数据库?当前数据将被覆盖。`)) return; + setBackupBusy(true); + setBackupErr(null); + setBackupMsg(null); + try { + const res = await restoreBackup(backupPassword, selectedBackup); + setBackupMsg(res.message); + setBackupPassword(""); + } catch (ex) { + setBackupErr(String(ex)); + } finally { + setBackupBusy(false); + } + }; + if (!ready) { return (
@@ -85,70 +154,138 @@ export default function SettingsPage() { return (
比特骆驼行情采集分析
-
系统设置 · 管理员账号
+
系统设置 · 账号与数据
-
-
系统设置
- {!authRequired && ( -

当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。

- )} +
+ +
账号设置
+ {!authRequired && ( +

当前鉴权已关闭(AUTH_SECRET=disabled),请在 .env 中修改。

+ )} - + - + - + - + - {err &&

{err}

} - {msg &&

{msg}

} + {err &&

{err}

} + {msg &&

{msg}

} - - + + + +
+
数据备份与恢复
+

+ 备份目录:{backupInfo?.backup_dir || "/root/market_intel_backups"} +

+ {!backupInfo?.writable && ( +

备份目录不可写,请检查服务器 /root/market_intel_backups 权限与 Docker 挂载。

+ )} + + + + {backupInfo && backupInfo.backups.length > 0 ? ( + + ) : ( +

尚无备份文件

+ )} + + {backupErr &&

{backupErr}

} + {backupMsg &&

{backupMsg}

} + +
+ + +
+
+
); diff --git a/web/src/styles.css b/web/src/styles.css index 0295eac..3a174fe 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -32,8 +32,22 @@ a { color: var(--accent); text-decoration: none; } padding: 1.5rem 0 3rem; } .settings-card { width: 100%; max-width: 420px; } +.settings-stack { width: 100%; max-width: 420px; display: flex; flex-direction: column; gap: 1.25rem; } .settings-title { font-size: 1.15rem; font-weight: 650; margin-bottom: 0.25rem; } .settings-hint { color: var(--muted); font-size: 0.85rem; line-height: 1.5; margin: 0 0 1rem; } +.settings-hint code { color: var(--text); font-size: 0.82rem; } +.btn-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; } +.btn-danger { + padding: 0.55rem 1.2rem; + border-radius: 6px; + border: 0; + background: #c45656; + color: #fff; + cursor: pointer; + font-size: 0.95rem; +} +.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; } +select.field-input { cursor: pointer; } .field { display: block; margin-bottom: 1rem; } .field-input { display: block;