From 6e94db0c2bc4f5004aca7808f31c20710bbb1c1b Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 29 Jun 2026 19:16:11 +0800 Subject: [PATCH] feat(admin): add backup/restore tab (dual-mode + task progress polling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 dashmap 依赖。create_backup 探测 pg_dump 优先(含 schema,签名头前置)、 不可用回退纯 SQL(COPY TO STDOUT 逐表,按表精确进度)。restore_backup 校验 签名头(仅本系统备份)+ 路径穿越防护 + 二次确认,探测 psql 执行。 list_backups/delete_backup 管理备份,GET /api/database/backups/{name} 下载 (admin 鉴权 + 白名单)。tasks.rs DashMap 进度表(1h 惰性 GC),前端每 1.5s 轮询 get_task_progress 显示进度条/stage,完成后刷新列表。 backups/ gitignored 不直接暴露。AGENTS.md 同步新增 CodeMirror 子项目与 数据库管理章节。 --- .gitignore | 3 + AGENTS.md | 24 ++ Cargo.lock | 1 + Cargo.toml | 2 + src/api/database/backup.rs | 549 +++++++++++++++++++++++++++++++++++++ src/api/database/mod.rs | 4 + src/api/database/tasks.rs | 120 ++++++++ src/main.rs | 5 + src/pages/admin/system.rs | 340 ++++++++++++++++++++++- 9 files changed, 1047 insertions(+), 1 deletion(-) create mode 100644 src/api/database/backup.rs create mode 100644 src/api/database/tasks.rs diff --git a/.gitignore b/.gitignore index 001d4e9..d96028a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ uploads/* !uploads/.gitkeep uploads/.cache/ +# Database backups +backups/ + profile.json.gz # Git worktrees diff --git a/AGENTS.md b/AGENTS.md index 2ebb69a..401f3cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,6 +162,30 @@ Core JavaScript bundle in `libs/yggdrasil-core/`, built as an IIFE library expos Do not edit `public/yggdrasil-core/` — they are build artifacts. +## CodeMirror Editor Subproject + +CodeMirror 6 code editor in `libs/codemirror-editor/`, built as an IIFE library exposing `window.CodeMirrorEditor` (object literal `{ create(containerId, options) }`) and `window.EditorOptions` (a class so `new EditorOptions()` survives TS erasure from wasm). Mirrors the tiptap-editor packaging pattern exactly. + +- Output: `public/codemirror/` (`editor.js`, `editor.js.map`). No CSS file — themes are JS `Extension`s from `@catppuccin/codemirror` (Latte light / Mocha dark, matching the syntax-highlighting `themes/`). +- `make build` runs `pnpm ci --include=dev && pnpm run build` inside `libs/codemirror-editor`; the `build` script is `tsc --noEmit && vite build` (Vite 8 / Rolldown, type-check before bundle). Output is IIFE (`formats: ['iife']`, `exports: 'default'`). +- Features: `@codemirror/lang-sql` with live schema autocomplete (schema injected via `set_schema` from `get_db_schema` server function), `@replit/codemirror-vim` keymap (injected before other keymaps), Catppuccin theme hot-swap via `Compartment.reconfigure` (no instance rebuild — preserves Vim state/cursor/undo). +- Rust bridge: `src/codemirror_bridge.rs` mirrors `src/tiptap_bridge.rs` — `get_module()` uses `js_sys::Reflect::get` + `unchecked_into` (object literal, not a constructor, so NOT an extern fn call and NOT `dyn_into`); `EditorHandle` holds the instance + all `Closure`s; `impl Drop` calls `destroy()`. +- Shared data types `SqlSchema`/`SqlTable` (serde) compile on both targets; the wasm-bindgen externs live in a `#[cfg(target_arch = "wasm32")] mod wasm`. +- Unit tests: `pnpm test` (Vitest 4 + happy-dom), covering getValue/setValue, setTheme (Compartment), setSchema, vim toggle, onChange. +- Injected via `Dioxus.toml` `script` array (`/codemirror/editor.js`). + +Do not edit `public/codemirror/` — they are build artifacts. + +## Database Management (`/admin/system`) + +Admin area at `/admin/system` (menu "系统") with 5 tabs: 数据库状态 / 服务器状态 / SQL 控制台 / 数据导出 / 备份恢复. All gated by `get_current_admin_user` (admin-only). Backend in `src/api/database/` (status/system_status/sql_console/schema/export/backup/tasks), page in `src/pages/admin/system.rs`. + +- **SQL 控制台** is full read-write with 4 guards: (1) `sqlparser` AST gates — `DROP DATABASE`/`DROP SCHEMA`/`CREATE DATABASE` absolutely forbidden (string pre-check); `DROP`/`TRUNCATE`/`ALTER` require a `confirm_dangerous` checkbox; (2) `UPDATE`/`DELETE` without `WHERE` rejected; (3) `STATEMENT_TIMEOUT_SECS` query timeout (pool-level GUC); (4) frontend write-confirm dialog. Multi-statement disabled by default. Results capped at 500 rows. +- **备份恢复** uses `dashmap` task-progress table; `create_backup`/`restore_backup` return a task_id immediately and poll `get_task_progress`. Backup prefers `pg_dump` (full, incl. schema), falls back to per-table `COPY TO STDOUT` (data only) when `pg_dump` is unavailable. Backup files carry a `-- YGGDRASIL BACKUP v1` signature header; restore rejects non-system files. `backups/` is gitignored and served only via `GET /api/database/backups/{filename}` (admin-gated, path-allowlist). +- **服务器状态** uses `sysinfo` (optional, server feature) with a background sampler (`SYSINFO_SAMPLE_SECS`, default 0.5s) writing to a `RwLock`; server functions read the snapshot (zero sampling cost), so frontend can poll high-frequency. `src/cache.rs` exposes moka hit-rate via `AtomicU64` hit/miss counters per cache + `cache_stats()`. +- New env var: `SYSINFO_SAMPLE_SECS` (sysinfo sampling interval in seconds, supports decimals). +- New deps (all `optional = true`, server feature): `sysinfo`, `sqlparser`, `dashmap`. + ## Syntax Highlighting Pipeline - `themes/` contains Catppuccin Latte (light) and Mocha (dark) `.tmTheme` files diff --git a/Cargo.lock b/Cargo.lock index ed7906e..7f46f82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5479,6 +5479,7 @@ dependencies = [ "axum", "bytes", "chrono", + "dashmap", "deadpool-postgres", "dioxus", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index ac14783..c3b3e11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ moka = { version = "0.12", features = ["future", "sync"], optional = true } governor = { version = "0.8", optional = true } sysinfo = { version = "0.34", optional = true } sqlparser = { version = "0.45", optional = true } +dashmap = { version = "6.2", optional = true } md-5 = { version = "0.10", optional = true } futures = { version = "0.3", optional = true } bytes = { version = "1", optional = true } @@ -91,6 +92,7 @@ server = [ "dep:governor", "dep:sysinfo", "dep:sqlparser", + "dep:dashmap", "dep:md-5", "dep:futures", "dep:bytes", diff --git a/src/api/database/backup.rs b/src/api/database/backup.rs new file mode 100644 index 0000000..84cc60a --- /dev/null +++ b/src/api/database/backup.rs @@ -0,0 +1,549 @@ +#![allow(clippy::unused_unit, deprecated)] + +//! 备份与恢复(读写,最高风险)。 +//! +//! 备份:探测 pg_dump 可用性——可用则子进程生成完整 .sql,不可用则回退纯 SQL +//! (仅数据)。备份文件含签名头。 +//! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护。 +//! 长耗时操作走后台任务 + 进度轮询(见 [`super::tasks`])。 + +use std::path::{Component, PathBuf}; + +use chrono::Utc; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::api::auth::get_current_admin_user; +use crate::api::database::tasks::{self, TaskKind, TaskStatus}; +use crate::api::error::AppError; + +/// 备份目录(项目根,与 uploads/ 平级,gitignored)。 +const BACKUP_DIR: &str = "backups"; +/// 文件名白名单正则:仅字母数字下划线点连字符(防路径穿越)。 +const FILENAME_RE: &str = r"^[a-zA-Z0-9_.\-]+$"; +/// 备份文件签名头(恢复时校验,拒绝非本系统文件)。 +const BACKUP_SIGNATURE: &str = "-- YGGDRASIL BACKUP v1"; + +/// 备份文件元信息(列表展示用)。 +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct BackupInfo { + pub filename: String, + pub size_bytes: u64, + /// 备份模式:pg_dump / sql-fallback(从签名头解析)。 + pub mode: String, + pub created_at: Option, +} + +/// 发起备份,立即返回 task_id,后台任务执行。 +#[server(CreateBackup, "/api")] +pub async fn create_backup() -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + let task_id = uuid::Uuid::new_v4().to_string(); + tasks::insert(task_id.clone(), TaskKind::Backup); + let tid = task_id.clone(); + tokio::spawn(async move { + run_backup(&tid).await; + }); + Ok(task_id) + } + #[cfg(not(feature = "server"))] + { + Ok(String::new()) + } +} + +/// 后台执行备份:pg_dump 优先,不可用回退纯 SQL。 +#[cfg(feature = "server")] +async fn run_backup(task_id: &str) { + let _ = std::fs::create_dir_all(BACKUP_DIR); + let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string(); + + // 探测 pg_dump + let pg_dump_ok = std::process::Command::new("pg_dump") + .arg("--version") + .output() + .is_ok(); + + if pg_dump_ok { + run_pg_dump_backup(task_id, ×tamp).await; + } else { + run_sql_fallback_backup(task_id, ×tamp).await; + } +} + +/// pg_dump 模式:子进程生成完整备份(含 schema),前置签名头。 +#[cfg(feature = "server")] +async fn run_pg_dump_backup(task_id: &str, timestamp: &str) { + tasks::update( + task_id, + "正在用 pg_dump 导出", + 10, + TaskStatus::Running, + None, + None, + None, + ); + let filename = format!("backup_{}.sql", timestamp); + let path = backup_path(&filename); + let db_url = std::env::var("DATABASE_URL").unwrap_or_default(); + + let mut header = String::new(); + header.push_str(&format!("{}\n", BACKUP_SIGNATURE)); + header.push_str(&format!("-- created_at: {}\n", Utc::now())); + header.push_str("-- mode: pg_dump\n"); + + // 先写签名头,再追加 pg_dump 输出。 + let write_header = std::fs::write(&path, &header); + if write_header.is_err() { + tasks::update( + task_id, + "写入备份文件失败", + 100, + TaskStatus::Failed, + None, + Some("无法写入备份目录".to_string()), + None, + ); + return; + } + + let stdout_file = match std::fs::OpenOptions::new().append(true).open(&path) { + Ok(f) => f, + Err(e) => { + tasks::update( + task_id, + "pg_dump 启动失败", + 100, + TaskStatus::Failed, + None, + Some(e.to_string()), + None, + ); + return; + } + }; + let child = match std::process::Command::new("pg_dump") + .arg(&db_url) + .stdout(std::process::Stdio::from(stdout_file)) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + tasks::update( + task_id, + "pg_dump 启动失败", + 100, + TaskStatus::Failed, + None, + Some(e.to_string()), + None, + ); + return; + } + }; + let output = child.wait_with_output(); + match output { + Ok(o) if o.status.success() => { + tasks::update( + task_id, + "完成", + 100, + TaskStatus::Done, + None, + None, + Some(filename), + ); + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr).to_string(); + tasks::update( + task_id, + "pg_dump 失败", + 100, + TaskStatus::Failed, + None, + Some(stderr), + None, + ); + } + Err(e) => { + tasks::update( + task_id, + "pg_dump 执行失败", + 100, + TaskStatus::Failed, + None, + Some(e.to_string()), + None, + ); + } + } +} + +/// 纯 SQL 回退:仅备份数据(不含 schema),按表计数精确进度。 +#[cfg(feature = "server")] +async fn run_sql_fallback_backup(task_id: &str, timestamp: &str) { + tasks::update( + task_id, + "pg_dump 不可用,使用纯 SQL 回退(仅数据)", + 10, + TaskStatus::Running, + Some("仅备份数据,不含 schema/索引/触发器".to_string()), + None, + None, + ); + let filename = format!("backup_{}_sqlfallback.sql", timestamp); + let path = backup_path(&filename); + + let client = match crate::db::pool::get_conn().await { + Ok(c) => c, + Err(e) => { + tasks::update( + task_id, + "数据库连接失败", + 100, + TaskStatus::Failed, + None, + Some(e.to_string()), + None, + ); + return; + } + }; + + // 取 public schema 下所有表名 + let tables: Vec = match client + .query( + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename", + &[], + ) + .await + { + Ok(rows) => rows.into_iter().map(|r| r.get(0)).collect(), + Err(e) => { + tasks::update( + task_id, + "读取表清单失败", + 100, + TaskStatus::Failed, + None, + Some(e.to_string()), + None, + ); + return; + } + }; + let total = tables.len().max(1); + + let mut out = String::new(); + out.push_str(&format!("{}\n", BACKUP_SIGNATURE)); + out.push_str(&format!("-- created_at: {}\n", Utc::now())); + out.push_str("-- mode: sql-fallback\n\n"); + + for (i, table) in tables.iter().enumerate() { + out.push_str(&format!("\n-- table: {}\n", table)); + let copy_stmt = format!("COPY \"{}\" TO STDOUT WITH CSV", table); + match client.copy_out(©_stmt).await { + Ok(stream) => { + use futures::StreamExt; + // CopyOutStream 是 !Unpin,必须 pin 才能调 next。 + tokio::pin!(stream); + while let Some(chunk) = stream.next().await { + if let Ok(bytes) = chunk { + out.push_str(&String::from_utf8_lossy(&bytes)); + } + } + } + Err(e) => { + out.push_str(&format!("-- 导出失败: {}\n", e)); + } + } + // 按表更新进度 + tasks::update( + task_id, + &format!("导出表 {}/{}", i + 1, total), + 10 + (i + 1) as u8 * 90 / total as u8, + TaskStatus::Running, + None, + None, + None, + ); + } + + if std::fs::write(&path, out).is_err() { + tasks::update( + task_id, + "写入备份文件失败", + 100, + TaskStatus::Failed, + None, + Some("无法写入备份目录".to_string()), + None, + ); + return; + } + tasks::update( + task_id, + "完成", + 100, + TaskStatus::Done, + None, + None, + Some(filename), + ); +} + +/// 发起恢复:校验签名 + 路径穿越防护 + 二次确认,立即返回 task_id。 +#[server(RestoreBackup, "/api")] +pub async fn restore_backup(filename: String, confirm: bool) -> Result { + let _user = get_current_admin_user().await?; + + if !confirm { + return Err(AppError::BadRequest("需确认恢复(会覆盖现有数据)".to_string()).into()); + } + // 路径穿越防护 + let re = regex::Regex::new(FILENAME_RE).unwrap(); + if !re.is_match(&filename) { + return Err(AppError::BadRequest("无效的文件名".to_string()).into()); + } + let path = backup_path(&filename); + if !path.exists() { + return Err(AppError::NotFound("备份文件不存在").into()); + } + + // 签名校验:首行需含签名 + let head = std::fs::read_to_string(&path) + .ok() + .and_then(|s| s.lines().next().map(|l| l.trim().to_string())) + .unwrap_or_default(); + if !head.contains(BACKUP_SIGNATURE) { + return Err(AppError::BadRequest("非本系统生成的备份文件,拒绝恢复".to_string()).into()); + } + + #[cfg(feature = "server")] + { + let task_id = uuid::Uuid::new_v4().to_string(); + tasks::insert(task_id.clone(), TaskKind::Restore); + let tid = task_id.clone(); + let f = filename; + tokio::spawn(async move { + run_restore(&tid, &f).await; + }); + Ok(task_id) + } + #[cfg(not(feature = "server"))] + { + Ok(String::new()) + } +} + +/// 后台执行恢复:探测 psql,可用则 psql -f,不可用则报告。 +#[cfg(feature = "server")] +async fn run_restore(task_id: &str, filename: &str) { + let path = backup_path(filename); + let db_url = std::env::var("DATABASE_URL").unwrap_or_default(); + let psql_ok = std::process::Command::new("psql") + .arg("--version") + .output() + .is_ok(); + if !psql_ok { + tasks::update( + task_id, + "psql 不可用", + 100, + TaskStatus::Failed, + None, + Some("恢复需要 psql,但当前环境未安装 psql".to_string()), + None, + ); + return; + } + tasks::update( + task_id, + "正在用 psql 恢复", + 50, + TaskStatus::Running, + None, + None, + None, + ); + let output = std::process::Command::new("psql") + .arg(&db_url) + .arg("-f") + .arg(&path) + .stderr(std::process::Stdio::piped()) + .output(); + match output { + Ok(o) if o.status.success() => { + tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None); + } + Ok(o) => { + let stderr = String::from_utf8_lossy(&o.stderr).to_string(); + tasks::update( + task_id, + "恢复失败", + 100, + TaskStatus::Failed, + None, + Some(stderr), + None, + ); + } + Err(e) => { + tasks::update( + task_id, + "psql 启动失败", + 100, + TaskStatus::Failed, + None, + Some(e.to_string()), + None, + ); + } + } +} + +/// 列出 backups/ 目录下的备份文件元信息。 +#[server(ListBackups, "/api")] +pub async fn list_backups() -> Result, ServerFnError> { + let _user = get_current_admin_user().await?; + #[cfg(feature = "server")] + { + let mut infos: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(BACKUP_DIR) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.ends_with(".sql") { + continue; + } + let meta = match entry.metadata() { + Ok(m) => m, + Err(_) => continue, + }; + let mode = std::fs::read_to_string(entry.path()) + .ok() + .and_then(|s| { + s.lines() + .find(|l| l.starts_with("-- mode:")) + .map(|l| l.trim_start_matches("-- mode: ").trim().to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + let created_at = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| { + chrono::DateTime::::from_timestamp(d.as_secs() as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default() + }); + infos.push(BackupInfo { + filename: name, + size_bytes: meta.len(), + mode, + created_at, + }); + } + } + // 按创建时间降序(新的在前) + infos.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(infos) + } + #[cfg(not(feature = "server"))] + { + Ok(vec![]) + } +} + +/// 删除备份文件。 +#[server(DeleteBackup, "/api")] +pub async fn delete_backup(filename: String) -> Result<(), ServerFnError> { + let _user = get_current_admin_user().await?; + #[cfg(feature = "server")] + { + let re = regex::Regex::new(FILENAME_RE).unwrap(); + if !re.is_match(&filename) { + return Err(AppError::BadRequest("无效的文件名".to_string()).into()); + } + let path = backup_path(&filename); + if !path.exists() { + return Err(AppError::NotFound("备份文件不存在").into()); + } + std::fs::remove_file(&path).map_err(|_| AppError::Internal("删除失败"))?; + Ok(()) + } + #[cfg(not(feature = "server"))] + { + Ok(()) + } +} + +/// 构造 backups/ 下的安全路径(额外防御:校验规范化后仍在 BACKUP_DIR 内)。 +#[cfg(feature = "server")] +fn backup_path(filename: &str) -> PathBuf { + let raw: PathBuf = [BACKUP_DIR, filename].iter().collect(); + // 确保规范化后首两段仍是 BACKUP_DIR/filename(防任何路径穿越残留) + let mut it = raw.components(); + let _ = it.next(); // BACKUP_DIR + if it.all(|c| !matches!(c, Component::ParentDir | Component::RootDir)) { + raw + } else { + PathBuf::from(BACKUP_DIR) + } +} + +/// Axum 处理器:下载备份文件(admin 鉴权 + 路径白名单)。 +pub async fn download_backup( + axum::extract::Path(filename): axum::extract::Path, + headers: axum::http::HeaderMap, +) -> Result { + use axum::http::{header, StatusCode}; + + // 鉴权 + let cookie_header = headers + .get("cookie") + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + let token = crate::auth::session::parse_session_token(cookie_header).map(str::to_string); + let token = match token { + Some(t) => t, + None => return Err((StatusCode::UNAUTHORIZED, "未登录".to_string())), + }; + let user = match crate::api::auth::get_user_by_token(&token).await { + Ok(Some(u)) => u, + _ => return Err((StatusCode::UNAUTHORIZED, "会话已过期".to_string())), + }; + if user.role != crate::models::user::UserRole::Admin { + return Err((StatusCode::FORBIDDEN, "权限不足".to_string())); + } + + // 路径白名单 + let re = regex::Regex::new(FILENAME_RE).unwrap(); + if !re.is_match(&filename) { + return Err((StatusCode::BAD_REQUEST, "无效的文件名".to_string())); + } + let path = backup_path(&filename); + let bytes = tokio::fs::read(&path) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "文件不存在".to_string()))?; + let disposition = format!("attachment; filename=\"{}\"", filename); + Ok(( + StatusCode::OK, + [ + ( + header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/sql; charset=utf-8"), + ), + ( + header::CONTENT_DISPOSITION, + axum::http::HeaderValue::from_str(&disposition) + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("attachment")), + ), + ], + axum::body::Body::from(bytes), + )) +} diff --git a/src/api/database/mod.rs b/src/api/database/mod.rs index 0b4e3e1..c155e4b 100644 --- a/src/api/database/mod.rs +++ b/src/api/database/mod.rs @@ -16,3 +16,7 @@ pub mod sql_console; pub mod schema; /// 数据导出 Axum 流式处理器。 pub mod export; +/// 备份/恢复(双模式 + 任务进度)。 +pub mod backup; +/// 备份/恢复异步任务进度表。 +pub mod tasks; diff --git a/src/api/database/tasks.rs b/src/api/database/tasks.rs new file mode 100644 index 0000000..19814ed --- /dev/null +++ b/src/api/database/tasks.rs @@ -0,0 +1,120 @@ +#![allow(clippy::unused_unit, deprecated)] + +//! 备份/恢复的异步任务进度表(DashMap)。 +//! +//! create_backup/restore_backup 立即返回 task_id,后台任务跑时通过 +//! [`update`] 更新进度,前端轮询 [`get_task_progress`]。 +//! 已完成超过 1 小时的任务惰性清理,避免内存累积。 + +use std::sync::LazyLock; + +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::api::auth::get_current_admin_user; + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub enum TaskKind { + Backup, + Restore, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] +pub enum TaskStatus { + Running, + Done, + Failed, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct TaskProgress { + pub id: String, + pub kind: TaskKind, + pub stage: String, + pub percent: u8, + pub detail: Option, + pub status: TaskStatus, + pub error: Option, + pub created_at: DateTime, + /// 完成后的备份文件名(下载/恢复用)。 + pub result_filename: Option, +} + +#[cfg(feature = "server")] +static TASKS: LazyLock> = LazyLock::new(DashMap::new); + +/// 注册新任务(初始 Running,0%)。 +#[cfg(feature = "server")] +pub(super) fn insert(id: String, kind: TaskKind) { + TASKS.insert( + id.clone(), + TaskProgress { + id, + kind, + stage: "排队中".to_string(), + percent: 0, + detail: None, + status: TaskStatus::Running, + error: None, + created_at: Utc::now(), + result_filename: None, + }, + ); +} + +/// 更新任务进度(后台任务调用)。 +#[cfg(feature = "server")] +pub(super) fn update( + id: &str, + stage: &str, + percent: u8, + status: TaskStatus, + detail: Option, + error: Option, + result_filename: Option, +) { + if let Some(mut p) = TASKS.get_mut(id) { + p.stage = stage.to_string(); + p.percent = percent; + p.status = status; + p.detail = detail; + p.error = error; + p.result_filename = result_filename; + } +} + +/// 惰性清理已完成超过 1 小时的任务(查询时顺便清)。 +#[cfg(feature = "server")] +fn gc_old() { + let cutoff = Utc::now() - chrono::Duration::hours(1); + TASKS.retain(|_, p| !(matches!(p.status, TaskStatus::Done | TaskStatus::Failed) && p.created_at < cutoff)); +} + +/// 查询任务进度(轮询用)。 +#[server(GetTaskProgress, "/api")] +pub async fn get_task_progress(task_id: String) -> Result { + let _user = get_current_admin_user().await?; + #[cfg(feature = "server")] + { + gc_old(); + TASKS.get(&task_id) + .map(|p| p.clone()) + .ok_or_else(|| crate::api::error::AppError::NotFound("任务不存在").into()) + } + #[cfg(not(feature = "server"))] + { + Ok(TaskProgress { + id: task_id, + kind: TaskKind::Backup, + stage: String::new(), + percent: 0, + detail: None, + status: TaskStatus::Done, + error: None, + created_at: Utc::now(), + result_filename: None, + }) + } +} diff --git a/src/main.rs b/src/main.rs index 68ddf97..f430eb1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -395,6 +395,11 @@ fn main() { "/api/database/export", axum::routing::get(crate::api::database::export::export_data), ) + // 备份下载:admin 鉴权 + 路径白名单(backups/ 不直接暴露静态目录) + .route( + "/api/database/backups/{filename}", + axum::routing::get(crate::api::database::backup::download_backup), + ) .layer(TimeoutLayer::with_status_code( StatusCode::REQUEST_TIMEOUT, Duration::from_secs(120), diff --git a/src/pages/admin/system.rs b/src/pages/admin/system.rs index 98b2a58..b38bf9a 100644 --- a/src/pages/admin/system.rs +++ b/src/pages/admin/system.rs @@ -63,7 +63,7 @@ pub fn System() -> Element { SystemTab::ServerStatus => rsx! { ServerStatusTab {} }, SystemTab::SqlConsole => rsx! { SqlConsoleTab {} }, SystemTab::Export => rsx! { ExportTab {} }, - SystemTab::Backup => rsx! { div { class: "text-paper-secondary py-8", "备份恢复(待实现)" } }, + SystemTab::Backup => rsx! { BackupTab {} }, } } } @@ -1037,3 +1037,341 @@ fn urlencode(s: &str) -> String { } out } + +/// 备份恢复 tab:备份按钮 + 进度轮询 + 备份列表(下载/恢复/删除)。 +#[allow(non_snake_case)] +fn BackupTab() -> Element { + use crate::api::database::backup::BackupInfo; + use crate::api::database::tasks::TaskProgress; + #[cfg(target_arch = "wasm32")] + use crate::api::database::backup::{create_backup, delete_backup, list_backups, restore_backup}; + #[cfg(target_arch = "wasm32")] + use crate::api::database::tasks::{get_task_progress, TaskStatus}; + use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; + + let backups = use_signal(Vec::::new); + let mut loading = use_signal(|| false); + let error = use_signal(|| Option::::None); + // 当前进行中的任务(备份/恢复)id + 进度 + let active_task_id: Signal> = use_signal(|| None); + let active_progress = use_signal(|| Option::::None); + // 待恢复的文件名(确认对话框用) + let mut pending_restore: Signal> = use_signal(|| None); + let busy = use_signal(|| false); + + // 刷新备份列表 + let mut refresh_list = move || { + loading.set(true); + #[cfg(target_arch = "wasm32")] + { + let backups = backups; + let error = error; + spawn(async move { + match list_backups().await { + Ok(list) => { + backups.set(list); + error.set(None); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } + }; + + use_effect(move || { + refresh_list(); + }); + + // 恢复确认:pending_restore 被设置时弹出原生 confirm,确认后发起 restore_backup + // 并开始进度轮询。用 use_future 响应 pending_restore 变化。 + let _pending_for_confirm = pending_restore.read().clone(); + use_future(move || { + let pending_restore = pending_restore; + let busy = busy; + let active_progress = active_progress; + let active_task_id = active_task_id; + let error = error; + async move { + #[cfg(target_arch = "wasm32")] + { + let fname = match _pending_for_confirm { + Some(f) => f, + None => return, + }; + let confirmed = web_sys::window() + .and_then(|w| { + w.confirm_with_message(&format!( + "恢复将覆盖现有数据,确认恢复 {}?\n\n仅本系统生成的备份可恢复。", + fname + )) + .ok() + }) + == Some(true); + if confirmed { + busy.set(true); + active_progress.set(None); + match restore_backup(fname, true).await { + Ok(id) => active_task_id.set(Some(id)), + Err(e) => { + error.set(Some(e.to_string())); + busy.set(false); + } + } + } + // 无论确认与否都清空 pending + pending_restore.set(None); + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (pending_restore, busy, active_progress, active_task_id, error); + } + } + }); + + // 任务进度轮询:active_task_id 存在时每 1.5s 拉取进度,Done/Failed 后停止 + 刷新列表 + let _task_id_for_poll = active_task_id.read().clone(); + use_future(move || { + let active_task_id = active_task_id; + let active_progress = active_progress; + let backups_f = backups; + let busy_f = busy; + async move { + #[cfg(target_arch = "wasm32")] + { + let tid = match _task_id_for_poll { + Some(t) => t, + None => return, + }; + loop { + wasm_sleep(1500).await; + match get_task_progress(tid.clone()).await { + Ok(p) => { + let done = p.status == TaskStatus::Done || p.status == TaskStatus::Failed; + active_progress.set(Some(p)); + if done { + // 刷新列表(备份完成后新文件出现)并清理任务态 + match list_backups().await { + Ok(list) => backups_f.set(list), + _ => {} + } + active_task_id.set(None); + busy_f.set(false); + break; + } + } + Err(_) => { + active_task_id.set(None); + busy_f.set(false); + break; + } + } + } + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (active_task_id, active_progress, backups_f, busy_f); + } + } + }); + + let current_backups = backups.read().clone(); + let current_error = error.read().clone(); + let current_progress = active_progress.read().clone(); + let is_busy = busy(); + // 预格式化备份行(避免在 rsx for 循环体内 let / 格式化)。 + // 每行:(filename, mode, size_str, dl_url) + let backup_rows: Vec<(String, String, String, String)> = current_backups + .iter() + .map(|b| { + ( + b.filename.clone(), + b.mode.clone(), + format_bytes(b.size_bytes as i64), + format!("/api/database/backups/{}", urlencode_dl(&b.filename)), + ) + }) + .collect(); + + rsx! { + div { class: "space-y-4", + // 操作栏 + div { class: "flex items-center gap-3", + button { + class: "px-4 py-1.5 text-sm bg-paper-accent text-paper-theme rounded hover:brightness-110 transition disabled:opacity-50", + disabled: is_busy, + onclick: move |_| { + #[cfg(target_arch = "wasm32")] + { + busy.set(true); + active_progress.set(None); + let active_task_id = active_task_id; + spawn(async move { + match create_backup().await { + Ok(id) => active_task_id.set(Some(id)), + Err(e) => { error.set(Some(e.to_string())); busy.set(false); } + } + }); + } + }, + if is_busy { "处理中..." } else { "创建备份" } + } + button { + class: "px-3 py-1.5 text-sm border border-paper-border text-paper-primary rounded hover:bg-paper-entry transition disabled:opacity-50", + disabled: loading() || is_busy, + onclick: move |_| refresh_list(), + "刷新列表" + } + } + + // 进度 + if let Some(p) = current_progress { + div { class: "{ADMIN_CARD_CLASS} p-4", + div { class: "flex items-center justify-between mb-2", + span { class: "text-sm font-medium text-paper-primary", "{p.stage}" } + span { class: "text-sm text-paper-secondary", "{p.percent}%" } + } + div { class: "w-full bg-paper-entry rounded-full h-2 overflow-hidden", + div { class: "bg-paper-accent h-full transition-all", style: "width: {p.percent}%" } + } + if let Some(detail) = p.detail { + p { class: "text-xs text-paper-secondary mt-2", "{detail}" } + } + if let Some(err) = p.error { + p { class: "text-xs text-red-600 dark:text-red-400 mt-2", "错误:{err}" } + } + } + } + + // 错误 + if let Some(err) = current_error { + div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 text-sm text-red-700 dark:text-red-300", + "{err}" + } + } + + // 备份列表 + if !current_backups.is_empty() { + div { class: "{ADMIN_TABLE_CLASS}", + div { class: "overflow-x-auto", + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-paper-border text-left text-paper-secondary", + th { class: "px-4 py-2 font-medium", "文件名" } + th { class: "px-4 py-2 font-medium", "模式" } + th { class: "px-4 py-2 font-medium text-right", "大小" } + th { class: "px-4 py-2 font-medium text-right", "操作" } + } + } + tbody { + for (fname, mode, size_str, dl_url) in backup_rows.iter() { + BackupRow { + key: "{fname}", + filename: fname.clone(), + mode: mode.clone(), + size_str: size_str.clone(), + dl_url: dl_url.clone(), + busy: is_busy, + on_restore: move |f| pending_restore.set(Some(f)), + on_delete: move |_fname_del| { + #[cfg(target_arch = "wasm32")] + { + let fname_del = _fname_del; + let confirmed = web_sys::window() + .and_then(|w| { + w.confirm_with_message(&format!("确认删除 {}?", fname_del)) + .ok() + }) + == Some(true); + if confirmed { + let backups = backups; + spawn(async move { + let _ = delete_backup(fname_del.clone()).await; + if let Ok(list) = list_backups().await { + backups.set(list); + } + }); + } + } + }, + } + } + } + } + } + } + } else if !loading() { + div { class: "text-paper-secondary text-sm py-4", "暂无备份文件" } + } + + p { class: "text-xs text-paper-secondary", + "备份优先用 pg_dump(含 schema),不可用时回退纯 SQL(仅数据)。" + "恢复仅接受本系统生成的备份,且会覆盖现有数据。" + } + } + } +} + +/// 下载链接用的 URL 编码(wasm32 才用,server 端 rsx 也引用故需编译)。 +fn urlencode_dl(s: &str) -> String { + #[cfg(target_arch = "wasm32")] + { + urlencode(s) + } + #[cfg(not(target_arch = "wasm32"))] + { + s.to_string() + } +} + +/// 备份列表单行(抽取为子组件:各自 scope 内 let/clone 不冲突)。 +#[derive(Props, Clone, PartialEq)] +struct BackupRowProps { + filename: String, + mode: String, + size_str: String, + dl_url: String, + busy: bool, + on_restore: Callback, + on_delete: Callback, +} + +#[component] +fn BackupRow(props: BackupRowProps) -> Element { + // Callback 是 Copy,直接复用;filename 需 clone(两个闭包各取一份)。 + let on_restore = props.on_restore; + let on_delete = props.on_delete; + let fname_for_restore = props.filename.clone(); + let fname_for_delete = props.filename.clone(); + rsx! { + tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", + td { class: "px-4 py-2 font-mono text-xs text-paper-primary", "{props.filename}" } + td { class: "px-4 py-2 text-paper-secondary", "{props.mode}" } + td { class: "px-4 py-2 text-right text-paper-secondary", "{props.size_str}" } + td { class: "px-4 py-2 text-right whitespace-nowrap", + a { + class: "text-xs text-paper-accent hover:underline mr-3", + href: "{props.dl_url}", + download: "", + "下载" + } + button { + class: "text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 mr-3 disabled:opacity-50", + disabled: props.busy, + onclick: move |_| on_restore.call(fname_for_restore.clone()), + "恢复" + } + button { + class: "text-xs text-red-600 hover:text-red-800 dark:text-red-400 disabled:opacity-50", + disabled: props.busy, + onclick: move |_| on_delete.call(fname_for_delete.clone()), + "删除" + } + } + } + } +}