using System.Text.Json; using Microsoft.Data.Sqlite; using WebDrop.Core; namespace WebDrop.Storage; /// /// SQLite 实现:WAL 模式,写入由单个连接加锁串行执行(保证 seq 严格连续),读取使用独立连接。 /// 全部使用手写 SQL 与显式映射,不依赖反射,可 NativeAOT 发布。 /// public sealed class SqliteChannelStore : IResourceStore, IDisposable { private const int SchemaVersion = 1; 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;"); InitializeSchema(); // v8: 频道只接受登录账号,不再有访客席位;丢弃旧的席位表和针对终端的拒绝记录(幂等) Exec(_writeConn, "DROP TABLE IF EXISTS seats;"); Exec(_writeConn, "DELETE FROM channel_join_denials WHERE target LIKE 'endpoint:%';"); // v7: 设置里的成员人数上限由 seats 改名为 max_members,迁移已有频道的设置 JSON(幂等) Exec(_writeConn, "UPDATE channels SET settings = json_remove(json_set(settings, '$.max_members', json_extract(settings, '$.seats')), '$.seats') WHERE json_extract(settings, '$.seats') IS NOT NULL AND json_extract(settings, '$.max_members') IS NULL;"); try { Exec(_writeConn, "ALTER TABLE channel_avatars ADD COLUMN thumb TEXT;"); } catch (SqliteException) { /* 新建的表或已经迁移过 */ } // Additive table: retain the running v2 database and its existing channel data. Exec(_writeConn, """ CREATE TABLE IF NOT EXISTS channel_key_packages ( channel_id BLOB NOT NULL, username TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (channel_id, username) ) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS ix_channel_keys_username ON channel_key_packages(username); CREATE TABLE IF NOT EXISTS workspace_structures (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS workspace_crypto (space_id BLOB PRIMARY KEY, state TEXT NOT NULL) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS channel_avatars (space_id BLOB PRIMARY KEY, avatar TEXT NOT NULL, thumb TEXT) WITHOUT ROWID; """); } public T Read(Func action) { for (int attempt = 0; ; attempt++) { using var conn = Open(); // 显式事务让一次 Read 内的多条查询看到同一份快照 // 读事务延迟到首次访问时才取快照,避免并发读预检抢占 SQLite 写锁。 SqliteTransaction tx; try { tx = conn.BeginTransaction(deferred: true); } // 并发压力下偶发 SQLITE_ERROR(CI 的并行追加测试里出现过):丢弃这条连接和池里的闲置连接,换新连接重试 catch (SqliteException e) when (e.SqliteErrorCode == 1 && attempt < 2) { SqliteConnection.ClearPool(conn); continue; } using (tx) return action(new SqliteTx(conn)); } } public T Write(Func 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.ClearPool(_writeConn); } 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 InitializeSchema() { using var cmd = _writeConn.CreateCommand(); cmd.CommandText = "PRAGMA user_version;"; long version = (long)cmd.ExecuteScalar()!; if (version == SchemaVersion) return; if (version != 0) throw new InvalidOperationException("存储版本不兼容当前应用契约"); using var tx = _writeConn.BeginTransaction(); Exec(_writeConn, Schema); Exec(_writeConn, $"PRAGMA user_version={SchemaVersion};"); tx.Commit(); } private const string Schema = """ 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, creator_account TEXT NOT NULL, join_password_hash BLOB, join_password_salt BLOB ); CREATE INDEX ix_channels_expires ON channels(expires_at); CREATE TABLE items ( channel_id BLOB NOT NULL, item_id BLOB NOT NULL, author_hash BLOB NOT NULL, author_account TEXT, 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 (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); 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, creator_account TEXT NOT NULL ); 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; CREATE TABLE download_grants ( resource_id BLOB NOT NULL, item_id BLOB NOT NULL, endpoint_hash BLOB NOT NULL, PRIMARY KEY (resource_id, item_id, endpoint_hash) ) WITHOUT ROWID; CREATE INDEX ix_download_grants_resource ON download_grants(resource_id); CREATE TABLE channel_invitations(channel_id BLOB NOT NULL,username TEXT NOT NULL,payload TEXT NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username)); CREATE INDEX ix_channel_invitations_username ON channel_invitations(username); CREATE TABLE channel_join_denials(channel_id BLOB NOT NULL,target TEXT NOT NULL,PRIMARY KEY(channel_id,target)); CREATE TABLE channel_members(channel_id BLOB NOT NULL,username TEXT NOT NULL,role TEXT NOT NULL,joined_at INTEGER NOT NULL,PRIMARY KEY(channel_id,username)); """; 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, creator_account, join_password_hash, join_password_salt"; public WorkspaceState? GetWorkspace(Id16 id) => One("SELECT state FROM workspace_structures WHERE space_id=@id", r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceJson.Default.WorkspaceState)!, ("@id", id.ToArray())); public CryptoState GetWorkspaceCrypto(Id16 id) => One("SELECT state FROM workspace_crypto WHERE space_id=@id", r => JsonSerializer.Deserialize(r.GetString(0), WorkspaceCryptoJson.Default.CryptoState)!, ("@id", id.ToArray())) ?? new CryptoState(); public void PutWorkspaceCrypto(Id16 id, CryptoState state) => Run( "INSERT INTO workspace_crypto(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state", ("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceCryptoJson.Default.CryptoState))); public IReadOnlyList ListAccountSpaces(string username, long now) => Many( "SELECT c.id FROM channels c JOIN channel_members m ON m.channel_id=c.id WHERE m.username=@u AND c.expires_at>@n ORDER BY c.created_at DESC", r => Id16.FromBytes((byte[])r[0]), ("@u", username), ("@n", now)); public void PutWorkspace(Id16 id, WorkspaceState state) => Run( "INSERT INTO workspace_structures(space_id,state) VALUES(@id,@s) ON CONFLICT(space_id) DO UPDATE SET state=excluded.state", ("@id", id.ToArray()), ("@s", JsonSerializer.Serialize(state, WorkspaceJson.Default.WorkspaceState))); 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, author_account"; 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(string sql, Func 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 Many(string sql, Func map, params (string, object?)[] args) { using var cmd = Cmd(sql, args); using var r = cmd.ExecuteReader(); var list = new List(); 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), r.GetString(10)); 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,creator_account 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,creator_account) VALUES (@id,@settings,@read,@owner,@creator,@created,@expires,@sealed,@manifest,@bytes,@account)", ("@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), ("@account", s.CreatorAccount)); 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,creator_account=@account 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()), ("@account", s.CreatorAccount)); public IReadOnlyList 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 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 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 download_grants WHERE resource_id=@id", ("@id", key)); 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 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 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 download_grants WHERE resource_id=@s AND item_id=@i", ("@s", shareId.ToArray()), ("@i", itemId.ToArray())); 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())); } public bool HasDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan endpointHash) => Scalar( "SELECT 1 FROM download_grants WHERE resource_id=@r AND item_id=@i AND endpoint_hash=@e", ("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.ToArray())) is not null; public void AddDownloadGrant(Id16 resourceId, Id16 itemId, ReadOnlySpan endpointHash) => Run( "INSERT OR IGNORE INTO download_grants (resource_id,item_id,endpoint_hash) VALUES (@r,@i,@e)", ("@r", resourceId.ToArray()), ("@i", itemId.ToArray()), ("@e", endpointHash.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 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), r.GetString(11), BlobOrNull(r, 12), BlobOrNull(r, 13)); 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,@account,@ph,@ps)", ("@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), ("@account", c.CreatorAccount), ("@ph", c.JoinPasswordHash), ("@ps", c.JoinPasswordSalt)); 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 SetJoinPassword(Id16 id, byte[]? hash, byte[]? salt) => Run("UPDATE channels SET join_password_hash=@h,join_password_salt=@s WHERE id=@id", ("@h", hash), ("@s", salt), ("@id", id.ToArray())); public bool HasInvitation(Id16 id, string username) => Scalar("SELECT 1 FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)) is not null; public void PutInvitation(ChannelInvitation i) => Run("INSERT INTO channel_invitations(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at", ("@id", i.ChannelId.ToArray()), ("@u", i.Username), ("@p", i.Payload), ("@t", i.CreatedAt)); private static ChannelInvitation MapInvitation(SqliteDataReader r) => new(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetInt64(3)); public IReadOnlyList ListInvitations(Id16 id) => Many("SELECT channel_id,username,payload,created_at FROM channel_invitations i WHERE channel_id=@id AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@id", id.ToArray())); public IReadOnlyList ListUserInvitations(string username) => Many("SELECT i.channel_id,i.username,i.payload,i.created_at FROM channel_invitations i JOIN channels c ON c.id=i.channel_id WHERE i.username=@u AND c.expires_at>@now AND NOT EXISTS (SELECT 1 FROM channel_members m WHERE m.channel_id=i.channel_id AND m.username=i.username)", MapInvitation, ("@u", username), ("@now", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())); public void DeleteInvitation(Id16 id, string username) => Run("DELETE FROM channel_invitations WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)); public void PutKeyPackage(Id16 id, string username, string payload, long createdAt) => Run( "INSERT INTO channel_key_packages(channel_id,username,payload,created_at) VALUES(@id,@u,@p,@t) ON CONFLICT(channel_id,username) DO UPDATE SET payload=excluded.payload,created_at=excluded.created_at", ("@id", id.ToArray()), ("@u", username), ("@p", payload), ("@t", createdAt)); public void DeleteKeyPackage(Id16 id, string username) => Run("DELETE FROM channel_key_packages WHERE channel_id=@id AND username=@u", ("@id", id.ToArray()), ("@u", username)); public IReadOnlyList ListUserKeyPackages(string username, long now) => Many( """ SELECT k.channel_id,k.username,m.role,k.payload,k.created_at FROM channel_key_packages k JOIN channels c ON c.id=k.channel_id JOIN channel_members m ON m.channel_id=k.channel_id AND m.username=k.username WHERE k.username=@u AND c.expires_at>@now """, r => new ChannelKeyPackage(IdAt(r, 0), r.GetString(1), r.GetString(2), r.GetString(3), r.GetInt64(4)), ("@u", username), ("@now", now)); private static ChannelMember MapMember(SqliteDataReader r) => new(r.GetString(0), r.GetString(1), r.GetInt64(2)); public ChannelMember? GetChannelMember(Id16 id, string username) => One("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id AND username=@u", MapMember, ("@id", id.ToArray()), ("@u", username)); public IReadOnlyList ListChannelMembers(Id16 id) => Many("SELECT username,role,joined_at FROM channel_members WHERE channel_id=@id ORDER BY joined_at,username", MapMember, ("@id", id.ToArray())); public void PutChannelMember(Id16 id, ChannelMember member) => Run("INSERT OR IGNORE INTO channel_members(channel_id,username,role,joined_at) VALUES(@id,@u,@r,@t)", ("@id", id.ToArray()), ("@u", member.Username), ("@r", member.Role), ("@t", member.JoinedAt)); public void DeleteChannelMember(Id16 id, string username) => Run("DELETE FROM channel_members WHERE channel_id=@id AND username=@u AND role<>'owner'", ("@id", id.ToArray()), ("@u", username)); public bool IsJoinDenied(Id16 id, string username) => Scalar("SELECT 1 FROM channel_join_denials WHERE channel_id=@id AND target=@a LIMIT 1", ("@id", id.ToArray()), ("@a", "account:" + username)) is not null; public void DenyAccount(Id16 id, string username) => Run("INSERT OR IGNORE INTO channel_join_denials VALUES(@id,@t)", ("@id", id.ToArray()), ("@t", "account:" + username)); public void AllowAccount(Id16 id, string username) => Run("DELETE FROM channel_join_denials WHERE channel_id=@id AND target=@t", ("@id", id.ToArray()), ("@t", "account:" + username)); 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 download_grants WHERE resource_id=@c", ("@c", key)); 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 channels WHERE id=@c", ("@c", key)); Run("DELETE FROM channel_invitations WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM channel_key_packages WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM workspace_structures WHERE space_id=@c", ("@c", key)); Run("DELETE FROM workspace_crypto WHERE space_id=@c", ("@c", key)); Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", key)); Run("DELETE FROM channel_members WHERE channel_id=@c", ("@c", key)); Run("DELETE FROM channel_join_denials WHERE channel_id=@c", ("@c", key)); } public string? GetChannelAvatar(Id16 id, bool thumb) => Scalar(thumb ? "SELECT COALESCE(thumb, avatar) FROM channel_avatars WHERE space_id=@c" : "SELECT avatar FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())) as string; public void SetChannelAvatar(Id16 id, string? avatar, string? thumb) { if (avatar is null) Run("DELETE FROM channel_avatars WHERE space_id=@c", ("@c", id.ToArray())); else Run("INSERT INTO channel_avatars (space_id, avatar, thumb) VALUES (@c,@a,@t) ON CONFLICT(space_id) DO UPDATE SET avatar=excluded.avatar, thumb=excluded.thumb", ("@c", id.ToArray()), ("@a", avatar), ("@t", (object?)thumb ?? DBNull.Value)); } public IReadOnlyList ListExpiredChannels(long now, int limit) => Many("SELECT id FROM channels WHERE expires_at<=@now LIMIT @n", r => IdAt(r, 0), ("@now", now), ("@n", limit)); // ───────── 成员 ───────── public int CountMembers(Id16 channelId) => (int)(long)Scalar("SELECT COUNT(*) FROM channel_members WHERE channel_id=@c", ("@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,@account)", ("@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), ("@account", i.AuthorAccount)); 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 download_grants WHERE resource_id=@c AND item_id=@i", ("@c", c), ("@i", i)); 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 AND (flags & 16)=0", ("@c", channelId.ToArray()))!; public int CountControlItems(Id16 channelId) => (int)(long)Scalar("SELECT COUNT(*) FROM items WHERE channel_id=@c AND (flags & 16)<>0", ("@c", channelId.ToArray()))!; public IReadOnlyList ListOldestItems(Id16 channelId, int limit) => Many( $"SELECT {ItemCols} FROM items WHERE channel_id=@c AND (flags & 16)=0 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 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 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 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; } } }