feat(server): implement sealed share lifecycle

Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
justme committed 2026-10-02 00:59:56 +08:00
1 parent e02e024605
commit 42b93ae2d8
45 files changed
+6327

No files matched your search

@@ -0,0 +1,90 @@
using WebDrop.Core;
namespace WebDrop.Storage;
/// <summary>
/// 磁盘 blob 存储。键形如 "channelHex/itemHex/index.digest",直接映射为目录与文件。
/// 键只能由服务生成(十六进制和数字),这里仍做路径逃逸检查作为纵深防御。
/// </summary>
public sealed class DiskBlobStore : IBlobStore
{
private readonly string _root;
public DiskBlobStore(string root)
{
_root = Path.GetFullPath(root);
Directory.CreateDirectory(_root);
}
public async ValueTask PutAsync(string key, ReadOnlyMemory<byte> data, CancellationToken ct = default)
{
string path = Resolve(key);
// 键里含内容摘要:文件已存在就意味着内容相同,无需重写(也避免并发覆盖在 Windows 上冲突)
if (File.Exists(path)) return;
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
// 先写临时文件再重命名,读者永远看不到半个文件
string tmp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
await using (var fs = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous))
{
await fs.WriteAsync(data, ct).ConfigureAwait(false);
}
try
{
File.Move(tmp, path, overwrite: false);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException && File.Exists(path))
{
File.Delete(tmp); // 并发写入同一内容,对方先到了
}
}
public async ValueTask<byte[]?> ReadAsync(string key, CancellationToken ct = default)
{
string path = Resolve(key);
try
{
return await File.ReadAllBytesAsync(path, ct).ConfigureAwait(false);
}
catch (FileNotFoundException)
{
return null;
}
catch (DirectoryNotFoundException)
{
return null;
}
}
public void Delete(string key)
{
try { File.Delete(Resolve(key)); }
catch (DirectoryNotFoundException) { }
}
public void DeletePrefix(string prefix)
{
string dir = Resolve(prefix);
try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); }
catch (DirectoryNotFoundException) { }
}
public IEnumerable<(string Key, DateTimeOffset LastWrite)> Enumerate()
{
foreach (string file in Directory.EnumerateFiles(_root, "*", SearchOption.AllDirectories))
{
if (file.EndsWith(".tmp", StringComparison.Ordinal)) continue;
string key = Path.GetRelativePath(_root, file).Replace('\\', '/');
yield return (key, new DateTimeOffset(File.GetLastWriteTimeUtc(file), TimeSpan.Zero));
}
}
private string Resolve(string key)
{
string full = Path.GetFullPath(Path.Combine(_root, key));
if (!full.StartsWith(_root + Path.DirectorySeparatorChar, StringComparison.Ordinal))
throw new ArgumentException("非法 blob 键", nameof(key));
return full;
}
}
@@ -0,0 +1,545 @@
using System.Text.Json;
using Microsoft.Data.Sqlite;
using WebDrop.Core;
namespace WebDrop.Storage;
/// <summary>
/// SQLite 实现:WAL 模式,写入由单个连接加锁串行执行(保证 seq 严格连续),读取使用独立连接。
/// 全部使用手写 SQL 与显式映射,不依赖反射,可 NativeAOT 发布。
/// </summary>
public sealed class SqliteChannelStore : IResourceStore, IDisposable
{
private const int SchemaVersion = 2;
private readonly string _connectionString;
private readonly SqliteConnection _writeConn;
private readonly object _writeLock = new();
public SqliteChannelStore(string dbPath)
{
string? dir = Path.GetDirectoryName(Path.GetFullPath(dbPath));
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
_connectionString = new SqliteConnectionStringBuilder
{
DataSource = dbPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Cache = SqliteCacheMode.Private,
Pooling = true,
}.ToString();
_writeConn = Open();
Exec(_writeConn, "PRAGMA journal_mode=WAL;");
Migrate();
}
public T Read<T>(Func<IResourceTx, T> action)
{
using var conn = Open();
// 显式事务让一次 Read 内的多条查询看到同一份快照
using var tx = conn.BeginTransaction();
return action(new SqliteTx(conn));
}
public T Write<T>(Func<IResourceTx, T> action)
{
lock (_writeLock)
{
using var tx = _writeConn.BeginTransaction();
try
{
T result = action(new SqliteTx(_writeConn));
tx.Commit();
return result;
}
catch
{
tx.Rollback();
throw;
}
}
}
public void Dispose()
{
_writeConn.Dispose();
SqliteConnection.ClearAllPools();
}
private SqliteConnection Open()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
Exec(conn, "PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=OFF;");
return conn;
}
private static void Exec(SqliteConnection conn, string sql)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
private void Migrate()
{
using var cmd = _writeConn.CreateCommand();
cmd.CommandText = "PRAGMA user_version;";
long version = (long)cmd.ExecuteScalar()!;
if (version > SchemaVersion) throw new InvalidOperationException("数据库版本高于本程序支持的版本");
if (version == SchemaVersion) return;
using var tx = _writeConn.BeginTransaction();
if (version == 0) Exec(_writeConn, SchemaV1);
if (version < 2) Exec(_writeConn, SchemaV2);
Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};");
tx.Commit();
}
private const string SchemaV1 = """
CREATE TABLE channels (
id BLOB NOT NULL PRIMARY KEY,
mode INTEGER NOT NULL,
settings TEXT NOT NULL,
read_hash BLOB,
write_hash BLOB,
owner_hash BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seq INTEGER NOT NULL DEFAULT 0,
compacted_through INTEGER NOT NULL DEFAULT 0,
bytes INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX ix_channels_expires ON channels(expires_at);
CREATE TABLE seats (
channel_id BLOB NOT NULL,
endpoint_hash BLOB NOT NULL,
label BLOB,
joined_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, endpoint_hash)
) WITHOUT ROWID;
CREATE TABLE items (
channel_id BLOB NOT NULL,
item_id BLOB NOT NULL,
author_hash BLOB NOT NULL,
flags INTEGER NOT NULL,
max_downloads INTEGER NOT NULL,
downloads INTEGER NOT NULL,
frame_count INTEGER NOT NULL,
max_index INTEGER NOT NULL,
final_index INTEGER,
bytes INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
close_deadline INTEGER,
label TEXT,
PRIMARY KEY (channel_id, item_id)
);
CREATE INDEX ix_items_channel_created ON items(channel_id, created_at);
CREATE INDEX ix_items_expires ON items(expires_at);
CREATE INDEX ix_items_close ON items(close_deadline) WHERE close_deadline IS NOT NULL;
CREATE TABLE frames (
channel_id BLOB NOT NULL,
item_id BLOB NOT NULL,
frame_index INTEGER NOT NULL,
seq INTEGER NOT NULL,
length INTEGER NOT NULL,
digest BLOB NOT NULL,
inline_blob BLOB,
blob_key TEXT,
PRIMARY KEY (channel_id, item_id, frame_index)
) WITHOUT ROWID;
CREATE INDEX ix_frames_blob ON frames(blob_key) WHERE blob_key IS NOT NULL;
CREATE TABLE log (
channel_id BLOB NOT NULL,
seq INTEGER NOT NULL,
kind INTEGER NOT NULL,
item_id BLOB NOT NULL,
frame_index INTEGER NOT NULL,
aux INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (channel_id, seq)
) WITHOUT ROWID;
CREATE INDEX ix_log_item ON log(channel_id, item_id);
""";
private const string SchemaV2 = """
CREATE TABLE shares (
id BLOB NOT NULL PRIMARY KEY,
settings TEXT NOT NULL,
read_hash BLOB NOT NULL,
owner_hash BLOB,
creator_endpoint_hash BLOB,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
sealed_at INTEGER,
manifest_item_id BLOB,
bytes INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX ix_shares_expires ON shares(expires_at);
CREATE TABLE share_visitors (
share_id BLOB NOT NULL,
endpoint_hash BLOB NOT NULL,
joined_at INTEGER NOT NULL,
PRIMARY KEY (share_id, endpoint_hash)
) WITHOUT ROWID;
CREATE TABLE share_items (
share_id BLOB NOT NULL,
item_id BLOB NOT NULL,
flags INTEGER NOT NULL,
max_downloads INTEGER NOT NULL,
downloads INTEGER NOT NULL,
frame_count INTEGER NOT NULL,
max_index INTEGER NOT NULL,
final_index INTEGER,
bytes INTEGER NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
close_deadline INTEGER,
PRIMARY KEY (share_id, item_id)
) WITHOUT ROWID;
CREATE INDEX ix_share_items_expires ON share_items(expires_at);
CREATE INDEX ix_share_items_close ON share_items(close_deadline) WHERE close_deadline IS NOT NULL;
CREATE TABLE share_frames (
share_id BLOB NOT NULL,
item_id BLOB NOT NULL,
frame_index INTEGER NOT NULL,
seq INTEGER NOT NULL,
length INTEGER NOT NULL,
digest BLOB NOT NULL,
inline_blob BLOB,
blob_key TEXT,
PRIMARY KEY (share_id, item_id, frame_index)
) WITHOUT ROWID;
CREATE INDEX ix_share_frames_blob ON share_frames(blob_key) WHERE blob_key IS NOT NULL;
""";
private sealed class SqliteTx(SqliteConnection conn) : IResourceTx
{
private const string ChannelCols = "id, mode, settings, read_hash, write_hash, owner_hash, created_at, expires_at, last_seq, compacted_through, bytes";
private const string ItemCols = "channel_id, item_id, author_hash, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline, label";
private const string FrameCols = "channel_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
private const string ShareItemCols = "share_id, item_id, flags, max_downloads, downloads, frame_count, max_index, final_index, bytes, created_at, expires_at, close_deadline";
private const string ShareFrameCols = "share_id, item_id, frame_index, seq, length, digest, inline_blob, blob_key";
// ───────── 通用辅助 ─────────
private SqliteCommand Cmd(string sql, (string, object?)[] args)
{
var cmd = conn.CreateCommand();
cmd.CommandText = sql;
foreach (var (name, value) in args) cmd.Parameters.AddWithValue(name, value ?? DBNull.Value);
return cmd;
}
private int Run(string sql, params (string, object?)[] args)
{
using var cmd = Cmd(sql, args);
return cmd.ExecuteNonQuery();
}
private object? Scalar(string sql, params (string, object?)[] args)
{
using var cmd = Cmd(sql, args);
return cmd.ExecuteScalar();
}
private T? One<T>(string sql, Func<SqliteDataReader, T> map, params (string, object?)[] args) where T : class
{
using var cmd = Cmd(sql, args);
using var r = cmd.ExecuteReader();
return r.Read() ? map(r) : null;
}
private List<T> Many<T>(string sql, Func<SqliteDataReader, T> map, params (string, object?)[] args)
{
using var cmd = Cmd(sql, args);
using var r = cmd.ExecuteReader();
var list = new List<T>();
while (r.Read()) list.Add(map(r));
return list;
}
private static byte[] Blob(SqliteDataReader r, int i) => (byte[])r.GetValue(i);
private static byte[]? BlobOrNull(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : (byte[])r.GetValue(i);
private static long? LongOrNull(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : r.GetInt64(i);
private static Id16 IdAt(SqliteDataReader r, int i) => Id16.FromBytes(Blob(r, i));
// ───────── 便捷分享 ─────────
private static Share MapShare(SqliteDataReader r) => new(
IdAt(r, 0),
JsonSerializer.Deserialize(r.GetString(1), CoreJsonContext.Default.ShareSettings)!,
Blob(r, 2), BlobOrNull(r, 3), BlobOrNull(r, 4), r.GetInt64(5), r.GetInt64(6),
LongOrNull(r, 7), r.IsDBNull(8) ? null : IdAt(r, 8), r.GetInt64(9));
public Share? GetShare(Id16 id) => One(
"SELECT id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes FROM shares WHERE id=@id",
MapShare, ("@id", id.ToArray()));
public bool HasResourceId(Id16 id) => Scalar(
"SELECT 1 FROM channels WHERE id=@id UNION ALL SELECT 1 FROM shares WHERE id=@id LIMIT 1",
("@id", id.ToArray())) is not null;
public void InsertShare(Share s) => Run(
"INSERT INTO shares (id,settings,read_hash,owner_hash,creator_endpoint_hash,created_at,expires_at,sealed_at,manifest_item_id,bytes) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes)",
("@id", s.Id.ToArray()), ("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)),
("@read", s.ReadHash), ("@owner", s.OwnerHash), ("@creator", s.CreatorEndpointHash),
("@created", s.CreatedAt), ("@expires", s.ExpiresAt), ("@sealed", s.SealedAt),
("@manifest", s.ManifestItemId?.ToArray()), ("@bytes", s.Bytes));
public void UpdateShare(Share s) => Run(
"UPDATE shares SET settings=@settings,owner_hash=@owner,expires_at=@expires,sealed_at=@sealed,manifest_item_id=@manifest,bytes=@bytes WHERE id=@id",
("@settings", JsonSerializer.Serialize(s.Settings, CoreJsonContext.Default.ShareSettings)), ("@owner", s.OwnerHash),
("@expires", s.ExpiresAt), ("@sealed", s.SealedAt), ("@manifest", s.ManifestItemId?.ToArray()),
("@bytes", s.Bytes), ("@id", s.Id.ToArray()));
public IReadOnlyList<Id16> ListExpiredShares(long now, int limit) => Many(
"SELECT id FROM shares WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
public int CountShareVisitors(Id16 id) =>
(int)(long)Scalar("SELECT COUNT(*) FROM share_visitors WHERE share_id=@id", ("@id", id.ToArray()))!;
public bool HasShareVisitor(Id16 id, ReadOnlySpan<byte> endpointHash) => Scalar(
"SELECT 1 FROM share_visitors WHERE share_id=@id AND endpoint_hash=@ep",
("@id", id.ToArray()), ("@ep", endpointHash.ToArray())) is not null;
public void InsertShareVisitor(Id16 id, ReadOnlySpan<byte> endpointHash, long joinedAt) => Run(
"INSERT INTO share_visitors (share_id,endpoint_hash,joined_at) VALUES (@id,@ep,@at)",
("@id", id.ToArray()), ("@ep", endpointHash.ToArray()), ("@at", joinedAt));
public void DeleteShareRows(Id16 id)
{
byte[] key = id.ToArray();
Run("DELETE FROM share_frames WHERE share_id=@id", ("@id", key));
Run("DELETE FROM share_items WHERE share_id=@id", ("@id", key));
Run("DELETE FROM share_visitors WHERE share_id=@id", ("@id", key));
Run("DELETE FROM shares WHERE id=@id", ("@id", key));
}
private static ShareItem MapShareItem(SqliteDataReader r) => new(
IdAt(r, 0), IdAt(r, 1), (FrameFlags)r.GetInt32(2), r.GetInt64(3), r.GetInt64(4), r.GetInt32(5),
r.GetInt64(6), LongOrNull(r, 7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10), LongOrNull(r, 11));
public IReadOnlyList<ShareItem> ListShareItems(Id16 id) => Many(
$"SELECT {ShareItemCols} FROM share_items WHERE share_id=@id ORDER BY created_at,item_id", MapShareItem, ("@id", id.ToArray()));
public int CountShareItems(Id16 id) => (int)(long)Scalar("SELECT COUNT(*) FROM share_items WHERE share_id=@id", ("@id", id.ToArray()))!;
public void AddShareBytes(Id16 id, long delta) => Run("UPDATE shares SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray()));
public IReadOnlyList<ShareItem> ListExpiredShareItems(long now, int limit) => Many(
$"SELECT {ShareItemCols} FROM share_items WHERE expires_at<=@now OR (close_deadline IS NOT NULL AND close_deadline<=@now) LIMIT @n",
MapShareItem, ("@now", now), ("@n", limit));
public ShareItem? GetShareItem(Id16 shareId, Id16 itemId) => One(
$"SELECT {ShareItemCols} FROM share_items WHERE share_id=@s AND item_id=@i", MapShareItem,
("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
public void InsertShareItem(ShareItem i) => Run(
"INSERT INTO share_items (share_id,item_id,flags,max_downloads,downloads,frame_count,max_index,final_index,bytes,created_at,expires_at,close_deadline) VALUES (@s,@i,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd)",
("@s", i.ShareId.ToArray()), ("@i", i.ItemId.ToArray()), ("@f", (int)i.Flags),
("@md", i.MaxDownloads), ("@d", i.Downloads), ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex),
("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline));
public void UpdateShareItem(ShareItem i) => Run(
"UPDATE share_items SET downloads=@d,frame_count=@fc,max_index=@mi,final_index=@fi,bytes=@b,expires_at=@ex,close_deadline=@cd WHERE share_id=@s AND item_id=@i",
("@d", i.Downloads), ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex), ("@b", i.Bytes),
("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@s", i.ShareId.ToArray()), ("@i", i.ItemId.ToArray()));
public void DeleteShareItemRows(Id16 shareId, Id16 itemId)
{
Run("DELETE FROM share_frames WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
Run("DELETE FROM share_items WHERE share_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray()));
}
private static ShareFrame MapShareFrame(SqliteDataReader r) => new(
IdAt(r, 0), IdAt(r, 1), (uint)r.GetInt64(2), r.GetInt64(3), r.GetInt32(4), Blob(r, 5), BlobOrNull(r, 6),
r.IsDBNull(7) ? null : r.GetString(7));
public ShareFrame? GetShareFrame(Id16 s, Id16 i, uint n) => One(
$"SELECT {ShareFrameCols} FROM share_frames WHERE share_id=@s AND item_id=@i AND frame_index=@n", MapShareFrame,
("@s", s.ToArray()), ("@i", i.ToArray()), ("@n", (long)n));
public void InsertShareFrame(ShareFrame f) => Run(
$"INSERT INTO share_frames ({ShareFrameCols}) VALUES (@s,@i,@n,@seq,@len,@digest,@inline,@key)",
("@s", f.ShareId.ToArray()), ("@i", f.ItemId.ToArray()), ("@n", (long)f.Index), ("@seq", f.AckSeq),
("@len", f.Length), ("@digest", f.Digest), ("@inline", f.Inline), ("@key", f.BlobKey));
public IReadOnlyList<ShareFrame> ListShareFrames(Id16 s, Id16 i, uint from, int limit) => Many(
$"SELECT {ShareFrameCols} FROM share_frames WHERE share_id=@s AND item_id=@i AND frame_index>=@from ORDER BY frame_index LIMIT @n",
MapShareFrame, ("@s", s.ToArray()), ("@i", i.ToArray()), ("@from", (long)from), ("@n", limit));
// ───────── 频道 ─────────
private static Channel MapChannel(SqliteDataReader r) => new(
IdAt(r, 0),
(ChannelMode)r.GetInt32(1),
JsonSerializer.Deserialize(r.GetString(2), CoreJsonContext.Default.ChannelSettings)!,
BlobOrNull(r, 3),
BlobOrNull(r, 4),
Blob(r, 5),
r.GetInt64(6), r.GetInt64(7), r.GetInt64(8), r.GetInt64(9), r.GetInt64(10));
public Channel? GetChannel(Id16 id) =>
One($"SELECT {ChannelCols} FROM channels WHERE id=@id", MapChannel, ("@id", id.ToArray()));
public void InsertChannel(Channel c) => Run(
$"INSERT INTO channels ({ChannelCols}) VALUES (@id,@mode,@settings,@rh,@wh,@oh,@created,@expires,@seq,@comp,@bytes)",
("@id", c.Id.ToArray()), ("@mode", (int)c.Mode),
("@settings", JsonSerializer.Serialize(c.Settings, CoreJsonContext.Default.ChannelSettings)),
("@rh", c.ReadHash), ("@wh", c.WriteHash), ("@oh", c.OwnerHash),
("@created", c.CreatedAt), ("@expires", c.ExpiresAt),
("@seq", c.LastSeq), ("@comp", c.CompactedThrough), ("@bytes", c.Bytes));
public void UpdateChannel(Id16 id, ChannelSettings settings, long expiresAt) => Run(
"UPDATE channels SET settings=@s, expires_at=@e WHERE id=@id",
("@s", JsonSerializer.Serialize(settings, CoreJsonContext.Default.ChannelSettings)),
("@e", expiresAt), ("@id", id.ToArray()));
public void TouchChannel(Id16 id, long expiresAt) =>
Run("UPDATE channels SET expires_at=@e WHERE id=@id", ("@e", expiresAt), ("@id", id.ToArray()));
public void AddChannelBytes(Id16 id, long delta) =>
Run("UPDATE channels SET bytes=bytes+@d WHERE id=@id", ("@d", delta), ("@id", id.ToArray()));
public void SetCompactedThrough(Id16 id, long seq) =>
Run("UPDATE channels SET compacted_through=@s WHERE id=@id", ("@s", seq), ("@id", id.ToArray()));
public long NextSeq(Id16 id) =>
(long)Scalar("UPDATE channels SET last_seq=last_seq+1 WHERE id=@id RETURNING last_seq", ("@id", id.ToArray()))!;
public void DeleteChannelRows(Id16 id)
{
byte[] key = id.ToArray();
Run("DELETE FROM frames WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM log WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM items WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM seats WHERE channel_id=@c", ("@c", key));
Run("DELETE FROM channels WHERE id=@c", ("@c", key));
}
public IReadOnlyList<Id16> ListExpiredChannels(long now, int limit) =>
Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit));
// ───────── 席位 ─────────
private static Seat MapSeat(SqliteDataReader r) => new(Blob(r, 0), r.GetInt64(1), BlobOrNull(r, 2));
public Seat? GetSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => One(
"SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
MapSeat, ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
public int CountSeats(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM seats WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
public void InsertSeat(Id16 channelId, Seat seat) => Run(
"INSERT INTO seats (channel_id, endpoint_hash, label, joined_at) VALUES (@c,@e,@l,@j)",
("@c", channelId.ToArray()), ("@e", seat.EndpointHash), ("@l", seat.Label), ("@j", seat.JoinedAt));
public bool DeleteSeat(Id16 channelId, ReadOnlySpan<byte> endpointHash) => Run(
"DELETE FROM seats WHERE channel_id=@c AND endpoint_hash=@e",
("@c", channelId.ToArray()), ("@e", endpointHash.ToArray())) > 0;
public void SetSeatLabel(Id16 channelId, ReadOnlySpan<byte> endpointHash, byte[] label) => Run(
"UPDATE seats SET label=@l WHERE channel_id=@c AND endpoint_hash=@e",
("@l", label), ("@c", channelId.ToArray()), ("@e", endpointHash.ToArray()));
public IReadOnlyList<Seat> ListSeats(Id16 channelId) => Many(
"SELECT endpoint_hash, joined_at, label FROM seats WHERE channel_id=@c ORDER BY joined_at, endpoint_hash",
MapSeat, ("@c", channelId.ToArray()));
// ───────── 条目 ─────────
private static Item MapItem(SqliteDataReader r) => new(
IdAt(r, 0), IdAt(r, 1), Blob(r, 2), (FrameFlags)r.GetInt32(3),
r.GetInt64(4), r.GetInt64(5), r.GetInt32(6), r.GetInt64(7), LongOrNull(r, 8),
r.GetInt64(9), r.GetInt64(10), r.GetInt64(11), LongOrNull(r, 12),
r.IsDBNull(13) ? null : r.GetString(13));
public Item? GetItem(Id16 channelId, Id16 itemId) => One(
$"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id=@i",
MapItem, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()));
public void InsertItem(Item i) => Run(
$"INSERT INTO items ({ItemCols}) VALUES (@c,@i,@a,@f,@md,@d,@fc,@mi,@fi,@b,@cr,@ex,@cd,@l)",
("@c", i.ChannelId.ToArray()), ("@i", i.ItemId.ToArray()), ("@a", i.AuthorHash),
("@f", (int)i.Flags), ("@md", i.MaxDownloads), ("@d", i.Downloads),
("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex),
("@b", i.Bytes), ("@cr", i.CreatedAt), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline), ("@l", i.Label));
public void UpdateItem(Item i) => Run(
"UPDATE items SET downloads=@d, frame_count=@fc, max_index=@mi, final_index=@fi, bytes=@b, expires_at=@ex, close_deadline=@cd WHERE channel_id=@c AND item_id=@i",
("@d", i.Downloads), ("@fc", i.FrameCount), ("@mi", i.MaxIndex), ("@fi", i.FinalIndex),
("@b", i.Bytes), ("@ex", i.ExpiresAt), ("@cd", i.CloseDeadline),
("@c", i.ChannelId.ToArray()), ("@i", i.ItemId.ToArray()));
public void DeleteItemRows(Id16 channelId, Id16 itemId)
{
byte[] c = channelId.ToArray(), i = itemId.ToArray();
Run("DELETE FROM frames WHERE channel_id=@c AND item_id=@i", ("@c", c), ("@i", i));
// 墓碑保留,其余记录(帧、announce)随条目一起删除
Run("DELETE FROM log WHERE channel_id=@c AND item_id=@i AND kind<>@t", ("@c", c), ("@i", i), ("@t", (int)LogKind.Tombstone));
Run("DELETE FROM items WHERE channel_id=@c AND item_id=@i", ("@c", c), ("@i", i));
}
public int CountItems(Id16 channelId) =>
(int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c", ("@c", channelId.ToArray()))!;
public IReadOnlyList<Item> ListOldestItems(Id16 channelId, int limit) => Many(
$"SELECT {ItemCols} FROM items WHERE channel_id=@c ORDER BY created_at, rowid LIMIT @n",
MapItem, ("@c", channelId.ToArray()), ("@n", limit));
public Item? FindSnapshotItem(Id16 channelId, Id16 excludeItemId) => One(
$"SELECT {ItemCols} FROM items WHERE channel_id=@c AND item_id<>@x AND (flags & @s)<>0 ORDER BY created_at DESC, rowid DESC LIMIT 1",
MapItem, ("@c", channelId.ToArray()), ("@x", excludeItemId.ToArray()), ("@s", (int)FrameFlags.Snapshot));
public IReadOnlyList<Item> ListExpiredItems(long now, int limit) => Many(
$"SELECT {ItemCols} FROM items WHERE expires_at<=@now OR (close_deadline IS NOT NULL AND close_deadline<=@now) LIMIT @n",
MapItem, ("@now", now), ("@n", limit));
// ───────── 帧 ─────────
private static StoredFrame MapFrame(SqliteDataReader r) => new(
IdAt(r, 0), IdAt(r, 1), (uint)r.GetInt64(2), r.GetInt64(3), r.GetInt32(4),
Blob(r, 5), BlobOrNull(r, 6), r.IsDBNull(7) ? null : r.GetString(7));
public StoredFrame? GetFrame(Id16 channelId, Id16 itemId, uint index) => One(
$"SELECT {FrameCols} FROM frames WHERE channel_id=@c AND item_id=@i AND frame_index=@x",
MapFrame, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()), ("@x", (long)index));
public void InsertFrame(StoredFrame f) => Run(
$"INSERT INTO frames ({FrameCols}) VALUES (@c,@i,@x,@s,@len,@dg,@inl,@bk)",
("@c", f.ChannelId.ToArray()), ("@i", f.ItemId.ToArray()), ("@x", (long)f.Index),
("@s", f.Seq), ("@len", f.Length), ("@dg", f.Digest), ("@inl", f.Inline), ("@bk", f.BlobKey));
public IReadOnlyList<StoredFrame> ListFrames(Id16 channelId, Id16 itemId, uint fromIndex, int limit) => Many(
$"SELECT {FrameCols} FROM frames WHERE channel_id=@c AND item_id=@i AND frame_index>=@x ORDER BY frame_index LIMIT @n",
MapFrame, ("@c", channelId.ToArray()), ("@i", itemId.ToArray()), ("@x", (long)fromIndex), ("@n", limit));
public bool BlobKeyExists(string blobKey) =>
Scalar("SELECT 1 FROM frames WHERE blob_key=@k UNION ALL SELECT 1 FROM share_frames WHERE blob_key=@k LIMIT 1", ("@k", blobKey)) is not null;
// ───────── 日志 ─────────
public void InsertLog(Id16 channelId, LogEntry e) => Run(
"INSERT INTO log (channel_id, seq, kind, item_id, frame_index, aux, created_at) VALUES (@c,@s,@k,@i,@x,@a,@t)",
("@c", channelId.ToArray()), ("@s", e.Seq), ("@k", (int)e.Kind), ("@i", e.ItemId.ToArray()),
("@x", (long)e.FrameIndex), ("@a", (int)e.Aux), ("@t", e.CreatedAt));
public IReadOnlyList<LogEntry> ReadLog(Id16 channelId, long fromSeq, int limit) => Many(
"SELECT seq, kind, item_id, frame_index, aux, created_at FROM log WHERE channel_id=@c AND seq>=@s ORDER BY seq LIMIT @n",
r => new LogEntry(r.GetInt64(0), (LogKind)r.GetInt32(1), IdAt(r, 2), (uint)r.GetInt64(3), (byte)r.GetInt32(4), r.GetInt64(5)),
("@c", channelId.ToArray()), ("@s", fromSeq), ("@n", limit));
public long PruneTombstones(Id16 channelId, long olderThanMs)
{
byte[] c = channelId.ToArray();
var max = Scalar("SELECT MAX(seq) FROM log WHERE channel_id=@c AND kind=@k AND created_at<@t",
("@c", c), ("@k", (int)LogKind.Tombstone), ("@t", olderThanMs));
if (max is not long seq) return 0;
Run("DELETE FROM log WHERE channel_id=@c AND kind=@k AND seq<=@s AND created_at<@t",
("@c", c), ("@k", (int)LogKind.Tombstone), ("@s", seq), ("@t", olderThanMs));
return seq;
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\WebDrop.Core\WebDrop.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.12" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>