This commit is contained in:
wule committed 2026-10-19 15:13:22 +08:00
commit e4b1a24ddb
2 files changed
+438 -159

No files matched your search

@@ -1,3 +1,4 @@
using AllBase;
using LaunchDarkly.EventSource;
using Newtonsoft.Json;
using OrderSupportTicket.Models;
@@ -5,6 +6,7 @@ using OrderSupportTicket.Models.Api;
using System;
using System.Linq;
using System.Threading.Tasks;
using WxOrder.Framework.Helpers;
namespace OrderSupportTicket.Services;
@@ -14,186 +16,188 @@ namespace OrderSupportTicket.Services;
/// </summary>
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<TicketRealtimeNotificationEventArgs> NotificationReceived;
public event EventHandler<TicketRealtimeNotificationEventArgs> NotificationReceived;
public event EventHandler<TicketRealtimeStateChangedEventArgs> StateChanged;
public event EventHandler<TicketRealtimeStateChangedEventArgs> StateChanged;
/// <summary>
/// 当前 SSE 连接状态。
/// </summary>
public TicketRealtimeState State { get; private set; } = TicketRealtimeState.Disconnected;
/// <summary>
/// 当前 SSE 连接状态。
/// </summary>
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
{
NLogger.Instance.Info("工单服务连接成");
await source.StartAsync();
lock (_sync)
{
if (!_running || !ReferenceEquals(_eventSource, source))
return;
}
SetState(TicketRealtimeState.Reconnecting, "连接已关闭,等待重连…");
}
catch (Exception ex)
{
NLogger.Instance.Error("工单服务连接失败:" + 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<TicketRealtimeNotification>(data, TicketApiService.JsonSettings);
}
catch (JsonException)
{
return;
}
TicketRealtimeNotification notification;
try
{
notification = JsonConvert.DeserializeObject<TicketRealtimeNotification>(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);
}
}
+275
View File
@@ -0,0 +1,275 @@
using System;
using System.IO;
using System.Text;
namespace WxOrder.Framework.Helpers
{
/// <summary> 日志消息级别。 </summary>
public enum LogMessageLevel
{
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
Fatal = 5,
None = 6
}
/// <summary>
/// 调试输出桥接。默认输出到 System.Diagnostics.Debug,
/// 可在外部重新赋值 DebugPrint 以接管控件台/UI 输出。
/// </summary>
public static class DebugLog
{
/// <summary> 调试输出回调。默认写到 Debug 输出窗口。 </summary>
public static Action<string>? DebugPrint { get; set; }
= msg => System.Diagnostics.Debug.WriteLine(msg);
}
/// <summary>
/// 默认日志逻辑类(不依赖任何第三方日志库)。
/// 日志文件按日期分目录存放,并在跨天时自动切换:
/// {logDirectory}/yyyy-MM/yyyy-MM-dd.log,例如 log/2022-12/2022-12-12.log
/// </summary>
public sealed class NLogger : IDisposable
{
private const string DefaultName = "Default";
private static readonly object _lock = new object();
private static NLogger? _instance;
/// <summary>
/// 同一进程内所有实例共用的写入锁。
/// 多个实例(不同 Name)会写入同一个按日期命名的文件,需要串行化写入。
/// </summary>
private static readonly object _fileLock = new object();
/// <summary> 获取 NLogger 的唯一单例实例(线程安全)。 </summary>
public static NLogger Instance
{
get
{
if (_instance == null)
{
lock (_lock)
{
if (_instance == null)
_instance = new NLogger(DefaultName);
}
}
return _instance;
}
}
/// <summary> 静态工厂:根据名称创建日志实例。 </summary>
public static NLogger Create(string name, string? logDirectory = null)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("日志名称不能为空。", nameof(name));
return new NLogger(name, logDirectory);
}
/// <summary> 静态工厂:创建使用默认名称的日志实例。 </summary>
public static NLogger Create() => new NLogger(DefaultName);
/// <summary> 日志名称(写入每行日志中,用于区分来源,不再决定文件名)。 </summary>
public string Name { get; }
public bool IsDebugEnabled { get; set; } = true;
public bool IsDebugWriteLineEnabled { get; set; } = true;
public LogMessageLevel MinimumLevel { get; set; } = LogMessageLevel.Trace;
/// <summary> 日志根目录。 </summary>
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<string>? handler = DebugLog.DebugPrint;
if (handler != null)
{
handler(logOutput);
}
}
}
/// <summary> 根据日期生成日志文件路径:{根目录}/yyyy-MM/yyyy-MM-dd.log </summary>
private string GetLogFilePath(DateTime time)
{
return Path.Combine(
LogDirectory,
time.ToString("yyyy-MM"),
time.ToString("yyyy-MM-dd") + ".log");
}
/// <summary> 写入当天的日志文件,跨天时自动切换到新文件。 </summary>
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();
}
}
}
/// <summary> 确保写入器指向指定路径;路径变化(跨天/跨月)时关闭旧文件并打开新文件。 </summary>
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
}
}