更新项目配置,新增设备同步模块,优化WebSocket和Swagger配置,增强SCADA系统的免登录接口,支持数据字典项和登录日志的免登录查询与记录。调整Java编译设置,确保更好的开发体验。
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
using System.Net.Http;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// Jeecg 后端访问网关:
|
||||
/// 1. 统一封装 HTTP 调用;
|
||||
/// 2. 统一封装 WebSocket 双向连接;
|
||||
/// 3. 作为后续 Jeecg 集成功能的统一入口。
|
||||
/// </summary>
|
||||
public interface IJeecgBackendGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// 统一执行 Jeecg GET 请求(自动拼接 BaseUrl)。
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> ExecuteGetAsync(
|
||||
string relativeOrAbsoluteUrl,
|
||||
Dictionary<string, string>? headers = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 统一执行 Jeecg GET 请求并返回文本。
|
||||
/// </summary>
|
||||
Task<string?> ExecuteGetStringAsync(
|
||||
string relativeOrAbsoluteUrl,
|
||||
Dictionary<string, string>? headers = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 启动 Jeecg WebSocket 双向连接循环(自动重连)。
|
||||
/// </summary>
|
||||
Task RunWebSocketLoopAsync(
|
||||
Func<string, Task> onMessage,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 发送一条 WebSocket 消息(连接可用时)。
|
||||
/// </summary>
|
||||
Task<bool> SendWebSocketMessageAsync(string message, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 单次 WebSocket 上报(临时连接,适用于登录页等未常驻连接场景)。
|
||||
/// </summary>
|
||||
Task<bool> SendWebSocketOneShotAsync(string message, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// Jeecg 登录日志上报服务(WebSocket + HTTP + 本地离线队列)。
|
||||
/// </summary>
|
||||
public interface IJeecgLoginLogReportService
|
||||
{
|
||||
/// <summary>
|
||||
/// 启动后台离线日志补传循环(可重复调用,内部幂等)。
|
||||
/// </summary>
|
||||
void StartBackgroundSync();
|
||||
|
||||
/// <summary>
|
||||
/// 上报一次登录日志;网络不可达时自动落本地,联网后自动补传。
|
||||
/// </summary>
|
||||
Task ReportLoginAsync(string account, bool success, string message, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 上报通用日志(操作/异常/告警等),自动走 WS/HTTP/本地队列兜底。
|
||||
/// </summary>
|
||||
Task ReportLogAsync(
|
||||
string category,
|
||||
string message,
|
||||
string? account = null,
|
||||
bool? success = null,
|
||||
string? exception = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace YY.Admin.Services.Service.Jeecg
|
||||
{
|
||||
/// <summary>
|
||||
/// Jeecg 用户镜像后台同步协调器:统一设备通道(STOMP)信号 + Outbox 拉取,不再使用独立 Jeecg WebSocket 收包循环。
|
||||
/// </summary>
|
||||
public interface IJeecgUserSyncCoordinator
|
||||
{
|
||||
/// <summary>
|
||||
/// 启动后台同步(主窗口登录成功后调用)
|
||||
/// </summary>
|
||||
void Start();
|
||||
|
||||
/// <summary>
|
||||
/// 停止后台同步与 STOMP 信号订阅(登出或关闭主窗口时调用)
|
||||
/// </summary>
|
||||
void Stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net.Http;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.IO;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// Jeecg 后端网关实现。
|
||||
/// </summary>
|
||||
public class JeecgBackendGateway : IJeecgBackendGateway, ISingletonDependency
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILoggerService _logger;
|
||||
private readonly SemaphoreSlim _wsSendLock = new(1, 1);
|
||||
private readonly object _wsStateLock = new();
|
||||
private ClientWebSocket? _activeWebSocket;
|
||||
|
||||
public JeecgBackendGateway(
|
||||
IConfiguration configuration,
|
||||
HttpClient httpClient,
|
||||
ILoggerService logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> ExecuteGetAsync(
|
||||
string relativeOrAbsoluteUrl,
|
||||
Dictionary<string, string>? headers = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestUrl = BuildUrl(relativeOrAbsoluteUrl);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, requestUrl);
|
||||
if (headers != null)
|
||||
{
|
||||
foreach (var kv in headers)
|
||||
{
|
||||
req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return await _httpClient.SendAsync(req, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<string?> ExecuteGetStringAsync(
|
||||
string relativeOrAbsoluteUrl,
|
||||
Dictionary<string, string>? headers = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var resp = await ExecuteGetAsync(relativeOrAbsoluteUrl, headers, cancellationToken);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return await resp.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RunWebSocketLoopAsync(
|
||||
Func<string, Task> onMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var wsUrl = ResolveWebSocketUrl();
|
||||
_logger.Information($"Jeecg WebSocket 解析地址: {wsUrl}");
|
||||
if (string.IsNullOrWhiteSpace(wsUrl))
|
||||
{
|
||||
_logger.Warning("Jeecg WebSocket 未启动:解析地址为空");
|
||||
return;
|
||||
}
|
||||
|
||||
var backoffSeconds = 5;
|
||||
var buffer = new byte[8192];
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
using var ws = new ClientWebSocket();
|
||||
try
|
||||
{
|
||||
ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(10);
|
||||
await ws.ConnectAsync(new Uri(wsUrl), cancellationToken);
|
||||
lock (_wsStateLock)
|
||||
{
|
||||
_activeWebSocket = ws;
|
||||
}
|
||||
backoffSeconds = 5;
|
||||
_logger.Information($"Jeecg WebSocket 已连接: {wsUrl}");
|
||||
using var heartbeatCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var lastReceiveTicks = DateTime.UtcNow.Ticks;
|
||||
_ = Task.Run(() => HeartbeatLoopAsync(ws, heartbeatCts.Token), heartbeatCts.Token);
|
||||
var inactivitySeconds = _configuration.GetValue("JeecgIntegration:WebSocketInactivityReconnectSeconds", 0);
|
||||
CancellationTokenSource? inactivityCts = null;
|
||||
if (inactivitySeconds > 0)
|
||||
{
|
||||
inactivityCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_ = Task.Run(() => InactivityReconnectLoopAsync(ws, () => Interlocked.Read(ref lastReceiveTicks), inactivityCts.Token), inactivityCts.Token);
|
||||
}
|
||||
|
||||
while (ws.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
var seg = new ArraySegment<byte>(buffer);
|
||||
result = await ws.ReceiveAsync(seg, cancellationToken);
|
||||
_logger.Information($"Jeecg WebSocket 收帧: type={result.MessageType}, count={result.Count}, end={result.EndOfMessage}, state={ws.State}");
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_logger.Warning($"Jeecg WebSocket 收到关闭帧: closeStatus={result.CloseStatus}, desc={result.CloseStatusDescription}");
|
||||
break;
|
||||
}
|
||||
ms.Write(buffer, 0, result.Count);
|
||||
} while (!result.EndOfMessage);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_logger.Warning("Jeecg WebSocket 接收循环检测到关闭帧,准备重连");
|
||||
break;
|
||||
}
|
||||
|
||||
var payload = Encoding.UTF8.GetString(ms.ToArray());
|
||||
Interlocked.Exchange(ref lastReceiveTicks, DateTime.UtcNow.Ticks);
|
||||
_logger.Information($"Jeecg WebSocket 收到原始消息: {payload}");
|
||||
await onMessage(payload);
|
||||
}
|
||||
heartbeatCts.Cancel();
|
||||
inactivityCts?.Cancel();
|
||||
_logger.Warning($"Jeecg WebSocket 接收循环退出,当前状态={ws.State}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Warning("Jeecg WebSocket 接收循环取消");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Jeecg WebSocket 断开,{backoffSeconds} 秒后重连。地址: {wsUrl},异常: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_wsStateLock)
|
||||
{
|
||||
if (ReferenceEquals(_activeWebSocket, ws))
|
||||
{
|
||||
_activeWebSocket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(Math.Min(backoffSeconds, 120)), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
backoffSeconds = Math.Min(backoffSeconds * 2, 120);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HeartbeatLoopAsync(ClientWebSocket ws, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes("ping");
|
||||
while (!cancellationToken.IsCancellationRequested && ws.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(12), cancellationToken);
|
||||
if (ws.State != WebSocketState.Open)
|
||||
{
|
||||
break;
|
||||
}
|
||||
await ws.SendAsync(new ArraySegment<byte>(payload), WebSocketMessageType.Text, true, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Jeecg WebSocket 心跳发送失败: {ex.Message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InactivityReconnectLoopAsync(ClientWebSocket ws, Func<long> getLastReceiveTicks, CancellationToken cancellationToken)
|
||||
{
|
||||
var inactivitySeconds = _configuration.GetValue("JeecgIntegration:WebSocketInactivityReconnectSeconds", 0);
|
||||
if (inactivitySeconds <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
while (!cancellationToken.IsCancellationRequested && ws.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
|
||||
if (ws.State != WebSocketState.Open)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var lastReceiveUtc = new DateTime(getLastReceiveTicks(), DateTimeKind.Utc);
|
||||
var idleSeconds = (DateTime.UtcNow - lastReceiveUtc).TotalSeconds;
|
||||
if (idleSeconds < inactivitySeconds)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Warning($"Jeecg WebSocket 超过 {Math.Round(idleSeconds)} 秒未收到任何消息,主动重连");
|
||||
ws.Abort();
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Jeecg WebSocket 空闲检测异常: {ex.Message}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SendWebSocketMessageAsync(string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ClientWebSocket? socket;
|
||||
lock (_wsStateLock)
|
||||
{
|
||||
socket = _activeWebSocket;
|
||||
}
|
||||
if (socket == null || socket.State != WebSocketState.Open)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(message);
|
||||
var seg = new ArraySegment<byte>(bytes);
|
||||
await _wsSendLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await socket.SendAsync(seg, WebSocketMessageType.Text, true, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_wsSendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SendWebSocketOneShotAsync(string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var wsUrl = ResolveWebSocketUrl();
|
||||
if (string.IsNullOrWhiteSpace(wsUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var ws = new ClientWebSocket();
|
||||
try
|
||||
{
|
||||
await ws.ConnectAsync(new Uri(wsUrl), cancellationToken);
|
||||
var bytes = Encoding.UTF8.GetBytes(message);
|
||||
var seg = new ArraySegment<byte>(bytes);
|
||||
await ws.SendAsync(seg, WebSocketMessageType.Text, true, cancellationToken);
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "done", cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Jeecg WebSocket 单次上报失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildUrl(string relativeOrAbsoluteUrl)
|
||||
{
|
||||
if (Uri.TryCreate(relativeOrAbsoluteUrl, UriKind.Absolute, out _))
|
||||
{
|
||||
return relativeOrAbsoluteUrl;
|
||||
}
|
||||
|
||||
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return relativeOrAbsoluteUrl;
|
||||
}
|
||||
var path = relativeOrAbsoluteUrl.StartsWith("/") ? relativeOrAbsoluteUrl : "/" + relativeOrAbsoluteUrl;
|
||||
return $"{baseUrl}{path}";
|
||||
}
|
||||
|
||||
private string ResolveWebSocketUrl()
|
||||
{
|
||||
var anonymousMode = _configuration.GetValue("JeecgIntegration:AnonymousMode", true);
|
||||
var configUrl = _configuration.GetValue<string>("JeecgIntegration:WebSocketUrl");
|
||||
if (!string.IsNullOrWhiteSpace(configUrl))
|
||||
{
|
||||
return NormalizeWebSocketUrl(configUrl.Trim(), anonymousMode);
|
||||
}
|
||||
|
||||
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var wsPath = _configuration.GetValue<string>("JeecgIntegration:WebSocketPath");
|
||||
if (string.IsNullOrWhiteSpace(wsPath))
|
||||
{
|
||||
wsPath = "/websocket/scada-sync";
|
||||
}
|
||||
else if (!wsPath.StartsWith("/"))
|
||||
{
|
||||
wsPath = "/" + wsPath;
|
||||
}
|
||||
|
||||
// 默认从 BaseUrl + WebSocketPath 推导地址,避免只连到根路径导致握手失败
|
||||
if (baseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NormalizeWebSocketUrl("wss://" + baseUrl["https://".Length..] + wsPath, anonymousMode);
|
||||
}
|
||||
if (baseUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return NormalizeWebSocketUrl("ws://" + baseUrl["http://".Length..] + wsPath, anonymousMode);
|
||||
}
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizeWebSocketUrl(string wsUrl, bool anonymousMode)
|
||||
{
|
||||
if (!anonymousMode)
|
||||
{
|
||||
return wsUrl;
|
||||
}
|
||||
if (wsUrl.Contains("/ws/device/websocket", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return wsUrl.Replace("/ws/device/websocket", "/websocket/scada-sync", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return wsUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Net.Http;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// 登录日志上报:
|
||||
/// 1) WebSocket 优先;
|
||||
/// 2) 失败自动 HTTP 兜底;
|
||||
/// 3) 仍失败则写本地队列,后台自动补传。
|
||||
/// </summary>
|
||||
public class JeecgLoginLogReportService : IJeecgLoginLogReportService, IClientLogReportSink, ISingletonDependency
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IJeecgBackendGateway _jeecgBackendGateway;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SemaphoreSlim _queueLock = new(1, 1);
|
||||
private readonly CancellationTokenSource _syncCts = new();
|
||||
private int _started;
|
||||
|
||||
private sealed class LoginLogQueueItem
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Category { get; set; } = "LOGIN";
|
||||
public string Account { get; set; } = string.Empty;
|
||||
public bool? Success { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string? Exception { get; set; }
|
||||
public int LogType { get; set; } = 1;
|
||||
public int OperateType { get; set; } = 5;
|
||||
public string Method { get; set; } = "LOGIN";
|
||||
public string RequestUrl { get; set; } = "/desktop/log";
|
||||
public long Timestamp { get; set; } = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
||||
public string ClientType { get; set; } = "gkj";
|
||||
}
|
||||
|
||||
public JeecgLoginLogReportService(
|
||||
IConfiguration configuration,
|
||||
IJeecgBackendGateway jeecgBackendGateway,
|
||||
HttpClient httpClient)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_jeecgBackendGateway = jeecgBackendGateway;
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
public void StartBackgroundSync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _started, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(() => BackgroundSyncLoopAsync(_syncCts.Token), _syncCts.Token);
|
||||
}
|
||||
|
||||
public async Task ReportLoginAsync(string account, bool success, string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await ReportLogAsync("LOGIN", message, account, success, null, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReportLogAsync(
|
||||
string category,
|
||||
string message,
|
||||
string? account = null,
|
||||
bool? success = null,
|
||||
string? exception = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedCategory = string.IsNullOrWhiteSpace(category) ? "OPERATION" : category.Trim().ToUpperInvariant();
|
||||
var item = BuildQueueItem(normalizedCategory, account, success, message, exception);
|
||||
if (await TrySendToBackendAsync(item, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await EnqueueAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task BackgroundSyncLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await FlushQueueAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"日志离线补传失败: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> TrySendToBackendAsync(LoginLogQueueItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
cmd = "SCADA_LOG",
|
||||
category = item.Category,
|
||||
account = item.Account,
|
||||
success = item.Success,
|
||||
message = item.Message,
|
||||
exception = item.Exception,
|
||||
logType = item.LogType,
|
||||
operateType = item.OperateType,
|
||||
method = item.Method,
|
||||
requestUrl = item.RequestUrl,
|
||||
clientType = item.ClientType,
|
||||
timestamp = item.Timestamp
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
|
||||
if (await _jeecgBackendGateway.SendWebSocketOneShotAsync(json, cancellationToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl")?.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var url = $"{baseUrl}/sys/log/scada/addLoginLog";
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var resp = await _httpClient.SendAsync(req, cancellationToken);
|
||||
return resp.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnqueueAsync(LoginLogQueueItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
await _queueLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var path = GetQueuePath();
|
||||
if (File.Exists(path))
|
||||
{
|
||||
lines = File.ReadAllLines(path).Where(l => !string.IsNullOrWhiteSpace(l)).ToList();
|
||||
}
|
||||
lines.Add(JsonSerializer.Serialize(item));
|
||||
EnsureQueueDir(path);
|
||||
File.WriteAllLines(path, lines);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_queueLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FlushQueueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _queueLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var path = GetQueuePath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = File.ReadAllLines(path).Where(l => !string.IsNullOrWhiteSpace(l)).ToList();
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var remain = new List<string>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
LoginLogQueueItem? item = null;
|
||||
try
|
||||
{
|
||||
item = JsonSerializer.Deserialize<LoginLogQueueItem>(line);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 解析失败的数据直接丢弃,避免阻塞后续
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item == null || !await TrySendToBackendAsync(item, cancellationToken))
|
||||
{
|
||||
remain.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (remain.Count == 0)
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.WriteAllLines(path, remain);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_queueLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetQueuePath()
|
||||
{
|
||||
return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AppSettings", "offline-scada-log-queue.jsonl");
|
||||
}
|
||||
|
||||
private static void EnsureQueueDir(string path)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
}
|
||||
|
||||
private static LoginLogQueueItem BuildQueueItem(
|
||||
string category,
|
||||
string? account,
|
||||
bool? success,
|
||||
string? message,
|
||||
string? exception)
|
||||
{
|
||||
var item = new LoginLogQueueItem
|
||||
{
|
||||
Category = category,
|
||||
Account = account ?? string.Empty,
|
||||
Success = success,
|
||||
Message = message ?? string.Empty,
|
||||
Exception = exception
|
||||
};
|
||||
|
||||
switch (category)
|
||||
{
|
||||
case "LOGIN":
|
||||
item.LogType = 1;
|
||||
item.OperateType = 1;
|
||||
item.Method = "LOGIN";
|
||||
item.RequestUrl = "/desktop/login";
|
||||
break;
|
||||
case "EXCEPTION":
|
||||
item.LogType = 2;
|
||||
item.OperateType = 5;
|
||||
item.Method = "ERROR";
|
||||
item.RequestUrl = "/desktop/exception";
|
||||
break;
|
||||
case "WARNING":
|
||||
item.LogType = 2;
|
||||
item.OperateType = 4;
|
||||
item.Method = "WARNING";
|
||||
item.RequestUrl = "/desktop/warning";
|
||||
break;
|
||||
default:
|
||||
item.LogType = 1;
|
||||
item.OperateType = 5;
|
||||
item.Method = "OPERATION";
|
||||
item.RequestUrl = "/desktop/operation";
|
||||
break;
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg
|
||||
{
|
||||
/// <summary>
|
||||
/// Jeecg 同步本地状态(工控机断网续传水位)
|
||||
/// </summary>
|
||||
public class JeecgSyncState
|
||||
{
|
||||
/// <summary>
|
||||
/// 同步水位(UTC):标准列表作 updateTime_begin;SCADA 作 updatedAfter(与接口文档游标一致)
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastUserListSyncUtc")]
|
||||
public DateTime? LastUserListSyncUtc { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg
|
||||
{
|
||||
/// <summary>
|
||||
/// 读写本地 Jeecg 同步状态文件(与 appsettings 同目录下的 Configuration)
|
||||
/// </summary>
|
||||
public class JeecgSyncStateStore
|
||||
{
|
||||
private readonly string _filePath;
|
||||
|
||||
public JeecgSyncStateStore()
|
||||
{
|
||||
var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Configuration");
|
||||
_filePath = Path.Combine(dir, "jeecg-sync-state.json");
|
||||
}
|
||||
|
||||
public JeecgSyncState Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
return new JeecgSyncState();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_filePath);
|
||||
return JsonSerializer.Deserialize<JeecgSyncState>(json) ?? new JeecgSyncState();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new JeecgSyncState();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(JeecgSyncState state)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(_filePath, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using YY.Admin.Core;
|
||||
using YY.Admin.Core.Services;
|
||||
using YY.Admin.Services.Service.Auth;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// Outbox 消费端:执行 SCADA 全量拉取并写入 jeecg_sys_user(与设备模块 HTTP 幂等上报并列的第二条 REST 能力)。
|
||||
/// </summary>
|
||||
public class JeecgUserMirrorPullHandler : IJeecgUserMirrorPullHandler, ISingletonDependency
|
||||
{
|
||||
private readonly ISysAuthService _authService;
|
||||
|
||||
public JeecgUserMirrorPullHandler(ISysAuthService authService)
|
||||
{
|
||||
_authService = authService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExecutePullAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _authService.TryBackgroundSyncJeecgUsersAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Prism.Events;
|
||||
using System.Text.Json;
|
||||
using YY.Admin.Core;
|
||||
using YY.Admin.Core.Events;
|
||||
using YY.Admin.Core.Services;
|
||||
using YY.Admin.Core.Sync;
|
||||
|
||||
namespace YY.Admin.Services.Service.Jeecg;
|
||||
|
||||
/// <summary>
|
||||
/// 用户镜像同步:统一走设备同步规范线路(STOMP 收信号 → Outbox → REST 拉取 SCADA),不再使用独立 Jeecg 原生 WebSocket 收包循环。
|
||||
/// </summary>
|
||||
public class JeecgUserSyncCoordinator : IJeecgUserSyncCoordinator, ISingletonDependency
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IJeecgUserMirrorPullOutbox _mirrorOutbox;
|
||||
private readonly ILoggerService _logger;
|
||||
private CancellationTokenSource? _cts;
|
||||
private readonly object _lifecycleLock = new();
|
||||
private SubscriptionToken? _remoteCommandSubscription;
|
||||
|
||||
public JeecgUserSyncCoordinator(
|
||||
IConfiguration configuration,
|
||||
IEventAggregator eventAggregator,
|
||||
IJeecgUserMirrorPullOutbox mirrorOutbox,
|
||||
ILoggerService logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_eventAggregator = eventAggregator;
|
||||
_mirrorOutbox = mirrorOutbox;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Start()
|
||||
{
|
||||
var enabled = _configuration.GetValue("JeecgIntegration:Enabled", false);
|
||||
var baseUrl = _configuration.GetValue<string>("JeecgIntegration:BaseUrl") ?? string.Empty;
|
||||
var stompPath = "/ws/device/websocket";
|
||||
_logger.Information($"Jeecg用户同步协调器启动(统一设备通道),Enabled={enabled}, BaseUrl={baseUrl}, Stomp={stompPath}");
|
||||
|
||||
if (!enabled)
|
||||
{
|
||||
_logger.Warning("Jeecg用户同步协调器未启动:JeecgIntegration:Enabled=false");
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken token;
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
CancelAndDisposeCts();
|
||||
UnsubscribeRemoteCommand();
|
||||
_remoteCommandSubscription = _eventAggregator.GetEvent<RemoteCommandReceivedEvent>().Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread);
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
token = _cts.Token;
|
||||
}
|
||||
|
||||
// 进入主窗口后稍延迟再入队一次全量拉取,避免与登录同步抢带宽
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(3000, token).ConfigureAwait(false);
|
||||
await _mirrorOutbox.EnqueuePullAsync(JeecgUserMirrorOutbox.EventBoot, null, token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Jeecg 启动后入队同步失败: {ex.Message}");
|
||||
}
|
||||
}, token);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Stop()
|
||||
{
|
||||
lock (_lifecycleLock)
|
||||
{
|
||||
UnsubscribeRemoteCommand();
|
||||
CancelAndDisposeCts();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRemoteCommand(RemoteCommandPayload payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = payload.CommandJson ?? string.Empty;
|
||||
if (!ShouldTriggerUserSync(json))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Information($"收到设备统一通道(STOMP)用户变更信号,入队 Outbox: {json}");
|
||||
_ = _mirrorOutbox.EnqueuePullAsync(JeecgUserMirrorOutbox.EventSignal, json, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"处理 STOMP 用户变更信号失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeRemoteCommand()
|
||||
{
|
||||
if (_remoteCommandSubscription != null)
|
||||
{
|
||||
_eventAggregator.GetEvent<RemoteCommandReceivedEvent>().Unsubscribe(_remoteCommandSubscription);
|
||||
_remoteCommandSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelAndDisposeCts()
|
||||
{
|
||||
try
|
||||
{
|
||||
_cts?.Cancel();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldTriggerUserSync(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
if (TryMatchCmd(root))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// 设备模块 REST 下发的 commandJson 包裹
|
||||
if (root.TryGetProperty("commandJson", out var innerEl) && innerEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var rawInner = innerEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(rawInner))
|
||||
{
|
||||
using var innerDoc = JsonDocument.Parse(rawInner);
|
||||
return TryMatchCmd(innerDoc.RootElement);
|
||||
}
|
||||
}
|
||||
if (root.TryGetProperty("message", out var innerMessage)
|
||||
&& innerMessage.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var rawInner = innerMessage.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(rawInner))
|
||||
{
|
||||
using var innerDoc = JsonDocument.Parse(rawInner);
|
||||
return TryMatchCmd(innerDoc.RootElement);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryMatchCmd(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!element.TryGetProperty("cmd", out var cmd))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var cmdValue = cmd.GetString();
|
||||
return string.Equals(cmdValue, "SCADA_USER_CHANGED", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(cmdValue, "SCADA_USERS_CHANGED", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user