1use super::commands::pg_dump_command;
4use super::files::{
5 backup_path, backup_stamp, create_new_backup_file, rotate_auto_backups, uploads_archive_name,
6 BACKUP_DIR, BACKUP_SIGNATURE,
7};
8use super::{BackupOrigin, BackupRunOutcome};
9use crate::api::database::tasks::{self, TaskKind, TaskStatus};
10use chrono::Utc;
11use std::io::Write;
12use std::path::Path;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15pub(super) const BACKUP_BUSY: &str = "已有备份正在进行,请等待完成后再试";
17
18static BACKUP_RUNNING: AtomicBool = AtomicBool::new(false);
20
21pub(super) struct BackupGuard(());
24
25impl BackupGuard {
26 pub(super) fn try_acquire() -> Option<Self> {
28 BACKUP_RUNNING
29 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
30 .ok()
31 .map(|_| Self(()))
32 }
33}
34
35impl Drop for BackupGuard {
36 fn drop(&mut self) {
37 BACKUP_RUNNING.store(false, Ordering::Release);
38 }
39}
40
41pub(crate) async fn run_auto_backup() -> Result<BackupRunOutcome, String> {
44 let guard = BackupGuard::try_acquire().ok_or_else(|| BACKUP_BUSY.to_string())?;
45 let task_id = uuid::Uuid::new_v4().to_string();
46 tasks::insert(task_id.clone(), TaskKind::Backup);
47 run_backup(&task_id, BackupOrigin::Auto, guard).await
48}
49
50pub(super) async fn run_backup(
54 task_id: &str,
55 origin: BackupOrigin,
56 _guard: BackupGuard,
57) -> Result<BackupRunOutcome, String> {
58 let _ = std::fs::create_dir_all(BACKUP_DIR);
59 let stamp = backup_stamp(Utc::now());
60 let prefix = origin.file_prefix();
61
62 let settings = match crate::db::pool::get_conn().await {
65 Ok(conn) => crate::api::settings::load_backup_settings(&conn)
66 .await
67 .unwrap_or_default(),
68 Err(_) => crate::models::settings::BackupSettings::default(),
69 };
70
71 let pg_dump_ok = tokio::task::spawn_blocking(|| {
73 std::process::Command::new("pg_dump")
74 .arg("--version")
75 .output()
76 .is_ok()
77 })
78 .await
79 .unwrap_or(false);
80
81 let sql_result = if pg_dump_ok {
82 run_pg_dump_backup(task_id, prefix, &stamp).await
83 } else {
84 run_sql_fallback_backup(task_id, prefix, &stamp).await
85 };
86 let sql_filename = match sql_result {
87 Ok(f) => f,
88 Err(e) => return Err(e),
90 };
91
92 let mut uploads_filename = None;
94 let mut warning = None;
95 if settings.include_uploads {
96 tasks::update(
97 task_id,
98 "正在打包 uploads 素材",
99 92,
100 TaskStatus::Running,
101 None,
102 None,
103 None,
104 );
105 let tar_name = uploads_archive_name(&sql_filename);
106 let tar_path = backup_path(&tar_name);
107 match tokio::task::spawn_blocking(move || {
108 create_uploads_tarball(Path::new("uploads"), &tar_path)
109 })
110 .await
111 {
112 Ok(Ok(())) => uploads_filename = Some(tar_name),
113 Ok(Err(e)) => warning = Some(format!("uploads 打包失败: {e}")),
114 Err(e) => warning = Some(format!("uploads 打包任务panic: {e}")),
115 }
116 if warning.is_some() {
117 tracing::warn!("backup uploads tarball failed: {:?}", warning);
118 }
119 }
120
121 if origin == BackupOrigin::Auto {
123 rotate_auto_backups(settings.retention_count);
124 }
125
126 tasks::update(
127 task_id,
128 "完成",
129 100,
130 TaskStatus::Done,
131 warning.clone(),
132 None,
133 Some(sql_filename.clone()),
134 );
135 Ok(BackupRunOutcome {
136 sql_filename,
137 uploads_filename,
138 warning,
139 })
140}
141fn create_uploads_tarball(uploads_dir: &Path, out_path: &Path) -> std::io::Result<()> {
145 let file = create_new_backup_file(out_path)?;
146 let gz = flate2::write::GzEncoder::new(file, flate2::Compression::default());
147 let mut builder = tar::Builder::new(gz);
148 if uploads_dir.is_dir() {
149 for entry in std::fs::read_dir(uploads_dir)? {
150 let entry = entry?;
151 let name = entry.file_name();
152 if name == ".cache" || name == ".gitkeep" {
153 continue;
154 }
155 let path = entry.path();
156 if path.is_dir() {
157 builder.append_dir_all(&name, &path)?;
158 } else if path.is_file() {
159 builder.append_path_with_name(&path, &name)?;
160 }
161 }
162 }
163 let gz = builder.into_inner()?;
164 gz.finish()?;
165 Ok(())
166}
167async fn run_pg_dump_backup(task_id: &str, prefix: &str, stamp: &str) -> Result<String, String> {
168 tasks::update(
169 task_id,
170 "正在用 pg_dump 导出",
171 10,
172 TaskStatus::Running,
173 None,
174 None,
175 None,
176 );
177 let filename = format!("{prefix}_{stamp}.sql");
178 let path = backup_path(&filename);
179 let db_url = match std::env::var("DATABASE_URL") {
180 Ok(u) if !u.is_empty() => u,
181 _ => {
182 let msg = "pg_dump 备份需要 DATABASE_URL".to_string();
183 tasks::update(
184 task_id,
185 "DATABASE_URL 未配置",
186 100,
187 TaskStatus::Failed,
188 None,
189 Some(msg.clone()),
190 None,
191 );
192 return Err(msg);
193 }
194 };
195
196 let mut header = String::new();
197 header.push_str(&format!("{}\n", BACKUP_SIGNATURE));
198 header.push_str(&format!("-- created_at: {}\n", Utc::now()));
199 header.push_str("-- mode: pg_dump\n");
200
201 let mut stdout_file = match create_new_backup_file(&path) {
204 Ok(f) => f,
205 Err(e) => {
206 let msg = format!("无法写入备份目录: {e}");
207 tasks::update(
208 task_id,
209 "写入备份文件失败",
210 100,
211 TaskStatus::Failed,
212 None,
213 Some(msg.clone()),
214 None,
215 );
216 return Err(msg);
217 }
218 };
219 if let Err(e) = stdout_file.write_all(header.as_bytes()) {
220 let msg = format!("无法写入备份目录: {e}");
221 tasks::update(
222 task_id,
223 "写入备份文件失败",
224 100,
225 TaskStatus::Failed,
226 None,
227 Some(msg.clone()),
228 None,
229 );
230 return Err(msg);
231 }
232 let dump_result = tokio::task::spawn_blocking(
239 move || -> Result<std::process::Output, (bool, std::io::Error)> {
240 let mut command = pg_dump_command(&db_url).map_err(|error| (true, error))?;
241 command
242 .stdout(std::process::Stdio::from(stdout_file))
243 .stderr(std::process::Stdio::piped())
244 .spawn()
245 .map_err(|e| (true, e))?
246 .wait_with_output()
247 .map_err(|e| (false, e))
248 },
249 )
250 .await
251 .unwrap_or_else(|join_e| Err((false, std::io::Error::other(join_e.to_string()))));
252 match dump_result {
253 Ok(o) if o.status.success() => Ok(filename),
254 Ok(o) => {
255 let msg = String::from_utf8_lossy(&o.stderr).to_string();
256 tasks::update(
257 task_id,
258 "pg_dump 失败",
259 100,
260 TaskStatus::Failed,
261 None,
262 Some(msg.clone()),
263 None,
264 );
265 Err(msg)
266 }
267 Err((true, e)) => {
268 let msg = e.to_string();
269 tasks::update(
270 task_id,
271 "pg_dump 启动失败",
272 100,
273 TaskStatus::Failed,
274 None,
275 Some(msg.clone()),
276 None,
277 );
278 Err(msg)
279 }
280 Err((false, e)) => {
281 let msg = e.to_string();
282 tasks::update(
283 task_id,
284 "pg_dump 执行失败",
285 100,
286 TaskStatus::Failed,
287 None,
288 Some(msg.clone()),
289 None,
290 );
291 Err(msg)
292 }
293 }
294}
295
296async fn run_sql_fallback_backup(
304 task_id: &str,
305 prefix: &str,
306 stamp: &str,
307) -> Result<String, String> {
308 tasks::update(
309 task_id,
310 "pg_dump 不可用,使用纯 SQL 回退(仅数据)",
311 10,
312 TaskStatus::Running,
313 Some("仅备份数据,不含 schema/索引/触发器,且不可经 psql 恢复".to_string()),
314 None,
315 None,
316 );
317 let filename = format!("{prefix}_{stamp}_sqlfallback.sql");
318 let path = backup_path(&filename);
319
320 let client = match crate::db::pool::get_conn().await {
321 Ok(c) => c,
322 Err(e) => {
323 let msg = e.to_string();
324 tasks::update(
325 task_id,
326 "数据库连接失败",
327 100,
328 TaskStatus::Failed,
329 None,
330 Some(msg.clone()),
331 None,
332 );
333 return Err(msg);
334 }
335 };
336
337 let tables: Vec<String> = match client
339 .query(
340 "SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename",
341 &[],
342 )
343 .await
344 {
345 Ok(rows) => rows.into_iter().map(|r| r.get(0)).collect(),
346 Err(e) => {
347 let msg = e.to_string();
348 tasks::update(
349 task_id,
350 "读取表清单失败",
351 100,
352 TaskStatus::Failed,
353 None,
354 Some(msg.clone()),
355 None,
356 );
357 return Err(msg);
358 }
359 };
360 let total = tables.len().max(1);
361
362 let mut out = String::new();
363 out.push_str(&format!("{}\n", BACKUP_SIGNATURE));
364 out.push_str(&format!("-- created_at: {}\n", Utc::now()));
365 out.push_str("-- mode: sql-fallback\n\n");
366
367 for (i, table) in tables.iter().enumerate() {
368 out.push_str(&format!("\n-- table: {}\n", table));
369 let copy_stmt = format!("COPY \"{}\" TO STDOUT WITH CSV", table);
370 match client.copy_out(©_stmt).await {
371 Ok(stream) => {
372 use futures::StreamExt;
373 tokio::pin!(stream);
375 while let Some(chunk) = stream.next().await {
376 if let Ok(bytes) = chunk {
377 out.push_str(&String::from_utf8_lossy(&bytes));
378 }
379 }
380 }
381 Err(e) => {
382 out.push_str(&format!("-- 导出失败: {}\n", e));
383 }
384 }
385 tasks::update(
387 task_id,
388 &format!("导出表 {}/{}", i + 1, total),
389 (10 + (i + 1) as u32 * 90 / total as u32).min(99) as u8,
390 TaskStatus::Running,
391 None,
392 None,
393 None,
394 );
395 }
396
397 if let Err(e) = create_new_backup_file(&path).and_then(|mut f| f.write_all(out.as_bytes())) {
398 let msg = format!("无法写入备份目录: {e}");
399 tasks::update(
400 task_id,
401 "写入备份文件失败",
402 100,
403 TaskStatus::Failed,
404 None,
405 Some(msg.clone()),
406 None,
407 );
408 return Err(msg);
409 }
410 Ok(filename)
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn backup_guard_is_single_flight_until_dropped() {
419 let guard = BackupGuard::try_acquire().expect("空闲时应能拿到备份标记");
420 assert!(
421 BackupGuard::try_acquire().is_none(),
422 "已有备份在跑时必须拒绝第二个"
423 );
424 drop(guard);
425 assert!(
426 BackupGuard::try_acquire().is_some(),
427 "上一个备份结束后应能再次发起"
428 );
429 }
430
431 #[test]
432 fn tarball_excludes_cache_and_gitkeep() {
433 let nanos = std::time::SystemTime::now()
434 .duration_since(std::time::UNIX_EPOCH)
435 .expect("系统时间必然晚于 UNIX_EPOCH")
436 .as_nanos();
437 let dir = std::env::temp_dir().join(format!(
438 "yggdrasil_backup_tar_test_{}_{}",
439 nanos,
440 std::process::id()
441 ));
442 let uploads = dir.join("uploads");
443 std::fs::create_dir_all(uploads.join("2026")).expect("创建测试目录");
444 std::fs::create_dir_all(uploads.join(".cache")).expect("创建测试目录");
445 std::fs::write(uploads.join("2026/pic.webp"), b"img").expect("写测试文件");
446 std::fs::write(uploads.join(".cache/x.webp"), b"cache").expect("写测试文件");
447 std::fs::write(uploads.join(".gitkeep"), b"").expect("写测试文件");
448
449 let out = dir.join("out.tar.gz");
450 create_uploads_tarball(&uploads, &out).expect("打包应成功");
451
452 let file = std::fs::File::open(&out).expect("打开打包产物");
453 let gz = flate2::read::GzDecoder::new(file);
454 let mut archive = tar::Archive::new(gz);
455 let entries: Vec<String> = archive
456 .entries()
457 .expect("读取 tar 条目")
458 .map(|e| {
459 e.expect("tar 条目有效")
460 .path()
461 .expect("路径有效")
462 .to_string_lossy()
463 .to_string()
464 })
465 .collect();
466 assert!(
467 entries.iter().any(|p| p.contains("2026/pic.webp")),
468 "应包含素材文件: {entries:?}"
469 );
470 assert!(
471 !entries
472 .iter()
473 .any(|p| p.contains(".cache") || p.contains(".gitkeep")),
474 "应排除 .cache 与 .gitkeep: {entries:?}"
475 );
476 std::fs::remove_dir_all(&dir).expect("清理测试目录");
477 }
478}