Files
WebDrop/server/src/WebDrop.Api/Hosting/BackupWorker.cs
T
justmeandClaude Sonnet 5.5 7274390aa1
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/web-build Pipeline failed
ci/woodpecker/push/dev-image-api unknown status
perf(host): store avatars as content-addressed files and serve them from disk
Space info now carries avatar_version instead of the inlined thumbnail; the worker fetches the thumbnail once per version. Legacy base64 rows are migrated to files on startup, and the daily backup uploads avatar files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> [CI]
2026-10-19 14:16:50 +08:00

80 lines
4.0 KiB
C#

using System.IO.Compression;
using System.Globalization;
using WebDrop.Storage;
using WebDrop.Sync;
namespace WebDrop.Api;
/// <summary>
/// 每天把数据库快照(聊天、头像、账号)压缩后上传到外部存储,只在本地留一个临时文件。
/// 外部存储里按日期留 RetainDays 份:用户删掉的聊天,最多再在备份里留这么久。
/// </summary>
public sealed class BackupWorker(WebDropOptions options, SqliteChannelStore store, ILogger<BackupWorker> 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);
}
}