diff --git a/server/src/WebDrop.Api/ApiJson.cs b/server/src/WebDrop.Api/ApiJson.cs index 801fd30..8224927 100644 --- a/server/src/WebDrop.Api/ApiJson.cs +++ b/server/src/WebDrop.Api/ApiJson.cs @@ -159,6 +159,9 @@ public sealed class MemberBody /// 该账号在本频道登记过的设备(终端)数;设备只是账号的属性,不是独立成员。 [JsonPropertyName("devices")] public required int Devices { get; init; } [JsonPropertyName("online_devices")] public required int OnlineDevices { get; init; } + + /// 该账号任一设备最近一次活跃的 Unix 时间戳(登录时是秒,活跃续期后是毫秒,客户端按量级区分);没有登录过设备时为 0。 + [JsonPropertyName("last_seen")] public required long LastSeen { get; init; } } public sealed class AccountProfileRequest diff --git a/server/src/WebDrop.Api/ChannelHub.cs b/server/src/WebDrop.Api/ChannelHub.cs index 61ca094..2ea60a0 100644 --- a/server/src/WebDrop.Api/ChannelHub.cs +++ b/server/src/WebDrop.Api/ChannelHub.cs @@ -12,6 +12,10 @@ public sealed class ChannelHub : IChannelEvents private readonly object _gate = new(); private readonly Dictionary _signals = []; + // 账号级的「有频道前进了」信号:不管有没有人订阅这个频道都会记录,供空间列表上的未读提示用 + private readonly Dictionary _advanced = []; + private TaskCompletionSource _anyAdvanced = new(TaskCreationOptions.RunContinuationsAsynchronously); + // 在线状态:频道 -> 账号 -> 账号设备 -> 活动订阅数。只记账号。 private readonly Dictionary>> _presence = []; @@ -66,7 +70,24 @@ public sealed class ChannelHub : IChannelEvents public void Dispose() => Interlocked.Exchange(ref _release, null)?.Invoke(); } - public void Advanced(Id16 channelId, long seq) => Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq)); + public void Advanced(Id16 channelId, long seq) + { + TaskCompletionSource old; + lock (_gate) + { + _advanced[channelId] = Math.Max(_advanced.GetValueOrDefault(channelId), seq); + old = _anyAdvanced; + _anyAdvanced = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + old.TrySetResult(); + Publish(channelId, s => s.Seq = Math.Max(s.Seq, seq)); + } + + /// 先取这个任务、再读 ,最后等待任务:不会漏掉中间的前进。 + public Task AnyAdvanced { get { lock (_gate) return _anyAdvanced.Task; } } + + /// 本进程启动以来该频道见到的最大序号;没有见到过为 0。 + public long AdvancedSeq(Id16 channelId) { lock (_gate) return _advanced.GetValueOrDefault(channelId); } public void SettingsChanged(Id16 channelId) => Publish(channelId, s => s.SettingsVersion++); public void MembersChanged(Id16 channelId) => Publish(channelId, s => s.MembersVersion++); public void ChannelDeleted(Id16 channelId) => Publish(channelId, s => s.Deleted = true); diff --git a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs index 772df9a..dcd0050 100644 --- a/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs +++ b/server/src/WebDrop.Api/Endpoints.ChannelAccess.cs @@ -12,6 +12,44 @@ public static partial class Endpoints if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); return Json(svc.AccountSpaces(username).ToList(), ApiJson.Default.ListChannelInfo); }); + // 空间列表的实时提示:账号所在空间有新内容就推一条 {id,last_seq}。只含序号,不含内容 + api.MapGet("/accounts/spaces/events", async (HttpContext ctx, ChannelService svc, ChannelHub hub) => + { + if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); + var ct = ctx.RequestAborted; + ctx.Response.ContentType = "text/event-stream"; + ctx.Response.Headers["X-Accel-Buffering"] = "no"; + await ctx.Response.StartAsync(ct); + var sent = new Dictionary(); + try + { + while (!ct.IsCancellationRequested) + { + // 每次唤醒都重新核对登录:退出登录、被移除设备后,这条流立刻结束 + if (!TryAccount(ctx, accounts, out var current) || current != username) + { + await ctx.Response.WriteAsync("event: error\ndata: {\"error\":\"account_required\"}\n\n", ct); + break; + } + var changed = hub.AnyAdvanced; + foreach (var id in svc.AccountSpaceIds(username)) + { + var seq = hub.AdvancedSeq(id); + // 第一次只记下当前值:连接建立前的进展客户端已经从列表里拿到了 + if (!sent.TryGetValue(id, out var known)) { sent[id] = seq; continue; } + if (seq <= known) continue; + sent[id] = seq; + await ctx.Response.WriteAsync($"event: space\ndata: {{\"id\":\"{id.ToBase64Url()}\",\"last_seq\":{seq}}}\n\n", ct); + } + await ctx.Response.Body.FlushAsync(ct); + var idle = Task.Delay(TimeSpan.FromSeconds(20), ct); + // 久无变化就发心跳,客户端据此判断连接还活着 + if (await Task.WhenAny(changed, idle) == idle) { await ctx.Response.WriteAsync("event: ping\ndata: 1\n\n", ct); await ctx.Response.Body.FlushAsync(ct); } + } + } + catch (OperationCanceledException) { } + return Results.Empty; + }); api.MapGet("/accounts/channels", (HttpContext ctx, ChannelService svc) => { if (!TryAccount(ctx, accounts, out var username)) throw new WebDropException(ErrorCode.AccountRequired); @@ -54,8 +92,10 @@ public static partial class Endpoints { var (online, onlineDevices) = hub.Presence(channelId, member.Username); // 设备是账号的属性:总数取账号的登录设备,在线数取该账号在本频道有订阅的设备 + var devices = accounts.Devices(member.Username, null); return new MemberBody { Username = member.Username, Role = member.Role, JoinedAt = member.JoinedAt, - Online = online, Devices = Math.Max(accounts.Devices(member.Username, null).Count, onlineDevices), OnlineDevices = onlineDevices }; + Online = online, Devices = Math.Max(devices.Count, onlineDevices), OnlineDevices = onlineDevices, + LastSeen = devices.Count == 0 ? 0 : devices.Max(device => device.LastSeen) }; }).ToList(); return Json(members, ApiJson.Default.ListMemberBody); });