using System.IO.Compression; using System.Globalization; using WebDrop.Storage; using WebDrop.Sync; namespace WebDrop.Api; /// /// 每天把数据库快照(聊天、头像、账号)压缩后上传到外部存储,只在本地留一个临时文件。 /// 外部存储里按日期留 RetainDays 份:用户删掉的聊天,最多再在备份里留这么久。 /// public sealed class BackupWorker(WebDropOptions options, SqliteChannelStore store, ILogger logger) : BackgroundService { private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(30) }; private const string Prefix = "webdrop-"; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var backup = options.Backup; if (!backup.Enabled || !backup.S3.Configured) return; while (!stoppingToken.IsCancellationRequested) { try { await Task.Delay(DailySchedule.Next(DateTime.Now, backup.TimeOfDay) - DateTime.Now, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } try { await RunOnceAsync(DateTime.Now, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception e) { logger.LogWarning("数据库备份失败:{Type}", e.GetType().Name); } } } public async Task<(string Name, int Purged)> RunOnceAsync(DateTime now, CancellationToken ct) { var backup = options.Backup; var s3 = new S3Client(_http, backup.S3); string tmp = Path.Combine(options.DataDir, "v2", "backup-tmp"); Directory.CreateDirectory(tmp); string name = $"{Prefix}{now:yyyyMMdd-HHmm}.db.gz"; string db = Path.Combine(tmp, $"snapshot-{Guid.NewGuid():N}.db"), gz = db + ".gz"; try { store.Snapshot(db); await using (var source = File.OpenRead(db)) await using (var target = File.Create(gz)) await using (var zip = new GZipStream(target, CompressionLevel.SmallestSize)) await source.CopyToAsync(zip, ct).ConfigureAwait(false); await using (var upload = File.OpenRead(gz)) await s3.PutAsync($"chat/{name}", upload, ct).ConfigureAwait(false); } finally { foreach (var path in new[] { db, gz }) try { File.Delete(path); } catch (IOException) { } } // 头像是按内容哈希命名的磁盘文件(不在数据库里):内容不会变,已经上传过的跳过 foreach (var kind in new[] { "space", "account" }) { string directory = Path.Combine(options.DataDir, "v2", "avatars", kind); if (!Directory.Exists(directory)) continue; var uploaded = (await s3.ListAsync($"chat/avatars/{kind}", ct).ConfigureAwait(false)).ToHashSet(); foreach (var file in Directory.EnumerateFiles(directory)) { if (file.EndsWith(".tmp", StringComparison.Ordinal) || uploaded.Contains(Path.GetFileName(file))) continue; await using var content = File.OpenRead(file); await s3.PutAsync($"chat/avatars/{kind}/{Path.GetFileName(file)}", content, ct).ConfigureAwait(false); } } // 保留期:按文件名里的日期清理更早的 int purged = 0; var cutoff = now.Date.AddDays(-Math.Max(0, backup.RetainDays)); foreach (var existing in await s3.ListAsync("chat", ct).ConfigureAwait(false)) { if (!existing.StartsWith(Prefix, StringComparison.Ordinal) || existing.Length < Prefix.Length + 8) continue; if (!DateTime.TryParseExact(existing.AsSpan(Prefix.Length, 8), "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) || date >= cutoff) continue; if (await s3.DeleteAsync($"chat/{existing}", ct).ConfigureAwait(false)) purged++; } if (purged > 0) logger.LogInformation("清理了 {Count} 份过期的数据库备份", purged); return (name, purged); } }