From 1b0aa812638c7ee6ed048aea69a8aabbae6df857 Mon Sep 17 00:00:00 2001 From: chfei Date: Mon, 19 Oct 2026 14:53:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=B7=A5=E5=8D=95=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E5=A4=B1=E8=B4=A5=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EventSourceTicketRealtimeService.cs | 320 +++++++++--------- 1 file changed, 161 insertions(+), 159 deletions(-) diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs index c251ad4..26c8765 100644 --- a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs +++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs @@ -1,3 +1,4 @@ +using AllBase; using LaunchDarkly.EventSource; using Newtonsoft.Json; using OrderSupportTicket.Models; @@ -14,186 +15,187 @@ namespace OrderSupportTicket.Services; /// public sealed class EventSourceTicketRealtimeService { - private const string EventsPath = "/api/open/v2/customer-work-orders/events"; - private readonly object _sync = new object(); - private EventSource _eventSource; - private bool _running; - private bool _disposed; + private const string EventsPath = "/api/open/v2/customer-work-orders/events"; + private readonly object _sync = new object(); + private EventSource _eventSource; + private bool _running; + private bool _disposed; - public event EventHandler NotificationReceived; + public event EventHandler NotificationReceived; - public event EventHandler StateChanged; + public event EventHandler StateChanged; - /// - /// 当前 SSE 连接状态。 - /// - public TicketRealtimeState State { get; private set; } = TicketRealtimeState.Disconnected; + /// + /// 当前 SSE 连接状态。 + /// + public TicketRealtimeState State { get; private set; } = TicketRealtimeState.Disconnected; - public void Start(TicketClientOptions options) - { - if (_disposed) - throw new ObjectDisposedException(nameof(EventSourceTicketRealtimeService)); - if (options == null) - throw new ArgumentNullException(nameof(options)); + public void Start(TicketClientOptions options) + { + if (_disposed) + throw new ObjectDisposedException(nameof(EventSourceTicketRealtimeService)); + if (options == null) + throw new ArgumentNullException(nameof(options)); - TicketApiService.ValidateOptions(options); - StopCore(); + TicketApiService.ValidateOptions(options); + StopCore(); - var uri = BuildEventsUri(options); - var source = new EventSource(uri); - source.Opened += OnOpened; - source.Closed += OnClosed; - source.MessageReceived += OnMessageReceived; - source.Error += OnError; + var uri = BuildEventsUri(options); + var source = new EventSource(uri); + source.Opened += OnOpened; + source.Closed += OnClosed; + source.MessageReceived += OnMessageReceived; + source.Error += OnError; - lock (_sync) - { - _running = true; - _eventSource = source; - } + lock (_sync) + { + _running = true; + _eventSource = source; + } - SetState(TicketRealtimeState.Connecting, "连接中…"); - _ = RunAsync(source); - } + SetState(TicketRealtimeState.Connecting, "连接中…"); + _ = RunAsync(source); + } - public Task StopAsync() - { - StopCore(); - SetState(TicketRealtimeState.Disconnected, "已断开"); - return Task.FromResult(0); - } + public Task StopAsync() + { + StopCore(); + SetState(TicketRealtimeState.Disconnected, "已断开"); + return Task.FromResult(0); + } - public void Dispose() - { - if (_disposed) - return; - StopCore(); - _disposed = true; - } + public void Dispose() + { + if (_disposed) + return; + StopCore(); + _disposed = true; + } - private async Task RunAsync(EventSource source) - { - try - { - await source.StartAsync(); - lock (_sync) - { - if (!_running || !ReferenceEquals(_eventSource, source)) - return; - } - SetState(TicketRealtimeState.Reconnecting, "连接已关闭,等待重连…"); - } - catch (Exception ex) - { - lock (_sync) - { - if (!_running || !ReferenceEquals(_eventSource, source)) - return; - } - SetState(TicketRealtimeState.Reconnecting, "连接失败:" + ex.Message); - } - } + private async Task RunAsync(EventSource source) + { + try + { + await source.StartAsync(); + lock (_sync) + { + if (!_running || !ReferenceEquals(_eventSource, source)) + return; + } + SetState(TicketRealtimeState.Reconnecting, "连接已关闭,等待重连…"); + } + catch (Exception ex) + { + LogSave.Add("工单服务连接失败:" + ex.Message); + lock (_sync) + { + if (!_running || !ReferenceEquals(_eventSource, source)) + return; + } + SetState(TicketRealtimeState.Reconnecting, "连接失败:" + ex.Message); + } + } - private void OnOpened(object sender, StateChangedEventArgs args) - { - SetState(TicketRealtimeState.Connected, "已连接"); - } + private void OnOpened(object sender, StateChangedEventArgs args) + { + SetState(TicketRealtimeState.Connected, "已连接"); + } - private void OnClosed(object sender, StateChangedEventArgs args) - { - lock (_sync) - { - if (!_running || !ReferenceEquals(_eventSource, sender)) - return; - } - SetState(TicketRealtimeState.Reconnecting, "连接断开,重连中…"); - } + private void OnClosed(object sender, StateChangedEventArgs args) + { + lock (_sync) + { + if (!_running || !ReferenceEquals(_eventSource, sender)) + return; + } + SetState(TicketRealtimeState.Reconnecting, "连接断开,重连中…"); + } - private void OnError(object sender, ExceptionEventArgs args) - { - lock (_sync) - { - if (!_running || !ReferenceEquals(_eventSource, sender)) - return; - } - SetState( - TicketRealtimeState.Reconnecting, - "连接异常,重连中:" + (args.Exception?.Message ?? "未知错误")); - } + private void OnError(object sender, ExceptionEventArgs args) + { + lock (_sync) + { + if (!_running || !ReferenceEquals(_eventSource, sender)) + return; + } + SetState( + TicketRealtimeState.Reconnecting, + "连接异常,重连中:" + (args.Exception?.Message ?? "未知错误")); + } - private void OnMessageReceived(object sender, MessageReceivedEventArgs args) - { - if (args == null) - return; - var data = args.Message.Data; - if (string.IsNullOrWhiteSpace(data)) - return; + private void OnMessageReceived(object sender, MessageReceivedEventArgs args) + { + if (args == null) + return; + var data = args.Message.Data; + if (string.IsNullOrWhiteSpace(data)) + return; - TicketRealtimeNotification notification; - try - { - notification = JsonConvert.DeserializeObject(data, TicketApiService.JsonSettings); - } - catch (JsonException) - { - return; - } + TicketRealtimeNotification notification; + try + { + notification = JsonConvert.DeserializeObject(data, TicketApiService.JsonSettings); + } + catch (JsonException) + { + return; + } - if (notification == null) - return; - if (string.IsNullOrWhiteSpace(notification.EventId)) - notification.EventId = args.Message.LastEventId; - if (string.IsNullOrWhiteSpace(notification.Type)) - notification.Type = args.EventName; + if (notification == null) + return; + if (string.IsNullOrWhiteSpace(notification.EventId)) + notification.EventId = args.Message.LastEventId; + if (string.IsNullOrWhiteSpace(notification.Type)) + notification.Type = args.EventName; - NotificationReceived?.Invoke( - this, - new TicketRealtimeNotificationEventArgs(notification)); - } + NotificationReceived?.Invoke( + this, + new TicketRealtimeNotificationEventArgs(notification)); + } - private void StopCore() - { - EventSource source; - lock (_sync) - { - _running = false; - source = _eventSource; - _eventSource = null; - } + private void StopCore() + { + EventSource source; + lock (_sync) + { + _running = false; + source = _eventSource; + _eventSource = null; + } - if (source == null) - return; - source.Opened -= OnOpened; - source.Closed -= OnClosed; - source.MessageReceived -= OnMessageReceived; - source.Error -= OnError; - source.Close(); - } + if (source == null) + return; + source.Opened -= OnOpened; + source.Closed -= OnClosed; + source.MessageReceived -= OnMessageReceived; + source.Error -= OnError; + source.Close(); + } - private void SetState(TicketRealtimeState state, string message) - { - State = state; - StateChanged?.Invoke( - this, - new TicketRealtimeStateChangedEventArgs(state, message)); - } + private void SetState(TicketRealtimeState state, string message) + { + State = state; + StateChanged?.Invoke( + this, + new TicketRealtimeStateChangedEventArgs(state, message)); + } - private static Uri BuildEventsUri(TicketClientOptions options) - { - var appIds = options.AppIds - .Where(a => !string.IsNullOrWhiteSpace(a)) - .Distinct(StringComparer.Ordinal) - .Select(a => "appId=" + Uri.EscapeDataString(a.Trim())); - var query = new[] - { - "purchaseAccount=" + Uri.EscapeDataString(options.UserName) - }.Concat(appIds); + private static Uri BuildEventsUri(TicketClientOptions options) + { + var appIds = options.AppIds + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Distinct(StringComparer.Ordinal) + .Select(a => "appId=" + Uri.EscapeDataString(a.Trim())); + var query = new[] + { + "purchaseAccount=" + Uri.EscapeDataString(options.UserName) + }.Concat(appIds); - return new Uri( - TicketApiService.ServerUrl - + EventsPath - + "?" - + string.Join("&", query), - UriKind.Absolute); - } + return new Uri( + TicketApiService.ServerUrl + + EventsPath + + "?" + + string.Join("&", query), + UriKind.Absolute); + } } From c999a8004817d7597475aff7fd0a421e0f4762ea Mon Sep 17 00:00:00 2001 From: chfei Date: Mon, 19 Oct 2026 15:09:51 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=97=A5=E5=BF=97=20?= =?UTF-8?q?=E5=B7=A5=E5=8D=95=E8=BF=9E=E6=8E=A5=E5=A2=9E=E5=8A=A0=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EventSourceTicketRealtimeService.cs | 4 +- WxOrder.Framework/Helpers/NLogger.cs | 275 ++++++++++++++++++ 2 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 WxOrder.Framework/Helpers/NLogger.cs diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs index 26c8765..93c960a 100644 --- a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs +++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs @@ -6,6 +6,7 @@ using OrderSupportTicket.Models.Api; using System; using System.Linq; using System.Threading.Tasks; +using WxOrder.Framework.Helpers; namespace OrderSupportTicket.Services; @@ -76,6 +77,7 @@ public sealed class EventSourceTicketRealtimeService { try { + NLogger.Instance.Info("工单服务连接成"); await source.StartAsync(); lock (_sync) { @@ -86,7 +88,7 @@ public sealed class EventSourceTicketRealtimeService } catch (Exception ex) { - LogSave.Add("工单服务连接失败:" + ex.Message); + NLogger.Instance.Error("工单服务连接失败:" + ex.Message); lock (_sync) { if (!_running || !ReferenceEquals(_eventSource, source)) diff --git a/WxOrder.Framework/Helpers/NLogger.cs b/WxOrder.Framework/Helpers/NLogger.cs new file mode 100644 index 0000000..b26ea2e --- /dev/null +++ b/WxOrder.Framework/Helpers/NLogger.cs @@ -0,0 +1,275 @@ +using System; +using System.IO; +using System.Text; + +namespace WxOrder.Framework.Helpers +{ + /// 日志消息级别。 + public enum LogMessageLevel + { + Trace = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4, + Fatal = 5, + None = 6 + } + + /// + /// 调试输出桥接。默认输出到 System.Diagnostics.Debug, + /// 可在外部重新赋值 DebugPrint 以接管控件台/UI 输出。 + /// + public static class DebugLog + { + /// 调试输出回调。默认写到 Debug 输出窗口。 + public static Action? DebugPrint { get; set; } + = msg => System.Diagnostics.Debug.WriteLine(msg); + } + + /// + /// 默认日志逻辑类(不依赖任何第三方日志库)。 + /// 日志文件按日期分目录存放,并在跨天时自动切换: + /// {logDirectory}/yyyy-MM/yyyy-MM-dd.log,例如 log/2022-12/2022-12-12.log + /// + public sealed class NLogger : IDisposable + { + private const string DefaultName = "Default"; + + private static readonly object _lock = new object(); + private static NLogger? _instance; + + /// + /// 同一进程内所有实例共用的写入锁。 + /// 多个实例(不同 Name)会写入同一个按日期命名的文件,需要串行化写入。 + /// + private static readonly object _fileLock = new object(); + + /// 获取 NLogger 的唯一单例实例(线程安全)。 + public static NLogger Instance + { + get + { + if (_instance == null) + { + lock (_lock) + { + if (_instance == null) + _instance = new NLogger(DefaultName); + } + } + return _instance; + } + } + + /// 静态工厂:根据名称创建日志实例。 + public static NLogger Create(string name, string? logDirectory = null) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("日志名称不能为空。", nameof(name)); + + return new NLogger(name, logDirectory); + } + + /// 静态工厂:创建使用默认名称的日志实例。 + public static NLogger Create() => new NLogger(DefaultName); + + /// 日志名称(写入每行日志中,用于区分来源,不再决定文件名)。 + public string Name { get; } + + public bool IsDebugEnabled { get; set; } = true; + + public bool IsDebugWriteLineEnabled { get; set; } = true; + + public LogMessageLevel MinimumLevel { get; set; } = LogMessageLevel.Trace; + + /// 日志根目录。 + public string LogDirectory { get; } + + private string? _currentFilePath; + private StreamWriter? _writer; + private bool _disposed; + + private NLogger(string name, string? logDirectory = null) + { + Name = name; + LogDirectory = logDirectory ?? Path.Combine(AppContext.BaseDirectory, "log"); + // 文件在第一次写入时按当前日期惰性创建,构造阶段不打开文件。 + } + + #region 日志方法 + + public void Trace(string message, Exception? exception = null) + => Execute(LogMessageLevel.Trace, message, exception); + + public void Debug(string message, Exception? exception = null) + => Execute(LogMessageLevel.Debug, message, exception); + + public void Info(string message, Exception? exception = null) + => Execute(LogMessageLevel.Info, message, exception); + + public void Warn(string message, Exception? exception = null) + => Execute(LogMessageLevel.Warn, message, exception); + + public void Error(string message, Exception? exception = null) + => Execute(LogMessageLevel.Error, message, exception); + + public void Fatal(string message, Exception? exception = null) + => Execute(LogMessageLevel.Fatal, message, exception); + + public void Log(string message, LogMessageLevel level, Exception? exception = null) + => Execute(level, message, exception); + + #endregion + + #region 内部逻辑 + + private bool CheckEnabled(LogMessageLevel level) + { + if (level == LogMessageLevel.None) + return false; + + if (level < MinimumLevel) + return false; + + if (level == LogMessageLevel.Trace || level == LogMessageLevel.Debug) + return IsDebugEnabled; + + return true; + } + + private void Execute(LogMessageLevel level, string message, Exception? exception) + { + if (CheckEnabled(level)) + { + WriteToFile(level, message, exception); + } + + if (IsDebugWriteLineEnabled) + { + string logOutput = "[" + level + "] " + message; + if (exception != null) + { + logOutput += Environment.NewLine + exception; + } + + Action? handler = DebugLog.DebugPrint; + if (handler != null) + { + handler(logOutput); + } + } + } + + /// 根据日期生成日志文件路径:{根目录}/yyyy-MM/yyyy-MM-dd.log + private string GetLogFilePath(DateTime time) + { + return Path.Combine( + LogDirectory, + time.ToString("yyyy-MM"), + time.ToString("yyyy-MM-dd") + ".log"); + } + + /// 写入当天的日志文件,跨天时自动切换到新文件。 + private void WriteToFile(LogMessageLevel level, string message, Exception? exception) + { + DateTime now = DateTime.Now; + + var sb = new StringBuilder(); + sb.Append('[') + .Append(now.ToString("yyyy-MM-dd HH:mm:ss.fff")) + .Append("] [") + .Append(level) + .Append("] [") + .Append(Name) + .Append("] ") + .AppendLine(message); + + if (exception != null) + { + sb.AppendLine(exception.ToString()); + } + + string line = sb.ToString(); + + lock (_fileLock) + { + if (_disposed) return; + + try + { + EnsureWriter(GetLogFilePath(now)); + _writer!.Write(line); + _writer.Flush(); + } + catch + { + // 日志写入失败不应影响主流程,这里吞掉异常。 + // 重置写入器,下次写入时重新尝试打开文件。 + CloseWriter(); + } + } + } + + /// 确保写入器指向指定路径;路径变化(跨天/跨月)时关闭旧文件并打开新文件。 + private void EnsureWriter(string path) + { + if (_writer != null && string.Equals(_currentFilePath, path, StringComparison.Ordinal)) + return; + + CloseWriter(); + + string? dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + var fs = new FileStream( + path, + FileMode.Append, + FileAccess.Write, + FileShare.ReadWrite); + + _writer = new StreamWriter(fs, new UTF8Encoding(false)) + { + AutoFlush = false + }; + _currentFilePath = path; + } + + private void CloseWriter() + { + try + { + if (_writer != null) + { + _writer.Flush(); + _writer.Dispose(); + } + } + catch { /* ignore */ } + finally + { + _writer = null; + _currentFilePath = null; + } + } + + #endregion + + #region IDisposable + + public void Dispose() + { + lock (_fileLock) + { + if (_disposed) return; + _disposed = true; + CloseWriter(); + } + } + + #endregion + } +} \ No newline at end of file