using AIK.Common.Enum; using AIK.Common.Interface; using AIK.Common.StompNet; using AIK.Models.ApiModels; using AIK.Models.ApiModels.Integral; using AIK.Models.SystemSettings; using AIK.Service.IService; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; namespace AIK.Service.Service; public class UserSessionCoordinator : IUserSessionCoordinator { private const string DefaultLogoPath = "AIK.Assets.Images.logo.png"; private const int RequestedHeartbeatInterval = 4000; // 服务器心跳超时倍数:由 1.5 放宽到 3,容纳 2 个心跳周期(8s)的抖动, // 避免 Win7 网络波动导致二维码在几秒内被误判过期 private const double HeartbeatTimeoutMultiplier = 3.0; // 二维码过期时间下限:即使协商的心跳间隔很短,也保证二维码至少 1 分钟内不因心跳超时过期 private static readonly TimeSpan MinQrCodeExpireTime = TimeSpan.FromMinutes(1); private readonly IConfigurationService _configurationService; private readonly IQRCodeService _qrCodeService; private readonly IDataCacheService _dataCacheService; private readonly IAikDataService _aikDataService; private readonly ILogger _logger; private readonly SemaphoreSlim _loginChannelLock = new(1, 1); private UserSessionState _state = new(); private BouncyCastleWebSocketClient? _webSocket; private CancellationTokenSource? _cts; private CancellationTokenSource? _heartbeatCts; private int _clientSendInterval = RequestedHeartbeatInterval; private int _serverSendInterval = RequestedHeartbeatInterval; private DateTime _lastServerHeartbeat = DateTime.UtcNow; private string? _sessionId; private string? _userToken; public event EventHandler? StateChanged; public UserSessionCoordinator( IConfigurationService configurationService, IQRCodeService qrCodeService, IDataCacheService dataCacheService, IAikDataService aikDataService, ILogger logger) { _configurationService = configurationService; _qrCodeService = qrCodeService; _dataCacheService = dataCacheService; _aikDataService = aikDataService; _logger = logger; } public async Task InitializeAsync() { // 容错:SQLite 缓存数据库异常(损坏/缺失/被占用)时,不能阻断扫码登录。 // 否则二维码发布流程(PublishWaitingForQrCodeAsync)不会执行,Win7 上表现为二维码不显示。 string? token = null; try { token = await _dataCacheService.GetLatestUserTokenAsync(); } catch (Exception ex) { _logger.LogWarning(ex, "读取本地登录缓存失败(SQLite),按未登录处理,继续展示二维码。"); } _userToken = token; if (string.IsNullOrWhiteSpace(_userToken)) { await PublishWaitingForQrCodeAsync(); return; } await RestoreSessionAsync(_userToken); } public virtual async Task RefreshIntegralAsync() { var token = _userToken ?? await _dataCacheService.GetLatestUserTokenAsync(); if (string.IsNullOrWhiteSpace(token) || !_state.IsLoggedIn) { return; } var integral = await _aikDataService.GetUserIntegral(token); PublishState(CloneState(integralInfo: integral)); } public virtual async Task LogoutAsync() { await _loginChannelLock.WaitAsync(); try { await StopLoginChannelAsync(); _userToken = null; await _dataCacheService.ClearUserInfoAsync(); await _dataCacheService.ClearUserTokenAsync(); await PublishWaitingForQrCodeAsync(); } finally { _loginChannelLock.Release(); } } /// /// 账密登录成功后写入会话:停止扫码通道、保存 Token、拉取用户信息与积分并发布登录状态。 /// 返回是否成功应用(用户信息拉取失败返回 false)。 /// public virtual async Task LoginWithTokenAsync(string token) { if (string.IsNullOrWhiteSpace(token)) { return false; } await _loginChannelLock.WaitAsync(); try { await StopLoginChannelAsync(); _userToken = token; await _dataCacheService.ClearUserTokenAsync(); await _dataCacheService.StoreUserTokenAsync(_userToken); var user = await _aikDataService.GetUserInfo(_userToken); if (user is null) { PublishState(CloneState( isLoggedIn: false, statusText: "登录失败,无法获取用户信息", userStatus: UserStatus.NotLoggedIn, userInfo: null, integralInfo: null, canShowAuthorizedMenus: false)); _userToken = null; return false; } await _dataCacheService.StoreUserInfoAsync(user); var integral = await _aikDataService.GetUserIntegral(_userToken); PublishState(CloneState( isLoggedIn: true, isQrCodeExpired: false, statusText: "登录成功!", userStatus: UserStatus.LoggedInOnline, userInfo: user, integralInfo: integral, canShowAuthorizedMenus: true)); return true; } catch (Exception ex) { _logger.LogWarning(ex, "账密登录写入会话失败"); return false; } finally { _loginChannelLock.Release(); } } public virtual async Task RefreshQrCodeAsync() { await _loginChannelLock.WaitAsync(); try { await StopLoginChannelAsync(); await PublishWaitingForQrCodeAsync(); } finally { _loginChannelLock.Release(); } } protected virtual async Task StartLoginChannelAsync(CancellationToken cancellationToken) { ResetHeartbeatState(); _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // 使用 BouncyCastle 纯托管 TLS 建立扫码登录通道: // Win7 SChannel 未启用 TLS 1.2 时,websocket-sharp(内部 new SslStream)握手必然失败(1015)。 // BouncyCastleWebSocketClient 用 BouncyCastleTlsHelper 完成 TLS 1.2 握手(含 SNI + 证书校验), // 自行实现 RFC 6455 帧编解码,完全绕过 SChannel。 var webSocket = new BouncyCastleWebSocketClient(_configurationService.ApiSettings.WSUrl); _webSocket = webSocket; webSocket.WaitTime = TimeSpan.FromSeconds(15); webSocket.OnMessage += OnWebSocketMessage; webSocket.OnError += (sender, e) => _logger.LogWarning("Login WebSocket error: {Message}", e.Message); webSocket.OnClose += (sender, e) => _logger.LogInformation("Login WebSocket closed: {Code} {Reason}", e.Code, e.Reason); try { await Task.Run(() => webSocket.Connect(), _cts.Token); } catch (Exception ex) { _logger.LogError(ex, "Login WebSocket connection failed: {WSUrl}", _configurationService.ApiSettings.WSUrl); try { _webSocket = null; webSocket.Close(CloseStatusCode.Abnormal, string.Empty); } catch { // 关闭失败无需处理,连接已不可用 } PublishState(CloneState( isQrCodeExpired: true, statusText: $"登录服务连接失败: {ex.Message}", userStatus: UserStatus.NotLoggedIn)); return; } var connectFrame = StompFrameHelper.BuildFrame("CONNECT", new Dictionary { ["UUID"] = _sessionId!, ["accept-version"] = "1.2,1.1,1.0", ["heart-beat"] = $"{RequestedHeartbeatInterval},{RequestedHeartbeatInterval}" }); webSocket.Send(connectFrame); } protected virtual async Task StopLoginChannelAsync() { try { _heartbeatCts?.Cancel(); _heartbeatCts?.Dispose(); _heartbeatCts = null; _cts?.Cancel(); _cts?.Dispose(); _cts = null; if (_webSocket is { ReadyState: WebSocketState.Open }) { _webSocket.Close(CloseStatusCode.Normal, string.Empty); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to stop login channel cleanly."); } finally { _webSocket = null; } } protected void PublishState(UserSessionState state) { _state = state; StateChanged?.Invoke(this, new UserSessionStateChangedEventArgs(state)); } protected byte[] GenerateQrCode(string text) => _qrCodeService.GenerateQRCodeWithLogo(text, DefaultLogoPath, 12); private UserSessionState CloneState( bool? isLoggedIn = null, bool? isQrCodeExpired = null, string? statusText = null, UserStatus? userStatus = null, UserModel? userInfo = null, IntegralModel? integralInfo = null, byte[]? qrCodeUserBytes = null, bool? canShowAuthorizedMenus = null) { return new UserSessionState { IsLoggedIn = isLoggedIn ?? _state.IsLoggedIn, IsQrCodeExpired = isQrCodeExpired ?? _state.IsQrCodeExpired, StatusText = statusText ?? _state.StatusText, UserStatus = userStatus ?? _state.UserStatus, UserInfo = userInfo ?? _state.UserInfo, IntegralInfo = integralInfo ?? _state.IntegralInfo, QrCodeUserBytes = qrCodeUserBytes ?? _state.QrCodeUserBytes, CanShowAuthorizedMenus = canShowAuthorizedMenus ?? _state.CanShowAuthorizedMenus }; } private async Task PublishWaitingForQrCodeAsync() { _sessionId = Guid.NewGuid().ToString("N"); var qrBytes = GenerateQrCode($"login:{_sessionId}"); PublishState(new UserSessionState { IsLoggedIn = false, IsQrCodeExpired = false, StatusText = "等待扫码登录...", UserStatus = UserStatus.NotLoggedIn, QrCodeUserBytes = qrBytes, CanShowAuthorizedMenus = false, IntegralInfo = null, UserInfo = null }); await StartLoginChannelAsync(CancellationToken.None); } private async Task RestoreSessionAsync(string token) { _userToken = token; var user = await _aikDataService.GetUserInfo(token); if (user is not null) { var integral = await _aikDataService.GetUserIntegral(token); await _dataCacheService.ClearUserInfoAsync(); await _dataCacheService.StoreUserInfoAsync(user); PublishState(new UserSessionState { IsLoggedIn = true, StatusText = "已登录", UserStatus = UserStatus.LoggedInOnline, UserInfo = user, IntegralInfo = integral, CanShowAuthorizedMenus = true }); return; } var cachedUser = await _dataCacheService.GetUserInfoAsync(); if (cachedUser is not null) { PublishState(new UserSessionState { IsLoggedIn = true, StatusText = "已登录(离线模式)", UserStatus = UserStatus.LoggedInOffline, UserInfo = cachedUser, IntegralInfo = null, CanShowAuthorizedMenus = false }); return; } _userToken = null; await PublishWaitingForQrCodeAsync(); } private static TimeSpan? GetServerHeartbeatTimeout(int serverSendInterval) { if (serverSendInterval <= 0) { return null; } // 实际超时取「协商间隔 × 倍数」与 30 秒下限中的较大值 var computed = TimeSpan.FromMilliseconds(serverSendInterval * HeartbeatTimeoutMultiplier); return computed > MinQrCodeExpireTime ? computed : MinQrCodeExpireTime; } private static int NegotiateHeartbeatInterval(int localInterval, int remoteInterval) { return localInterval > 0 && remoteInterval > 0 ? Math.Max(localInterval, remoteInterval) : 0; } private void ResetHeartbeatState() { _clientSendInterval = RequestedHeartbeatInterval; _serverSendInterval = RequestedHeartbeatInterval; _lastServerHeartbeat = DateTime.UtcNow; } private void StartHeartbeat(BouncyCastleWebSocketClient webSocket) { _heartbeatCts?.Cancel(); _heartbeatCts?.Dispose(); _heartbeatCts = new CancellationTokenSource(); var token = _heartbeatCts.Token; var clientSendInterval = _clientSendInterval; var serverHeartbeatTimeout = GetServerHeartbeatTimeout(_serverSendInterval); if (clientSendInterval > 0) { _ = Task.Run(async () => { while (!token.IsCancellationRequested) { try { await Task.Delay(clientSendInterval, token); if (webSocket.ReadyState == WebSocketState.Open) { // STOMP 心跳:发送单个 0x0A 帧 webSocket.Send(new byte[] { 0x0A }); } } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogWarning(ex, "Failed to send STOMP heartbeat."); } } }, token); } if (serverHeartbeatTimeout is null) { return; } var timeout = serverHeartbeatTimeout.Value; _ = Task.Run(async () => { while (!token.IsCancellationRequested && !_state.IsLoggedIn && !_state.IsQrCodeExpired) { try { await Task.Delay(1500, token); if (DateTime.UtcNow - _lastServerHeartbeat <= timeout) { continue; } if (webSocket.ReadyState == WebSocketState.Open) { webSocket.Close(CloseStatusCode.ServerError, "Heartbeat timeout"); } if (!token.IsCancellationRequested && !_state.IsLoggedIn) { PublishState(CloneState(isQrCodeExpired: true)); } break; } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogWarning(ex, "Heartbeat monitor failed."); break; } } }, token); } /// /// websocket-sharp 事件模型:收到任何帧都会触发(在接收线程执行)。 /// 0x0A 单字节帧 = STOMP 服务器心跳;其余按文本帧解析 STOMP 协议。 /// private void OnWebSocketMessage(object? sender, MessageEventArgs e) { if (e.IsBinary && e.RawData.Length == 1 && e.RawData[0] == 0x0A) { _lastServerHeartbeat = DateTime.UtcNow; return; } if (!e.IsText || string.IsNullOrEmpty(e.Data)) { return; } var frame = StompFrameHelper.ParseFrame(Encoding.UTF8.GetBytes(e.Data)); if (frame.HasValue) { _ = HandleStompFrameAsync(frame.Value, CancellationToken.None); } } private async Task HandleStompFrameAsync((string command, Dictionary headers, string body) frame, CancellationToken ct) { var (command, headers, body) = frame; switch (command.ToUpperInvariant()) { case "CONNECTED": _lastServerHeartbeat = DateTime.UtcNow; if (headers.TryGetValue("heart-beat", out var heartBeat)) { var parts = heartBeat.Split(','); if (parts.Length == 2 && int.TryParse(parts[0], out var serverSendInterval) && int.TryParse(parts[1], out var serverReceiveInterval)) { _serverSendInterval = NegotiateHeartbeatInterval(RequestedHeartbeatInterval, serverSendInterval); _clientSendInterval = NegotiateHeartbeatInterval(RequestedHeartbeatInterval, serverReceiveInterval); StartHeartbeat(_webSocket!); } } var subscribeFrame = StompFrameHelper.BuildFrame("SUBSCRIBE", new Dictionary { ["id"] = "sub-0", ["destination"] = "/user/topic/session" }); _webSocket?.Send(subscribeFrame); break; case "MESSAGE": try { using var doc = JsonDocument.Parse(body); _userToken = doc.RootElement.GetProperty("msg").GetString(); if (string.IsNullOrWhiteSpace(_userToken)) { return; } await _dataCacheService.ClearUserTokenAsync(); await _dataCacheService.StoreUserTokenAsync(_userToken); var user = await _aikDataService.GetUserInfo(_userToken); if (user is null) { PublishState(CloneState( isLoggedIn: false, statusText: "登录失败,无法获取用户信息", userStatus: UserStatus.NotLoggedIn, userInfo: null, integralInfo: null, canShowAuthorizedMenus: false)); _userToken = null; return; } await _dataCacheService.StoreUserInfoAsync(user); var integral = await _aikDataService.GetUserIntegral(_userToken); PublishState(CloneState( isLoggedIn: true, isQrCodeExpired: false, statusText: "登录成功!", userStatus: UserStatus.LoggedInOnline, userInfo: user, integralInfo: integral, canShowAuthorizedMenus: true)); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to process login MESSAGE frame."); } break; case "ERROR": PublishState(CloneState(statusText: $"STOMP 错误: {body}")); break; } } }