feat(api)!: fold devices into users and report member presence
Contract 7.0.0 (breaking). Devices become an attribute of the account;
members are managed per user, and only unauthenticated guests keep a
per-terminal registration.
- rename seats -> max_members, seats_used -> members_used,
seats_full -> members_full, SeatJoined -> JoinResult, the `seats`
stream control message -> `members`, and POST /channels/{id}/seats ->
POST /channels/{id}/join (stored channel settings are migrated on start)
- drop per-terminal encrypted device labels and the seat list/release
endpoints; add GET/DELETE /channels/{id}/guests for guest terminals
- authenticate account members by account session only: the terminal key
is optional for logged-in accounts, no seat row is written for them,
and gated-download grants are counted per user (per terminal for guests)
- GET /channels/{id}/members now returns online, devices and
online_devices per account; presence is tracked in memory from active
WebSocket/SSE subscriptions and is not pushed over the stream
- owner invitation list no longer returns accounts that already joined
This commit is contained in:
1 parent
0fd2a269d9
commit
d946fc5e8a
35 files changed
+517
-490
No files matched your search
@@ -66,8 +66,8 @@ public class ChannelServiceTests : IDisposable
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var info = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
|
||||
Assert.Equal(1, info.SeatsUsed);
|
||||
Assert.Equal(2, info.Settings.Seats);
|
||||
Assert.Equal(1, info.MembersUsed);
|
||||
Assert.Equal(2, info.Settings.MaxMembers);
|
||||
Assert.Equal(1, info.OldestSeq);
|
||||
Assert.Equal(0, info.LastSeq);
|
||||
}
|
||||
@@ -99,38 +99,36 @@ public class ChannelServiceTests : IDisposable
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var a = Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
var again = Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
Assert.Equal(a.EndpointHash, again.EndpointHash);
|
||||
Assert.Equal(2, again.SeatsUsed);
|
||||
Assert.Equal(2, again.SeatsMax);
|
||||
Assert.Equal(a.JoinedAt, again.JoinedAt);
|
||||
Assert.Equal(2, again.MembersUsed);
|
||||
Assert.Equal(2, again.MembersMax);
|
||||
|
||||
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint())));
|
||||
Assert.Equal(1, _env.Events.Seats); // 只有真正新增时才通知
|
||||
AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, ch.Reader(TestEnv.Endpoint())));
|
||||
Assert.Equal(1, _env.Events.Members); // 只有真正新增时才通知
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountMembersUseOneLimitAcrossUnlimitedDevices()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 2 });
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxMembers = 2 });
|
||||
var memberOne = TestEnv.Endpoint();
|
||||
var memberTwo = TestEnv.Endpoint();
|
||||
var ownerTwo = TestEnv.Endpoint();
|
||||
Credentials alice(byte[] endpoint, string device) => new(Role.Read, ch.ReadToken, endpoint, "alice", device);
|
||||
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).SeatsUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).SeatsUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).SeatsUsed);
|
||||
Assert.Equal(4, Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Count); // 设备仍单独登记,但不计入成员人数
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberOne, "alice-1")).MembersUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, alice(memberTwo, "alice-2")).MembersUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, ch.Owner(ownerTwo)).MembersUsed);
|
||||
Assert.Empty(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 账号的设备不对外列出,也不计入成员人数
|
||||
|
||||
var bob = new Credentials(Role.Read, ch.ReadToken, TestEnv.Endpoint(), "bob", "bob-1");
|
||||
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob));
|
||||
AssertCode(ErrorCode.MembersFull, () => Svc.Join(ch.Id, bob));
|
||||
|
||||
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), memberOne);
|
||||
Assert.Equal(2, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed); // 移除一台设备不会释放账号成员名额
|
||||
AssertCode(ErrorCode.SeatsFull, () => Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }));
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), memberOne)); // 账号设备不能按设备移除,只能撤销用户
|
||||
|
||||
Svc.RevokeInvitation(ch.Id, ch.Owner(_ownerEp), "alice");
|
||||
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).SeatsUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).SeatsUsed);
|
||||
Assert.Equal(1, Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)).MembersUsed);
|
||||
Assert.Equal(2, Svc.Join(ch.Id, bob with { EndpointHash = TestEnv.Endpoint() }).MembersUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -209,29 +207,25 @@ public class ChannelServiceTests : IDisposable
|
||||
public async Task ReleasedSeatLosesAccess()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
var joined = Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
await AppendSmall(ch, ch.Member(_memberEp));
|
||||
|
||||
Svc.ReleaseSeat(ch.Id, ch.Owner(_ownerEp), TokenHash.Compute(TestEnv.Random(1)).Length == 0 ? [] : Codec_Decode(joined.EndpointHash));
|
||||
Svc.ReleaseGuest(ch.Id, ch.Owner(_ownerEp), _memberEp);
|
||||
await AssertCode(ErrorCode.NotJoined, () => AppendSmall(ch, ch.Member(_memberEp)));
|
||||
}
|
||||
|
||||
private static byte[] Codec_Decode(string b64) =>
|
||||
Codec.TryDecodeExact(b64, 32, out var bytes) ? bytes : throw new InvalidOperationException();
|
||||
|
||||
[Fact]
|
||||
public void OnlyTheOwnerManagesSeatsAndSettings()
|
||||
public void OnlyTheOwnerManagesGuestsAndSettings()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
Svc.Join(ch.Id, ch.Member(_memberEp));
|
||||
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ListSeats(ch.Id, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { Seats = 5 }));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ListGuests(ch.Id, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.UpdateSettings(ch.Id, ch.Member(_memberEp), new ChannelSettingsPatch { MaxMembers = 5 }));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.DeleteChannel(ch.Id, ch.Member(_memberEp)));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseSeat(ch.Id, ch.Member(_memberEp), _ownerEp));
|
||||
AssertCode(ErrorCode.Forbidden, () => Svc.ReleaseGuest(ch.Id, ch.Member(_memberEp), _ownerEp));
|
||||
|
||||
var seats = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp));
|
||||
Assert.Equal(2, seats.Count);
|
||||
Assert.Single(Svc.ListGuests(ch.Id, ch.Owner(_ownerEp))); // 访客只有成员那一台
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -243,25 +237,13 @@ public class ChannelServiceTests : IDisposable
|
||||
|
||||
// Owner management uses A; an owner on a new device can manage a full channel without first taking a seat.
|
||||
Assert.Equal(ch.Id.ToBase64Url(), Svc.GetChannel(ch.Id, unseatedOwner).Id);
|
||||
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { Seats = 3 }).Settings.Seats);
|
||||
Assert.Equal(2, Svc.ListSeats(ch.Id, unseatedOwner).Count);
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseSeat(ch.Id, unseatedOwner, TestEnv.Endpoint()));
|
||||
Assert.Equal(3, Svc.UpdateSettings(ch.Id, unseatedOwner, new ChannelSettingsPatch { MaxMembers = 3 }).Settings.MaxMembers);
|
||||
Assert.Single(Svc.ListGuests(ch.Id, unseatedOwner));
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.ReleaseGuest(ch.Id, unseatedOwner, TestEnv.Endpoint()));
|
||||
Svc.DeleteChannel(ch.Id, unseatedOwner);
|
||||
AssertCode(ErrorCode.NotFound, () => Svc.GetChannel(ch.Id, ch.Owner(_ownerEp)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeatLabelIsStoredAndBounded()
|
||||
{
|
||||
var ch = _env.Create(_ownerEp);
|
||||
Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), [1, 2, 3]);
|
||||
var mine = Svc.ListSeats(ch.Id, ch.Owner(_ownerEp)).Single();
|
||||
Assert.Equal(Codec.Encode([1, 2, 3]), mine.Label);
|
||||
|
||||
AssertCode(ErrorCode.TooLarge, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), new byte[257]));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.SetSeatLabel(ch.Id, ch.Owner(_ownerEp), []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateSettingsAppliesPatchAndRecomputesExpiry()
|
||||
{
|
||||
@@ -269,15 +251,15 @@ public class ChannelServiceTests : IDisposable
|
||||
var before = Svc.GetChannel(ch.Id, ch.Owner(_ownerEp));
|
||||
|
||||
_env.Clock.Advance(TimeSpan.FromMinutes(1));
|
||||
var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 3, TtlSeconds = 3600 });
|
||||
var after = Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MaxMembers = 3, TtlSeconds = 3600 });
|
||||
|
||||
Assert.Equal(3, after.Settings.Seats);
|
||||
Assert.Equal(3, after.Settings.MaxMembers);
|
||||
Assert.Equal(3600, after.Settings.TtlSeconds);
|
||||
Assert.Equal(_env.Clock.NowMs + 3600_000L, after.ExpiresAt);
|
||||
Assert.True(after.ExpiresAt < before.ExpiresAt);
|
||||
Assert.Equal(1, _env.Events.Settings);
|
||||
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { Seats = 0 }));
|
||||
AssertCode(ErrorCode.BadRequest, () => Svc.UpdateSettings(ch.Id, ch.Owner(_ownerEp), new ChannelSettingsPatch { MaxMembers = 0 }));
|
||||
}
|
||||
|
||||
// ───── 追加:幂等、形状、权限 ─────
|
||||
@@ -524,7 +506,7 @@ public class ChannelServiceTests : IDisposable
|
||||
public async Task ResumeNeedsACountedDownloadFromTheSameEndpoint()
|
||||
{
|
||||
var otherEndpoint = TestEnv.Endpoint();
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { Seats = 3, FileMaxDownloads = 1 });
|
||||
var ch = _env.Create(_ownerEp, settings: new ChannelSettings { MaxMembers = 3, FileMaxDownloads = 1 });
|
||||
Svc.Join(ch.Id, ch.Reader(_memberEp));
|
||||
Svc.Join(ch.Id, ch.Reader(otherEndpoint));
|
||||
var item = await UploadGated(ch, ch.Owner(_ownerEp), 2);
|
||||
|
||||
@@ -155,9 +155,9 @@ public class PolicyTests
|
||||
[Fact]
|
||||
public void SettingsPatchOnlyChangesProvidedFields()
|
||||
{
|
||||
var before = new ChannelSettings { Seats = 2, TtlSeconds = 100 };
|
||||
var after = new ChannelSettingsPatch { Seats = 5 }.ApplyTo(before);
|
||||
Assert.Equal(5, after.Seats);
|
||||
var before = new ChannelSettings { MaxMembers = 2, TtlSeconds = 100 };
|
||||
var after = new ChannelSettingsPatch { MaxMembers = 5 }.ApplyTo(before);
|
||||
Assert.Equal(5, after.MaxMembers);
|
||||
Assert.Equal(100, after.TtlSeconds);
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ public class PolicyTests
|
||||
[InlineData(2, 86400, 0)]
|
||||
public void SettingsValidationRejectsOutOfRange(int seats, int ttl, int maxItems)
|
||||
{
|
||||
var s = new ChannelSettings { Seats = seats, TtlSeconds = ttl, MaxItems = maxItems };
|
||||
var s = new ChannelSettings { MaxMembers = seats, TtlSeconds = ttl, MaxItems = maxItems };
|
||||
var ex = Assert.Throws<WebDropException>(() => s.Validate(new ServerLimits()));
|
||||
Assert.Equal(ErrorCode.BadRequest, ex.Code);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ public sealed class FakeClock : IClock
|
||||
public sealed class RecordingEvents : IChannelEvents
|
||||
{
|
||||
public List<(Id16 Channel, long Seq)> Advances { get; } = [];
|
||||
public int Settings, Seats, Deleted;
|
||||
public int Settings, Members, Deleted;
|
||||
public void Advanced(Id16 channelId, long seq) { lock (Advances) Advances.Add((channelId, seq)); }
|
||||
public void SettingsChanged(Id16 channelId) => Interlocked.Increment(ref Settings);
|
||||
public void SeatsChanged(Id16 channelId) => Interlocked.Increment(ref Seats);
|
||||
public void MembersChanged(Id16 channelId) => Interlocked.Increment(ref Members);
|
||||
public void ChannelDeleted(Id16 channelId) => Interlocked.Increment(ref Deleted);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user