using System.Net.WebSockets; using System.Text; using WebDrop.Core; namespace WebDrop.Api; /// 订阅的下行通道。WebSocket 与 SSE 各自实现,订阅循环共用。 public interface ILogSink { ValueTask SendRecordAsync(LogRecord record, CancellationToken ct); ValueTask SendControlAsync(string json, CancellationToken ct); /// 流已建立之后出现的业务错误(如席位被释放),通知客户端并结束。 ValueTask FailAsync(ErrorCode code, CancellationToken ct); } public static class LogStreamer { private const int PageSize = 200; public static readonly TimeSpan Heartbeat = TimeSpan.FromSeconds(20); /// /// 按游标持续读取日志并推送。 在首次鉴权和读取成功之后才被调用, /// 因此鉴权失败仍可以返回普通的 HTTP 错误,而不是先升级再断开。 /// public static async Task RunAsync( ChannelService svc, ChannelHub hub, Id16 id, Credentials creds, long from, Func> open, CancellationToken ct, Func? validateAccount = null) { using var sub = hub.Subscribe(id); ILogSink? sink = null; long cursor = from; var (settingsV, seatsV, _) = sub.State; try { while (!ct.IsCancellationRequested) { if (validateAccount is not null && !validateAccount()) throw new WebDropException(ErrorCode.AccountRequired); var changed = sub.Changed; // 先取任务再读状态:读取期间的发布不会丢 var page = await svc.ReadLogAsync(id, creds, cursor, PageSize, ct).ConfigureAwait(false); sink ??= await open().ConfigureAwait(false); foreach (var record in page.Records) { await sink.SendRecordAsync(record, ct).ConfigureAwait(false); cursor = record.Seq + 1; } var (settingsNow, seatsNow, deleted) = sub.State; if (settingsNow != settingsV) { settingsV = settingsNow; await sink.SendControlAsync("""{"t":"settings"}""", ct).ConfigureAwait(false); } if (seatsNow != seatsV) { seatsV = seatsNow; await sink.SendControlAsync("""{"t":"seats"}""", ct).ConfigureAwait(false); } if (deleted) throw new WebDropException(ErrorCode.NotFound); if (page.Records.Count >= PageSize) continue; // 还有积压,不用等 using var delayCts = CancellationTokenSource.CreateLinkedTokenSource(ct); var delay = Task.Delay(Heartbeat, delayCts.Token); var winner = await Task.WhenAny(changed, delay).ConfigureAwait(false); await delayCts.CancelAsync().ConfigureAwait(false); ct.ThrowIfCancellationRequested(); if (winner == delay) await sink.SendControlAsync($$"""{"t":"hb","seq":{{page.LastSeq}}}""", ct).ConfigureAwait(false); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { // 客户端断开,正常结束 } catch (WebDropException e) when (sink is not null) { await sink.FailAsync(e.Code, CancellationToken.None).ConfigureAwait(false); } } } public sealed class WebSocketSink(WebSocket ws) : ILogSink { public ValueTask SendRecordAsync(LogRecord record, CancellationToken ct) => ws.SendAsync(record.Bytes.AsMemory(), WebSocketMessageType.Binary, true, ct); public ValueTask SendControlAsync(string json, CancellationToken ct) => ws.SendAsync(Encoding.UTF8.GetBytes(json).AsMemory(), WebSocketMessageType.Text, true, ct); public async ValueTask FailAsync(ErrorCode code, CancellationToken ct) { if (ws.State is WebSocketState.Open or WebSocketState.CloseReceived) await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation, code.ToWire(), ct).ConfigureAwait(false); } } public sealed class SseSink(HttpResponse response) : ILogSink { public async ValueTask SendRecordAsync(LogRecord record, CancellationToken ct) { await response.WriteAsync($"id: {record.Seq}\nevent: log\ndata: {Codec.Encode(record.Bytes)}\n\n", ct).ConfigureAwait(false); await response.Body.FlushAsync(ct).ConfigureAwait(false); } public async ValueTask SendControlAsync(string json, CancellationToken ct) { await response.WriteAsync($"event: control\ndata: {json}\n\n", ct).ConfigureAwait(false); await response.Body.FlushAsync(ct).ConfigureAwait(false); } public async ValueTask FailAsync(ErrorCode code, CancellationToken ct) { await response.WriteAsync($"event: error\ndata: {{\"error\":\"{code.ToWire()}\"}}\n\n", ct).ConfigureAwait(false); await response.Body.FlushAsync(ct).ConfigureAwait(false); } }