兼容win7版应用
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

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;
}
}
}