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]
80 lines
4.0 KiB
C#
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);
|
|
}
|
|
}
|