You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
558 lines
20 KiB
558 lines
20 KiB
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<UserSessionCoordinator> _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<UserSessionStateChangedEventArgs>? StateChanged;
|
|
|
|
public UserSessionCoordinator(
|
|
IConfigurationService configurationService,
|
|
IQRCodeService qrCodeService,
|
|
IDataCacheService dataCacheService,
|
|
IAikDataService aikDataService,
|
|
ILogger<UserSessionCoordinator> 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 账密登录成功后写入会话:停止扫码通道、保存 Token、拉取用户信息与积分并发布登录状态。
|
|
/// 返回是否成功应用(用户信息拉取失败返回 false)。
|
|
/// </summary>
|
|
public virtual async Task<bool> 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<string, string>
|
|
{
|
|
["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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// websocket-sharp 事件模型:收到任何帧都会触发(在接收线程执行)。
|
|
/// 0x0A 单字节帧 = STOMP 服务器心跳;其余按文本帧解析 STOMP 协议。
|
|
/// </summary>
|
|
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<string, string> 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<string, string>
|
|
{
|
|
["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;
|
|
}
|
|
}
|
|
}
|
|
|