From 2310ed9abe16994327bebd236f7b67c8256a7489 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 13 Jul 2026 15:57:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(system):=20=E5=A4=87=E4=BB=BD=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=AE=9E=E9=99=85=E4=B8=8D=E5=86=99=E5=85=A5=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=20+=20=E5=81=87=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三层叠加 bug 导致「恢复完成却零数据变更」: 1. 备份用 pg_dump 生成但无 --clean,脚本不含 DROP。恢复到已有数据的库时, CREATE TABLE 全部 'already exists',COPY public.posts 在第一行就因主键 冲突中止(COPY 0),被软删的文章(deleted_at 非空)永远回不来。 2. psql 默认不带 ON_ERROR_STOP,即使满屏 ERROR 退出码仍是 0。run_restore 只判断 status.success(),于是 tasks::update 误报 TaskStatus::Done—— 用户看到「恢复完成」但实际零写入。 3. 即使数据写进去,前端 moka 缓存(posts/tags/stats/search)和 SSR 世代号 也不会失效,恢复后仍读旧数据。 修复: - run_pg_dump_backup 加 --clean --if-exists,备份脚本自带 DROP IF EXISTS, 恢复变为幂等的先删后建。 - run_restore 给 psql 加 -v ON_ERROR_STOP=1,任何 SQL 错误立即退出(码 3), status.success() 不再误判。 - 恢复成功后调用 invalidate_all_post_caches + invalidate_search_results + bump_global_generation,前端立即看到恢复的数据。 验证:建干净测试库跑完整反馈回路(3 篇 → 软删 2 篇 → 恢复),修复前 alive=1, 修复后 alive=3,exit 0。旧备份文件(无 DROP)在 ON_ERROR_STOP 下会正确报失败 而非假成功,需重新创建备份才能恢复。 根因假设(已通过最小反馈回路证实):pg_dump 无 --clean + psql 无 ON_ERROR_STOP + 缓存未失效三层叠加。hypothesis 来自 backup.rs 源码 + backups/*.sql 实际结构 + psql 退出码语义实测。 --- AGENTS.md | 2 +- src/api/database/backup.rs | 41 +++++++++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) 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) => {