feat(server): implement sealed share lifecycle
Signed-off-by: JustMe <mr.toing@outlook.com>
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user