328 lines
9.5 KiB
C#
328 lines
9.5 KiB
C#
using OrderSupportTicket.Models;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Windows.Threading;
|
|
|
|
namespace OrderSupportTicket.Services;
|
|
|
|
/// <summary>
|
|
/// 应用级工单实时监视器。SSE 连接的生命周期与应用一致,不随工单页关闭而停止。
|
|
/// </summary>
|
|
public sealed class TicketRealtimeMonitor : IDisposable
|
|
{
|
|
private readonly object _sync = new object();
|
|
private readonly HashSet<string> _seenEventIds =
|
|
new HashSet<string>(StringComparer.Ordinal);
|
|
private readonly Dictionary<string, int> _unreadMessages =
|
|
new Dictionary<string, int>(StringComparer.Ordinal);
|
|
private readonly Dispatcher _dispatcher;
|
|
private TicketClientOptions _options;
|
|
private string _optionsFingerprint;
|
|
private TicketRealtimeNotification _latestNotification;
|
|
private bool _subscribed;
|
|
private bool _disposed;
|
|
|
|
private TicketRealtimeMonitor()
|
|
{
|
|
RealtimeService = new EventSourceTicketRealtimeService();
|
|
RealtimeService.NotificationReceived += OnNotificationReceived;
|
|
_subscribed = true;
|
|
_dispatcher = System.Windows.Application.Current?.Dispatcher
|
|
?? Dispatcher.CurrentDispatcher;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 全局唯一的工单实时监视器。
|
|
/// </summary>
|
|
public static TicketRealtimeMonitor Current { get; } = new TicketRealtimeMonitor();
|
|
|
|
/// <summary>
|
|
/// 供工单页复用的实时服务,避免重复建立 SSE 连接。
|
|
/// </summary>
|
|
public EventSourceTicketRealtimeService RealtimeService { get; }
|
|
|
|
/// <summary>
|
|
/// 收到新的工单留言通知时触发。
|
|
/// </summary>
|
|
public event EventHandler<TicketInboxMessageEventArgs> NewMessageReceived;
|
|
|
|
/// <summary>
|
|
/// 未读工单数量变化时触发。
|
|
/// </summary>
|
|
public event EventHandler<TicketUnreadCountChangedEventArgs> UnreadCountChanged;
|
|
|
|
/// <summary>
|
|
/// 当前未读工单数量。
|
|
/// </summary>
|
|
public int UnreadCount
|
|
{
|
|
get
|
|
{
|
|
lock (_sync)
|
|
return _unreadMessages.Values.Sum();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 是否已经启动过实时连接。
|
|
/// </summary>
|
|
public bool IsStarted
|
|
{
|
|
get
|
|
{
|
|
lock (_sync)
|
|
return _options != null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 最近一条新消息通知,供主页面点击角标时定位工单。
|
|
/// </summary>
|
|
public TicketRealtimeNotification LatestNotification
|
|
{
|
|
get
|
|
{
|
|
lock (_sync)
|
|
return _latestNotification;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 启动或复用实时连接。相同账号和 APPID 重复调用不会重建连接。
|
|
/// </summary>
|
|
public async Task Start(TicketClientOptions options)
|
|
{
|
|
if (_disposed)
|
|
throw new ObjectDisposedException(nameof(TicketRealtimeMonitor));
|
|
if (options == null)
|
|
throw new ArgumentNullException(nameof(options));
|
|
|
|
TicketApiService.ValidateOptions(options);
|
|
var fingerprint = BuildFingerprint(options);
|
|
|
|
lock (_sync)
|
|
{
|
|
if (string.Equals(_optionsFingerprint, fingerprint, StringComparison.Ordinal)
|
|
&& RealtimeService.State != TicketRealtimeState.Disconnected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!string.Equals(_optionsFingerprint, fingerprint, StringComparison.Ordinal))
|
|
{
|
|
_seenEventIds.Clear();
|
|
}
|
|
_options = options;
|
|
_optionsFingerprint = fingerprint;
|
|
}
|
|
|
|
await RealtimeService.Start(options);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 将指定工单标记为已读。
|
|
/// </summary>
|
|
public void MarkRead(string appId, string wechatOrderId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(wechatOrderId))
|
|
return;
|
|
|
|
var key = BuildTicketKey(appId, wechatOrderId);
|
|
var changed = false;
|
|
lock (_sync)
|
|
changed = _unreadMessages.Remove(key);
|
|
if (changed)
|
|
RaiseUnreadCountChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 合并历史未读数据。与实时事件产生的未读取较大值,避免重复计数。
|
|
/// </summary>
|
|
/// <param name="unreadByTicket">
|
|
/// 键为 "appId|wechatOrderId",值为该工单的未读消息数。
|
|
/// </param>
|
|
public void MergeUnread(IDictionary<string, int> unreadByTicket)
|
|
{
|
|
if (unreadByTicket == null || unreadByTicket.Count == 0)
|
|
return;
|
|
|
|
var changed = false;
|
|
lock (_sync)
|
|
{
|
|
foreach (var item in unreadByTicket)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(item.Key) || item.Value <= 0)
|
|
continue;
|
|
|
|
_unreadMessages.TryGetValue(item.Key, out var current);
|
|
if (item.Value <= current)
|
|
continue;
|
|
|
|
_unreadMessages[item.Key] = item.Value;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed)
|
|
RaiseUnreadCountChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 清空全部未读提示。
|
|
/// </summary>
|
|
public void MarkAllRead()
|
|
{
|
|
var changed = false;
|
|
lock (_sync)
|
|
{
|
|
if (_unreadMessages.Count > 0)
|
|
{
|
|
_unreadMessages.Clear();
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed)
|
|
RaiseUnreadCountChanged();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
if (_subscribed)
|
|
{
|
|
RealtimeService.NotificationReceived -= OnNotificationReceived;
|
|
_subscribed = false;
|
|
}
|
|
RealtimeService.Dispose();
|
|
}
|
|
|
|
private void OnNotificationReceived(
|
|
object sender,
|
|
TicketRealtimeNotificationEventArgs args)
|
|
{
|
|
var notification = args?.Notification;
|
|
if (notification == null || notification.IsResyncRequired)
|
|
return;
|
|
if (string.IsNullOrWhiteSpace(notification.AppId)
|
|
|| string.IsNullOrWhiteSpace(notification.WechatOrderId))
|
|
{
|
|
return;
|
|
}
|
|
if (!IsMessageAdded(notification))
|
|
return;
|
|
|
|
if (!string.IsNullOrWhiteSpace(notification.EventId))
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (!_seenEventIds.Add(notification.EventId))
|
|
return;
|
|
if (_seenEventIds.Count > 5000)
|
|
_seenEventIds.Clear();
|
|
}
|
|
}
|
|
|
|
var key = BuildTicketKey(notification.AppId, notification.WechatOrderId);
|
|
int unreadCount;
|
|
lock (_sync)
|
|
{
|
|
_unreadMessages.TryGetValue(key, out var ticketUnreadCount);
|
|
_unreadMessages[key] = ticketUnreadCount + 1;
|
|
_latestNotification = notification;
|
|
unreadCount = _unreadMessages.Values.Sum();
|
|
}
|
|
|
|
_dispatcher.BeginInvoke(new Action(() =>
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
NewMessageReceived?.Invoke(
|
|
this,
|
|
new TicketInboxMessageEventArgs(notification, unreadCount));
|
|
UnreadCountChanged?.Invoke(
|
|
this,
|
|
new TicketUnreadCountChangedEventArgs(unreadCount));
|
|
}));
|
|
}
|
|
|
|
private void RaiseUnreadCountChanged()
|
|
{
|
|
var unreadCount = UnreadCount;
|
|
_dispatcher.BeginInvoke(new Action(() =>
|
|
{
|
|
if (!_disposed)
|
|
UnreadCountChanged?.Invoke(
|
|
this,
|
|
new TicketUnreadCountChangedEventArgs(unreadCount));
|
|
}));
|
|
}
|
|
|
|
private static bool IsMessageAdded(TicketRealtimeNotification notification)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(notification.MessageId))
|
|
return true;
|
|
return !string.IsNullOrWhiteSpace(notification.Type)
|
|
&& notification.Type.IndexOf(
|
|
"message_added",
|
|
StringComparison.OrdinalIgnoreCase) >= 0;
|
|
}
|
|
|
|
private static string BuildTicketKey(string appId, string wechatOrderId)
|
|
{
|
|
return appId + "|" + wechatOrderId;
|
|
}
|
|
|
|
private static string BuildFingerprint(TicketClientOptions options)
|
|
{
|
|
var appIds = (options.AppIds ?? Enumerable.Empty<string>())
|
|
.Where(appId => !string.IsNullOrWhiteSpace(appId))
|
|
.Select(appId => appId.Trim())
|
|
.Distinct(StringComparer.Ordinal)
|
|
.OrderBy(appId => appId, StringComparer.Ordinal);
|
|
return options.UserName + "|" + string.Join(",", appIds);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 主页面收到的工单新消息事件参数。
|
|
/// </summary>
|
|
public sealed class TicketInboxMessageEventArgs : EventArgs
|
|
{
|
|
public TicketInboxMessageEventArgs(
|
|
TicketRealtimeNotification notification,
|
|
int unreadCount)
|
|
{
|
|
Notification = notification;
|
|
UnreadCount = unreadCount;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 服务端推送的原始通知。
|
|
/// </summary>
|
|
public TicketRealtimeNotification Notification { get; }
|
|
|
|
/// <summary>
|
|
/// 收到该通知后的未读工单数量。
|
|
/// </summary>
|
|
public int UnreadCount { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 未读工单数量变化事件参数。
|
|
/// </summary>
|
|
public sealed class TicketUnreadCountChangedEventArgs : EventArgs
|
|
{
|
|
public TicketUnreadCountChangedEventArgs(int unreadCount)
|
|
{
|
|
UnreadCount = unreadCount;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 当前未读工单数量。
|
|
/// </summary>
|
|
public int UnreadCount { get; }
|
|
}
|