更新项目配置,新增设备同步模块,优化WebSocket和Swagger配置,增强SCADA系统的免登录接口,支持数据字典项和登录日志的免登录查询与记录。调整Java编译设置,确保更好的开发体验。

This commit is contained in:
geht
2026-04-28 10:23:58 +08:00
parent bbe46dcf2d
commit 142a0bdaba
1013 changed files with 41858 additions and 28 deletions

View File

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

View File

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

View File

@@ -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();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace YY.Admin.Services.Service.Jeecg
{
/// <summary>
/// Jeecg 同步本地状态(工控机断网续传水位)
/// </summary>
public class JeecgSyncState
{
/// <summary>
/// 同步水位UTC标准列表作 updateTime_beginSCADA 作 updatedAfter与接口文档游标一致
/// </summary>
[JsonPropertyName("lastUserListSyncUtc")]
public DateTime? LastUserListSyncUtc { get; set; }
}
}

View File

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

View File

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

View File

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