diff --git a/AGENTS.md b/AGENTS.md index 719f147..c1c67fb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,7 +205,7 @@ Run a single lib's tests: `cd libs && pnpm --filter @yggdrasil/ test` (Vit 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 `dashmap` task-progress table; `create_backup`/`restore_backup` return a task_id immediately and poll `get_task_progress`. Backup prefers `pg_dump` (`--clean --if-exists` so the dump script includes `DROP ... IF EXISTS` → restore is idempotent: it drops+recreates existing objects instead of failing on "relation already exists" / duplicate-key during COPY), falls back to per-table `COPY TO STDOUT` (data only) when `pg_dump` is unavailable. Restore runs `psql -v ON_ERROR_STOP=1 -f` (without `ON_ERROR_STOP`, psql returns exit 0 even when every statement errors → silent false-success with zero data change); on success it flushes all post caches + bumps SSR generation. 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). Pre-fix backup files (no DROP) will fail-restore correctly under `ON_ERROR_STOP` — regenerate the backup to actually restore. - **服务器状态** 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()`. ## Syntax Highlighting Pipeline diff --git a/src/api/database/backup.rs b/src/api/database/backup.rs index 23ca7e5..39f3d34 100644 --- a/src/api/database/backup.rs +++ b/src/api/database/backup.rs @@ -2,10 +2,17 @@ //! 备份与恢复(读写,最高风险)。 //! -//! 备份:探测 pg_dump 可用性——可用则子进程生成完整 .sql,不可用则回退纯 SQL -//! (仅数据)。备份文件含签名头。 -//! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护。 +//! 备份:探测 pg_dump 可用性——可用则子进程生成完整 .sql(`--clean --if-exists`, +//! 脚本自带 `DROP ... IF EXISTS`,使恢复幂等),不可用则回退纯 SQL(仅数据)。 +//! 备份文件含签名头。 +//! 恢复:仅接受本系统生成的备份(签名校验)+ 二次确认 + 路径穿越防护; +//! `psql -v ON_ERROR_STOP=1` 确保任何 SQL 错误立即中止并报失败(不再假成功); +//! 成功后全量失效文章缓存与 SSR 世代号。 //! 长耗时操作走后台任务 + 进度轮询(见 [`crate::api::database::tasks`])。 +//! +//! **兼容性提示**:`--clean --if-exists` 是后加的备份参数。本修复之前生成的备份 +//! 文件不含 DROP,对其执行恢复会在第一条「relation already exists」处中止并报 +//! 失败(行为正确,但无法恢复数据)——需重新创建备份才能恢复。 // Component/PathBuf/chrono::Utc 仅 server 构建的备份逻辑用到。 #[cfg(feature = "server")] @@ -88,6 +95,10 @@ async fn run_backup(task_id: &str) { } /// pg_dump 模式:子进程生成完整备份(含 schema),前置签名头。 +/// +/// `--clean --if-exists`:生成的脚本含 `DROP ... IF EXISTS`,使恢复幂等 +/// (恢复前自动删除现有对象,避免「relation already exists」/主键冲突导致 +/// 数据零写入)。详见 `run_restore`。 #[cfg(feature = "server")] async fn run_pg_dump_backup(task_id: &str, timestamp: &str) { tasks::update( @@ -154,6 +165,10 @@ async fn run_pg_dump_backup(task_id: &str, timestamp: &str) { }; let child = match std::process::Command::new("pg_dump") .arg(&db_url) + // --clean --if-exists:生成 DROP ... IF EXISTS,让恢复幂等(先删后建), + // 否则恢复时表已存在 → CREATE/COPY 全部失败、数据零写入。 + .arg("--clean") + .arg("--if-exists") .stdout(std::process::Stdio::from(stdout_file)) .stderr(std::process::Stdio::piped()) .spawn() @@ -369,6 +384,17 @@ pub async fn restore_backup(filename: String, confirm: bool) -> Result { + // 恢复用备份时刻的数据重建了 posts 等表,必须冲刷全部文章相关缓存 + // 与 SSR 世代号,否则前端仍读旧数据(被删的文章不会重新出现)。 + crate::cache::invalidate_all_post_caches(); + crate::cache::invalidate_search_results(); + crate::ssr_cache::bump_global_generation(); tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None); } Ok(o) => {