using Microsoft.Extensions.Configuration; using System.Net.Http; using System.IO; using System.Text; using System.Text.Json; using YY.Admin.Core.Util; namespace YY.Admin.Services.Service.Jeecg; /// /// 登录日志上报: /// 1) WebSocket 优先; /// 2) 失败自动 HTTP 兜底; /// 3) 仍失败则写本地队列,后台自动补传。 /// 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 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("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(); 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(); foreach (var line in lines) { LoginLogQueueItem? item = null; try { item = JsonSerializer.Deserialize(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() { var dir = AppWritablePaths.EnsureDirectoryExists(AppWritablePaths.AccountSettingsRootDirectory); return Path.Combine(dir, "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; } }