using Microsoft.Extensions.Configuration; using Prism.Events; using System.IO; using System.Net.WebSockets; using System.Text; using YY.Admin.Core.Events; using YY.Admin.Core.Services; using YY.Admin.Infrastructure.Storage; namespace YY.Admin.Infrastructure.Hubs; public class StompWebSocketService : ISignalRService { private readonly IConfiguration _configuration; private readonly IEventAggregator _eventAggregator; private readonly TokenStore _tokenStore; private ClientWebSocket? _socket; private string _deviceId = "default-device"; private string _token = string.Empty; public StompWebSocketService( IConfiguration configuration, IEventAggregator eventAggregator, TokenStore tokenStore) { _configuration = configuration; _eventAggregator = eventAggregator; _tokenStore = tokenStore; } /// public async Task ConnectAsync(string token, CancellationToken cancellationToken = default) { _token = token ?? string.Empty; await ConnectUnifiedDeviceChannelAsync(cancellationToken).ConfigureAwait(false); } /// public async Task ConnectUnifiedDeviceChannelAsync(CancellationToken cancellationToken = default) { var anonymous = _configuration.GetValue("JeecgIntegration:AnonymousMode", true); if (anonymous) { _token = string.Empty; } else if (string.IsNullOrWhiteSpace(_token)) { _token = await _tokenStore.GetTokenAsync(cancellationToken).ConfigureAwait(false) ?? string.Empty; } _deviceId = ResolveDeviceId(_token); var wsUrl = ResolveWsUrl(); var retryDelays = new[] { 0, 2, 5, 10, 30 }; foreach (var delay in retryDelays) { if (delay > 0) { await Task.Delay(TimeSpan.FromSeconds(delay), cancellationToken).ConfigureAwait(false); } try { _socket?.Dispose(); _socket = new ClientWebSocket(); _socket.Options.AddSubProtocol("v12.stomp"); await _socket.ConnectAsync(new Uri(wsUrl), cancellationToken).ConfigureAwait(false); var connectFrame = anonymous || string.IsNullOrWhiteSpace(_token) ? "CONNECT\naccept-version:1.2\nheart-beat:10000,10000\n\n\0" : BuildConnectFrame(_token); await SendFrameAsync(connectFrame, cancellationToken).ConfigureAwait(false); // 用户镜像变更:与后端 /topic/sync/jeecg-users 对齐(设备同步统一线路) await SendFrameAsync(BuildSubscribeFrame("sub-jeecg-users", "/topic/sync/jeecg-users"), cancellationToken).ConfigureAwait(false); // 非免密时同时订阅设备点对点指令队列 if (!anonymous && !string.IsNullOrWhiteSpace(_token)) { await SendFrameAsync(BuildSubscribeFrame("sub-device-command", $"/user/{_deviceId}/queue/command"), cancellationToken).ConfigureAwait(false); } _eventAggregator.GetEvent().Publish(new NetworkStatusChangedPayload { IsOnline = true, ChangedAt = DateTime.UtcNow }); _ = Task.Run(() => ReceiveLoopAsync(cancellationToken), cancellationToken); return; } catch { _eventAggregator.GetEvent().Publish(new NetworkStatusChangedPayload { IsOnline = false, ChangedAt = DateTime.UtcNow }); } } } /// public async Task SendDeviceStatusAsync(object status, CancellationToken cancellationToken = default) { if (_socket == null || _socket.State != WebSocketState.Open) { return; } var json = System.Text.Json.JsonSerializer.Serialize(status); var frame = $"SEND\n" + $"destination:/app/device/status\n" + $"content-type:application/json\n" + $"content-length:{Encoding.UTF8.GetByteCount(json)}\n\n" + $"{json}\0"; await SendFrameAsync(frame, cancellationToken).ConfigureAwait(false); } private async Task ReceiveLoopAsync(CancellationToken cancellationToken) { if (_socket == null) { return; } var buffer = new byte[8192]; while (_socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) { using var ms = new MemoryStream(); WebSocketReceiveResult result; do { result = await _socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken).ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) { _eventAggregator.GetEvent().Publish(new NetworkStatusChangedPayload { IsOnline = false, ChangedAt = DateTime.UtcNow }); await ConnectUnifiedDeviceChannelAsync(cancellationToken).ConfigureAwait(false); return; } ms.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); var text = Encoding.UTF8.GetString(ms.ToArray()); if (!text.StartsWith("MESSAGE", StringComparison.OrdinalIgnoreCase)) { continue; } var idx = text.IndexOf("\n\n", StringComparison.Ordinal); if (idx < 0) { continue; } var body = text[(idx + 2)..].TrimEnd('\0'); _eventAggregator.GetEvent().Publish(new RemoteCommandPayload { DeviceId = _deviceId, CommandJson = body }); } } private async Task SendFrameAsync(string frame, CancellationToken cancellationToken) { if (_socket == null || _socket.State != WebSocketState.Open) { return; } var data = Encoding.UTF8.GetBytes(frame); await _socket.SendAsync(new ArraySegment(data), WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false); } private string ResolveWsUrl() { var baseUrl = _configuration.GetValue("JeecgIntegration:BaseUrl")?.TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { return "ws://127.0.0.1:8080/jeecg-boot/ws/device/websocket"; } if (baseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return "wss://" + baseUrl["https://".Length..] + "/ws/device/websocket"; } return "ws://" + baseUrl["http://".Length..] + "/ws/device/websocket"; } private static string ResolveDeviceId(string token) { try { var parts = token.Split('.'); if (parts.Length < 2) { return "default-device"; } var payload = parts[1].Replace('-', '+').Replace('_', '/'); payload = payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='); var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); using var doc = System.Text.Json.JsonDocument.Parse(json); if (doc.RootElement.TryGetProperty("deviceId", out var deviceId)) { return deviceId.GetString() ?? "default-device"; } if (doc.RootElement.TryGetProperty("username", out var username)) { return username.GetString() ?? "default-device"; } return "default-device"; } catch { return "default-device"; } } private static string BuildConnectFrame(string token) { return "CONNECT\n" + "accept-version:1.2\n" + "heart-beat:10000,10000\n" + $"Authorization:Bearer {token}\n\n\0"; } private static string BuildSubscribeFrame(string subscriptionId, string destination) { return "SUBSCRIBE\n" + $"id:{subscriptionId}\n" + $"destination:{destination}\n" + "ack:auto\n\n\0"; } }