feat(server): split file storage into a separate file service with tickets, receipts and WebDAV backup
- new projects WebDrop.FileApi (file service), WebDrop.FileProtocol (shared tickets and receipts) and WebDrop.Sync (WebDAV) - file content of any size goes to the file service; chat, avatars and space structure stay on the main backend - tickets issued by the main backend, receipts by the file service, deletion queue and daily reconciliation - daily database snapshot and file sync to external storage (OpenList/WebDAV) with a 30 day retention - reorganize Core, Storage and Api into feature folders (namespaces unchanged) - web client uploads file frames to the file service and reads them with tickets
This commit is contained in:
1 parent
d66c93fe98
commit
72a5e5b839
84 files changed
+2156
-39
No files matched your search
@@ -0,0 +1,65 @@
|
||||
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.WebDav.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 dav = new WebDavClient(_http, backup.WebDav);
|
||||
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 dav.PutAsync($"chat/{name}", upload, ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var path in new[] { db, gz }) try { File.Delete(path); } catch (IOException) { }
|
||||
}
|
||||
|
||||
// 保留期:按文件名里的日期清理更早的
|
||||
int purged = 0;
|
||||
var cutoff = now.Date.AddDays(-Math.Max(0, backup.RetainDays));
|
||||
foreach (var existing in await dav.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 dav.DeleteAsync($"chat/{existing}", ct).ConfigureAwait(false)) purged++;
|
||||
}
|
||||
if (purged > 0) logger.LogInformation("清理了 {Count} 份过期的数据库备份", purged);
|
||||
return (name, purged);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user