|
|
@@ -1,44 +1,116 @@
|
|
|
+using System.Threading;
|
|
|
using Serilog;
|
|
|
+using YZWater.Core.Models;
|
|
|
|
|
|
namespace YZWater.Core.Services;
|
|
|
|
|
|
/// <summary>
|
|
|
-/// 数据库备份服务
|
|
|
+/// 数据库备份服务(增强版)
|
|
|
+///
|
|
|
+/// 设计原则(借鉴 AutoFMA):
|
|
|
+/// 1. WAL checkpoint + VACUUM INTO 在线备份,不锁库
|
|
|
+/// 2. 临时文件写入 → 验证 → 原子替换(避免备份失败时损坏旧备份)
|
|
|
+/// 3. Interlocked 防重入(启动备份和定时器可能同时触发)
|
|
|
+/// 4. 启动时备份 + 定时备份
|
|
|
+/// 5. 只保留一个备份文件,每次覆盖(可配置保留数量)
|
|
|
/// </summary>
|
|
|
public static class BackupService
|
|
|
{
|
|
|
private static readonly string BackupDir = "Backups";
|
|
|
+ private static Timer? _timer;
|
|
|
+ private static int _backupInProgress; // 0=空闲, 1=备份中(防重入)
|
|
|
+
|
|
|
+ /// <summary>备份文件名(固定,每次覆盖)</summary>
|
|
|
+ private const string BackupFileName = "yzwater_backup.db";
|
|
|
+
|
|
|
+ /// <summary>定时备份间隔(默认 24 小时)</summary>
|
|
|
+ private static readonly TimeSpan BackupInterval = TimeSpan.FromHours(24);
|
|
|
+
|
|
|
+ /// <summary>备份完成事件</summary>
|
|
|
+ public static event Action<bool, string>? BackupCompleted;
|
|
|
|
|
|
/// <summary>
|
|
|
- /// 创建数据库备份
|
|
|
+ /// 执行数据库备份(增强版)
|
|
|
+ /// 步骤:WAL checkpoint → VACUUM INTO 临时文件 → 验证 → 原子替换
|
|
|
/// </summary>
|
|
|
public static async Task<(bool Success, string Path)> BackupAsync()
|
|
|
{
|
|
|
+ // 防重入:启动时备份和定时器可能同时触发
|
|
|
+ if (Interlocked.CompareExchange(ref _backupInProgress, 1, 0) != 0)
|
|
|
+ {
|
|
|
+ Log.Debug("数据库备份正在执行中,跳过本次");
|
|
|
+ return (false, string.Empty);
|
|
|
+ }
|
|
|
+
|
|
|
try
|
|
|
{
|
|
|
- Directory.CreateDirectory(BackupDir);
|
|
|
+ var dbPath = "yzwater.db";
|
|
|
+ if (!File.Exists(dbPath))
|
|
|
+ {
|
|
|
+ Log.Warning("数据库文件不存在,跳过备份");
|
|
|
+ return (false, string.Empty);
|
|
|
+ }
|
|
|
|
|
|
- var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
|
|
- var backupPath = Path.Combine(BackupDir, $"yzwater_backup_{timestamp}.db");
|
|
|
+ Directory.CreateDirectory(BackupDir);
|
|
|
|
|
|
- // SQLite 备份:使用 VACUUM INTO 创建一致性快照
|
|
|
- // 比 File.Copy 更安全,避免复制期间写入导致备份损坏
|
|
|
+ var backupPath = Path.Combine(BackupDir, BackupFileName);
|
|
|
+ var tempPath = Path.Combine(BackupDir, BackupFileName + ".tmp");
|
|
|
var db = DatabaseService.Db;
|
|
|
- await Task.Run(() =>
|
|
|
+
|
|
|
+ // 步骤 1:WAL checkpoint(TRUNCATE 模式)
|
|
|
+ // 将 WAL 中未合并的变更全部写入主数据库文件,并清空 WAL 文件
|
|
|
+ // 这样 VACUUM INTO 备份的数据更完整,备份文件也更小(无冗余 WAL 页)
|
|
|
+ try
|
|
|
{
|
|
|
- // VACUUM INTO 会创建数据库的一致性快照
|
|
|
- db.Ado.ExecuteCommand($"VACUUM INTO '{backupPath.Replace("'", "''")}'");
|
|
|
- });
|
|
|
+ await Task.Run(() => db.Ado.ExecuteCommand("PRAGMA wal_checkpoint(TRUNCATE)"));
|
|
|
+ Log.Debug("WAL checkpoint 完成");
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ Log.Warning(ex, "WAL checkpoint 失败(不影响备份)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 步骤 2:VACUUM INTO 先写入临时文件(VACUUM INTO 不覆盖已存在文件)
|
|
|
+ // 写入临时文件的好处:备份失败时旧备份不受影响
|
|
|
+ if (File.Exists(tempPath)) File.Delete(tempPath);
|
|
|
+ var escapedTempPath = tempPath.Replace("'", "''");
|
|
|
+ await Task.Run(() => db.Ado.ExecuteCommand($"VACUUM INTO '{escapedTempPath}'"));
|
|
|
|
|
|
- Log.Information("数据库备份成功: {Path}", backupPath);
|
|
|
- AuditService.Log("系统", "Backup", $"数据库备份: {backupPath}");
|
|
|
+ // 步骤 3:验证临时备份文件
|
|
|
+ var tempInfo = new FileInfo(tempPath);
|
|
|
+ if (!tempInfo.Exists || tempInfo.Length == 0)
|
|
|
+ {
|
|
|
+ Log.Error("数据库备份文件异常:临时文件不存在或为空");
|
|
|
+ TryDelete(tempPath);
|
|
|
+ return (false, string.Empty);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 步骤 4:原子替换——删除旧备份,临时文件重命名为最终备份
|
|
|
+ if (File.Exists(backupPath)) File.Delete(backupPath);
|
|
|
+ File.Move(tempPath, backupPath);
|
|
|
+
|
|
|
+ var backupInfo = new FileInfo(backupPath);
|
|
|
+ var sourceInfo = new FileInfo(dbPath);
|
|
|
+ var message = $"数据库备份完成: {BackupFileName}({backupInfo.Length / 1024.0:F1} KB,源文件 {sourceInfo.Length / 1024.0:F1} KB)";
|
|
|
+ Log.Information(message);
|
|
|
+
|
|
|
+ AuditService.Log("系统", "Backup", message);
|
|
|
+ BackupCompleted?.Invoke(true, backupPath);
|
|
|
return (true, backupPath);
|
|
|
}
|
|
|
catch (Exception ex)
|
|
|
{
|
|
|
Log.Error(ex, "数据库备份失败");
|
|
|
+ // 备份失败时清理可能残留的临时文件
|
|
|
+ var tempPath = Path.Combine(BackupDir, BackupFileName + ".tmp");
|
|
|
+ TryDelete(tempPath);
|
|
|
+ BackupCompleted?.Invoke(false, string.Empty);
|
|
|
return (false, string.Empty);
|
|
|
}
|
|
|
+ finally
|
|
|
+ {
|
|
|
+ Interlocked.Exchange(ref _backupInProgress, 0);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
|
@@ -88,6 +160,7 @@ public static class BackupService
|
|
|
return new List<(string, DateTime, long)>();
|
|
|
|
|
|
return Directory.GetFiles(BackupDir, "*.db")
|
|
|
+ .Where(f => !f.EndsWith(".tmp")) // 排除临时文件
|
|
|
.Select(f => new FileInfo(f))
|
|
|
.OrderByDescending(f => f.CreationTime)
|
|
|
.Select(f => (f.FullName, f.CreationTime, f.Length))
|
|
|
@@ -97,12 +170,13 @@ public static class BackupService
|
|
|
/// <summary>
|
|
|
/// 清理旧备份(保留最近 N 个)
|
|
|
/// </summary>
|
|
|
- public static int CleanupOldBackups(int keepCount = 10)
|
|
|
+ public static int CleanupOldBackups(int keepCount = 7)
|
|
|
{
|
|
|
if (!Directory.Exists(BackupDir))
|
|
|
return 0;
|
|
|
|
|
|
var backups = Directory.GetFiles(BackupDir, "*.db")
|
|
|
+ .Where(f => !f.EndsWith(".tmp")) // 排除临时文件
|
|
|
.Select(f => new FileInfo(f))
|
|
|
.OrderByDescending(f => f.CreationTime)
|
|
|
.Skip(keepCount)
|
|
|
@@ -126,4 +200,87 @@ public static class BackupService
|
|
|
|
|
|
return backups.Count;
|
|
|
}
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 启动定时备份(每 24 小时)
|
|
|
+ /// </summary>
|
|
|
+ public static void StartScheduledBackup()
|
|
|
+ {
|
|
|
+ if (_timer != null) return; // 防止重复启动
|
|
|
+
|
|
|
+ _timer = new Timer(_ =>
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ // 同步调用 BackupAsync,等待完成
|
|
|
+ BackupAsync().GetAwaiter().GetResult();
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ Log.Error(ex, "定时备份异常");
|
|
|
+ }
|
|
|
+ }, null, BackupInterval, BackupInterval);
|
|
|
+
|
|
|
+ Log.Information($"定时备份已启动,间隔 {BackupInterval.TotalHours} 小时");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 停止定时备份
|
|
|
+ /// </summary>
|
|
|
+ public static void StopScheduledBackup()
|
|
|
+ {
|
|
|
+ _timer?.Dispose();
|
|
|
+ _timer = null;
|
|
|
+ Log.Information("定时备份已停止");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 启动时执行备份 + 启动定时器
|
|
|
+ /// </summary>
|
|
|
+ public static async Task StartAsync()
|
|
|
+ {
|
|
|
+ // 启动时立即备份一次
|
|
|
+ try
|
|
|
+ {
|
|
|
+ await BackupAsync();
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ Log.Error(ex, "启动时备份失败");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理旧备份
|
|
|
+ try
|
|
|
+ {
|
|
|
+ CleanupOldBackups();
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ Log.Warning(ex, "清理旧备份失败");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 启动定时备份
|
|
|
+ StartScheduledBackup();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 停止备份服务
|
|
|
+ /// </summary>
|
|
|
+ public static void Stop()
|
|
|
+ {
|
|
|
+ StopScheduledBackup();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>尝试删除文件,失败不影响主流程</summary>
|
|
|
+ private static void TryDelete(string path)
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ if (File.Exists(path)) File.Delete(path);
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ Log.Debug(ex, "删除临时文件失败: {Path}", path);
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|