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:
justme committed 2026-10-17 15:25:16 +08:00
1 parent d66c93fe98
commit 72a5e5b839
84 files changed
+2156 -39

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
using System.Buffers.Binary;
using System.Text.Json.Serialization;
namespace WebDrop.Core;
public sealed class CreateChannelRequest
{
[JsonPropertyName("key_package")] public string? KeyPackage { get; init; }
[JsonPropertyName("join_password")] public string? JoinPassword { get; init; }
[JsonPropertyName("id")] public string? Id { get; init; }
[JsonPropertyName("mode")] public ChannelMode? Mode { get; init; }
[JsonPropertyName("settings")] public ChannelSettings? Settings { get; init; }
[JsonPropertyName("read_hash")] public string? ReadHash { get; init; }
[JsonPropertyName("write_hash")] public string? WriteHash { get; init; }
[JsonPropertyName("owner_hash")] public string? OwnerHash { get; init; }
}
public sealed class CreateShareRequest
{
[JsonPropertyName("id")] public string? Id { get; init; }
[JsonPropertyName("read_hash")] public string? ReadHash { get; init; }
[JsonPropertyName("owner_hash")] public string? OwnerHash { get; init; }
[JsonPropertyName("settings")] public ShareSettings? Settings { get; init; }
}
public sealed class FinishShareRequest
{
[JsonPropertyName("manifest_item_id")] public string? ManifestItemId { get; init; }
}
public sealed class ShareInfo
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("state")] public required string State { get; init; }
[JsonPropertyName("expires_at")] public required long ExpiresAt { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
[JsonPropertyName("sealed_at")] public long? SealedAt { get; init; }
[JsonPropertyName("manifest_item_id")] public string? ManifestItemId { get; init; }
[JsonPropertyName("settings")] public required ShareSettings Settings { get; init; }
[JsonPropertyName("items")] public required IReadOnlyList<ShareItemInfo> Items { get; init; }
}
public sealed class ShareItemInfo
{
[JsonPropertyName("complete")] public bool Complete { get; init; }
[JsonPropertyName("item_id")] public required string ItemId { get; init; }
[JsonPropertyName("bytes")] public required long Bytes { get; init; }
[JsonPropertyName("frames")] public required int Frames { get; init; }
[JsonPropertyName("max_downloads")] public required long MaxDownloads { get; init; }
[JsonPropertyName("downloads_left")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] public long? DownloadsLeft { get; init; }
}
public sealed class ChannelInfo
{
[JsonPropertyName("can_upload")] public bool CanUpload { get; init; }
[JsonPropertyName("can_manage_structure")] public bool CanManageStructure { get; init; }
[JsonPropertyName("password_required")] public bool PasswordRequired { get; init; }
[JsonPropertyName("creator_account")] public string? CreatorAccount { get; init; }
/// <summary>空间头像(data URL,PNG/JPEG/WebP/GIF),没有设置时为 null。</summary>
[JsonPropertyName("avatar")] public string? Avatar { get; init; }
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("mode")] public required ChannelMode Mode { get; init; }
[JsonPropertyName("settings")] public required ChannelSettings Settings { get; init; }
/// <summary>频道成员数(账号),不统计账号设备数。</summary>
[JsonPropertyName("members_used")] public required int MembersUsed { get; init; }
/// <summary>从该 seq 起的续传有效;0 &lt; from &lt; oldest_seq 的续传会得到 410,需从 0 全量同步。</summary>
[JsonPropertyName("oldest_seq")] public required long OldestSeq { get; init; }
[JsonPropertyName("last_seq")] public required long LastSeq { get; init; }
[JsonPropertyName("created_at")] public required long CreatedAt { get; init; }
[JsonPropertyName("expires_at")] public required long ExpiresAt { get; init; }
}
public sealed class JoinResult
{
[JsonPropertyName("joined_at")] public required long JoinedAt { get; init; }
/// <summary>加入后的成员人数,同一账号的其他终端不增加此值。</summary>
[JsonPropertyName("members_used")] public required int MembersUsed { get; init; }
[JsonPropertyName("members_max")] public required int MembersMax { get; init; }
}
public sealed class AppendResult
{
[JsonPropertyName("seq")] public required long Seq { get; init; }
/// <summary>true 为新追加(201),false 为幂等重发(200)。</summary>
[JsonIgnore] public bool Created { get; init; }
}
/// <summary>一条日志记录的线格式:seq(u64) ‖ kind(u8) ‖ body。</summary>
public sealed record LogRecord(long Seq, byte[] Bytes)
{
public static byte[] Encode(long seq, LogKind kind, ReadOnlySpan<byte> body)
{
var bytes = new byte[9 + body.Length];
BinaryPrimitives.WriteInt64BigEndian(bytes, seq);
bytes[8] = (byte)kind;
body.CopyTo(bytes.AsSpan(9));
return bytes;
}
public static byte[] EncodeTombstone(long seq, Id16 itemId, TombstoneReason reason)
{
Span<byte> body = stackalloc byte[Id16.Size + 1];
itemId.WriteTo(body);
body[Id16.Size] = (byte)reason;
return Encode(seq, LogKind.Tombstone, body);
}
public static byte[] EncodeAnnounce(long seq, Id16 itemId, FrameFlags flags, long createdMs)
{
Span<byte> body = stackalloc byte[Id16.Size + 1 + 8];
itemId.WriteTo(body);
body[Id16.Size] = (byte)flags;
BinaryPrimitives.WriteInt64BigEndian(body[(Id16.Size + 1)..], createdMs);
return Encode(seq, LogKind.Announce, body);
}
}
public sealed record LogPage(IReadOnlyList<LogRecord> Records, long LastSeq);
/// <summary>GATED 条目拉取的一个批次:自 FromIndex 起连续的完整封装。</summary>
public sealed record ItemChunk(IReadOnlyList<byte[]> Frames, uint NextIndex, bool Complete);
[JsonSerializable(typeof(ChannelSettings))]
[JsonSerializable(typeof(ShareSettings))]
public sealed partial class CoreJsonContext : JsonSerializerContext;