fix(system): 备份恢复实际不写入数据 + 假成功
三层叠加 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 退出码语义实测。
This commit is contained in:
parent
5888f1fc41
commit
2310ed9abe
@ -205,7 +205,7 @@ Run a single lib's tests: `cd libs && pnpm --filter @yggdrasil/<name> 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`.
|
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.
|
- **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<SystemSnapshot>`; 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()`.
|
- **服务器状态** uses `sysinfo` (optional, server feature) with a background sampler (`SYSINFO_SAMPLE_SECS`, default 0.5s) writing to a `RwLock<SystemSnapshot>`; 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
|
## Syntax Highlighting Pipeline
|
||||||
|
|||||||
@ -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`])。
|
//! 长耗时操作走后台任务 + 进度轮询(见 [`crate::api::database::tasks`])。
|
||||||
|
//!
|
||||||
|
//! **兼容性提示**:`--clean --if-exists` 是后加的备份参数。本修复之前生成的备份
|
||||||
|
//! 文件不含 DROP,对其执行恢复会在第一条「relation already exists」处中止并报
|
||||||
|
//! 失败(行为正确,但无法恢复数据)——需重新创建备份才能恢复。
|
||||||
|
|
||||||
// Component/PathBuf/chrono::Utc 仅 server 构建的备份逻辑用到。
|
// Component/PathBuf/chrono::Utc 仅 server 构建的备份逻辑用到。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
@ -88,6 +95,10 @@ async fn run_backup(task_id: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// pg_dump 模式:子进程生成完整备份(含 schema),前置签名头。
|
/// pg_dump 模式:子进程生成完整备份(含 schema),前置签名头。
|
||||||
|
///
|
||||||
|
/// `--clean --if-exists`:生成的脚本含 `DROP ... IF EXISTS`,使恢复幂等
|
||||||
|
/// (恢复前自动删除现有对象,避免「relation already exists」/主键冲突导致
|
||||||
|
/// 数据零写入)。详见 `run_restore`。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
async fn run_pg_dump_backup(task_id: &str, timestamp: &str) {
|
async fn run_pg_dump_backup(task_id: &str, timestamp: &str) {
|
||||||
tasks::update(
|
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")
|
let child = match std::process::Command::new("pg_dump")
|
||||||
.arg(&db_url)
|
.arg(&db_url)
|
||||||
|
// --clean --if-exists:生成 DROP ... IF EXISTS,让恢复幂等(先删后建),
|
||||||
|
// 否则恢复时表已存在 → CREATE/COPY 全部失败、数据零写入。
|
||||||
|
.arg("--clean")
|
||||||
|
.arg("--if-exists")
|
||||||
.stdout(std::process::Stdio::from(stdout_file))
|
.stdout(std::process::Stdio::from(stdout_file))
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
@ -369,6 +384,17 @@ pub async fn restore_backup(filename: String, confirm: bool) -> Result<String, S
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 后台执行恢复:探测 psql,可用则 psql -f,不可用则报告。
|
/// 后台执行恢复:探测 psql,可用则 psql -f,不可用则报告。
|
||||||
|
///
|
||||||
|
/// **幂等恢复**:备份由 `pg_dump --clean --if-exists` 生成,脚本自带
|
||||||
|
/// `DROP ... IF EXISTS`,恢复时会先删后建,数据完全回到备份时刻。
|
||||||
|
///
|
||||||
|
/// **错误中止**:`-v ON_ERROR_STOP=1` 让 psql 在第一条 SQL 错误时立即退出
|
||||||
|
/// (退出码 3)。否则 psql 即使满屏 ERROR 也返回 0,导致 `status.success()`
|
||||||
|
/// 误判为成功——这是「恢复完成却无任何数据变更」假成功的根因。
|
||||||
|
///
|
||||||
|
/// **恢复成功后失效全量缓存**:恢复会用备份时刻的数据重建 posts 等表,
|
||||||
|
/// 现有 moka 缓存(列表/标签/单篇/统计/搜索)与 SSR 世代号必须一并冲刷,
|
||||||
|
/// 否则前端仍读旧数据。
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
async fn run_restore(task_id: &str, filename: &str) {
|
async fn run_restore(task_id: &str, filename: &str) {
|
||||||
let path = backup_path(filename);
|
let path = backup_path(filename);
|
||||||
@ -414,12 +440,21 @@ async fn run_restore(task_id: &str, filename: &str) {
|
|||||||
);
|
);
|
||||||
let output = std::process::Command::new("psql")
|
let output = std::process::Command::new("psql")
|
||||||
.arg(&db_url)
|
.arg(&db_url)
|
||||||
|
// ON_ERROR_STOP=1:遇 SQL 错误立即中止(退出码 3)。
|
||||||
|
// 不加这个,psql 即使满屏 ERROR 也返回 0,status.success() 误报成功。
|
||||||
|
.arg("-v")
|
||||||
|
.arg("ON_ERROR_STOP=1")
|
||||||
.arg("-f")
|
.arg("-f")
|
||||||
.arg(&path)
|
.arg(&path)
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
.output();
|
.output();
|
||||||
match output {
|
match output {
|
||||||
Ok(o) if o.status.success() => {
|
Ok(o) if o.status.success() => {
|
||||||
|
// 恢复用备份时刻的数据重建了 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);
|
tasks::update(task_id, "恢复完成", 100, TaskStatus::Done, None, None, None);
|
||||||
}
|
}
|
||||||
Ok(o) => {
|
Ok(o) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user