工单 增加 接收服务端推送的新工单,支持 推送增加消息

This commit is contained in:
chfei committed 2026-10-22 09:48:12 +08:00
1 parent f0f1f1ed9e
commit 6ed2cede16
11 files changed
+429 -205

No files matched your search

@@ -42,6 +42,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
private readonly AsyncDelegateCommand _sendMessageCommand;
private readonly AsyncDelegateCommand<PendingAttachmentViewModel> _removePendingAttachmentCommand;
private readonly AsyncDelegateCommand _loadEarlierMessagesCommand;
private readonly AsyncDelegateCommand _refreshMessagesCommand;
private readonly AsyncDelegateCommand _retryRealtimeCommand;
private readonly DelegateCommand<FrameworkElement> _addImagesCommand;
private readonly HashSet<string> _seenEventIds =
@@ -50,6 +51,12 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
new Dictionary<string, int>(StringComparer.Ordinal);
private Dictionary<string, WorkOrderListCacheItem> _savedWorkOrders =
new Dictionary<string, WorkOrderListCacheItem>(StringComparer.Ordinal);
// 按 APPID 和订单号隔离各工单消息,切换会话不丢失推送。
private readonly Dictionary<string, Dictionary<string, TicketMessageDto>> _conversationMessages =
new Dictionary<string, Dictionary<string, TicketMessageDto>>(StringComparer.Ordinal);
private readonly Dictionary<string, WorkOrderMessagesResponse> _conversationPages = new Dictionary<string, WorkOrderMessagesResponse>(StringComparer.Ordinal);
private readonly Dictionary<string, Task> _conversationLoads = new Dictionary<string, Task>(StringComparer.Ordinal);
private bool IsRealtimeConversation => _options?.ConversationMode == TicketConversationMode.Realtime;
private bool _hasSavedCache;
private TicketClientOptions _options;
private bool _configured;
@@ -114,6 +121,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
_loadEarlierMessagesCommand = new AsyncDelegateCommand(
LoadEarlierMessagesAsync,
() => !IsBusy && SelectedTicket != null && HasMoreMessages);
_refreshMessagesCommand = new AsyncDelegateCommand(
RefreshMessagesAsync,
() => _configured && !IsBusy && !IsLoadingMessages && !IsDraft && SelectedTicket != null);
_retryRealtimeCommand = new AsyncDelegateCommand(
RestartRealtimeAsync,
() => _configured);
@@ -198,6 +208,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
/// </summary>
public ICommand LoadEarlierMessagesCommand => _loadEarlierMessagesCommand;
/// <summary>强制重新获取当前工单消息,两种会话模式均不使用缓存代替请求。</summary>
public ICommand RefreshMessagesCommand => _refreshMessagesCommand;
/// <summary>
/// 手动重新连接 SSE 的命令。
/// </summary>
@@ -377,7 +390,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
public bool IsLoadingMessages
{
get => _isLoadingMessages;
private set => SetValue(ref _isLoadingMessages, value);
private set
{
if (SetValue(ref _isLoadingMessages, value))
_refreshMessagesCommand.RaiseCanExecuteChanged();
}
}
/// <summary>
@@ -865,6 +882,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
_initialized = false;
_pendingOpenKey = null;
_options = CloneOptions(options);
_conversationMessages.Clear();
_conversationPages.Clear();
_conversationLoads.Clear();
_seenEventIds.Clear();
_lastReadSequences.Clear();
HasNewData = false;
NewDataText = string.Empty;
@@ -943,6 +965,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
if (!_initialized || started)
{
// 会话模式不改变初始化列表和历史对话加载流程。
await RefreshAllWorkOrdersAsync(loadSelectedMessages: true);
_initialized = true;
}
@@ -1272,7 +1295,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
receipts);
if (result.Message != null)
AppendMessage(result.Message);
{
RememberMessage(ticket.Key, result.Message);
MergeMessages(new[] { result.Message });
}
ticket.ApplySendResult(result.Status, result.Version);
MoveTicketToTop(ticket);
TicketView.Refresh();
@@ -1337,6 +1363,36 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
}
}
private async Task RefreshMessagesAsync()
{
var ticket = SelectedTicket;
if (!_configured || IsBusy || IsLoadingMessages || IsDraft || ticket == null)
return;
var options = _options;
IsBusy = true;
IsLoadingMessages = true;
ClearNotice();
try
{
await FetchConversationAsync(ticket.AppId, ticket.WechatOrderId, force: true);
if (_disposed || !ReferenceEquals(options, _options)
|| IsDraft || SelectedTicket?.Key != ticket.Key)
return;
ShowCachedConversation(SelectedTicket);
await MarkReadBestEffortAsync(SelectedTicket);
}
catch (Exception ex)
{
SetNotice("刷新工单消息失败:" + DescribeException(ex), true);
}
finally
{
IsLoadingMessages = false;
IsBusy = false;
}
}
private async Task LoadEarlierMessagesAsync()
{
var ticket = SelectedTicket;
@@ -1352,6 +1408,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
ticket.WechatOrderId,
_nextBefore,
50);
foreach (var message in response.Items ?? new List<TicketMessageDto>())
RememberMessage(ticket.Key, message);
_conversationPages[ticket.Key] = response;
MergeMessages(response.Items ?? new List<TicketMessageDto>());
HasMoreMessages = response.HasMore;
_nextBefore = response.NextBefore;
@@ -1398,41 +1457,82 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
IsLoadingMessages = true;
try
{
var response = await _ticketService.GetMessagesAsync(
_options,
ticket.AppId,
ticket.WechatOrderId,
null,
50);
if (version != _selectionVersion
|| SelectedTicket == null
|| !string.Equals(ticket.Key, SelectedTicket.Key, StringComparison.Ordinal))
{
// 点击工单始终可读取历史,Realtime 模式复用已加载的会话。
if (!IsRealtimeConversation || !_conversationMessages.ContainsKey(ticket.Key))
await FetchConversationAsync(ticket.AppId, ticket.WechatOrderId, force: !IsRealtimeConversation);
if (version != _selectionVersion || SelectedTicket?.Key != ticket.Key || IsDraft)
return;
}
if (replace)
Messages.Clear();
MergeMessages(response.Items ?? new List<TicketMessageDto>());
HasMoreMessages = response.HasMore;
_nextBefore = response.NextBefore;
ShowCachedConversation(ticket);
await MarkReadBestEffortAsync(ticket);
MessageScrollVersion++;
}
catch (Exception ex)
{
if (replace)
Messages.Clear();
HasMoreMessages = false;
_nextBefore = null;
SetNotice(DescribeException(ex), true);
if (version == _selectionVersion)
{
if (replace) Messages.Clear();
HasMoreMessages = false;
_nextBefore = null;
SetNotice(DescribeException(ex), true);
}
}
finally
{
IsLoadingMessages = false;
if (version == _selectionVersion)
IsLoadingMessages = false;
}
}
private async Task FetchConversationAsync(string appId, string orderId, bool force = false)
{
var key = appId + "|" + orderId;
if (_conversationLoads.TryGetValue(key, out var loading))
{
await loading;
if (!force) return;
}
var task = FetchConversationCoreAsync(appId, orderId);
_conversationLoads[key] = task;
try { await task; }
finally
{
if (_conversationLoads.TryGetValue(key, out var current) && ReferenceEquals(current, task))
_conversationLoads.Remove(key);
}
}
private async Task FetchConversationCoreAsync(string appId, string orderId)
{
var options = _options;
var key = appId + "|" + orderId;
var before = _conversationMessages.TryGetValue(key, out var existing)
? new Dictionary<string, TicketMessageDto>(existing, StringComparer.Ordinal) : null;
var response = await _ticketService.GetMessagesAsync(options, appId, orderId, null, 50);
if (_disposed || !ReferenceEquals(options, _options)) return;
// 刷新历史期间收到的增量不能被较早的接口快照覆盖。
var addedDuringLoad = before != null && _conversationMessages.TryGetValue(key, out var latest)
? latest.Where(pair => !before.TryGetValue(pair.Key, out var old)
|| !ReferenceEquals(old, pair.Value)).Select(pair => pair.Value).ToList()
: new List<TicketMessageDto>();
// 只在历史请求成功后建立缓存;首次推送触发的历史已包含该消息,不再追加。
_conversationMessages[key] = new Dictionary<string, TicketMessageDto>(StringComparer.Ordinal);
foreach (var message in response.Items ?? new List<TicketMessageDto>())
RememberMessage(key, message);
foreach (var message in addedDuringLoad)
RememberMessage(key, message);
_conversationPages[key] = response;
}
private void ShowCachedConversation(WorkOrderSummaryViewModel ticket)
{
Messages.Clear();
if (_conversationMessages.TryGetValue(ticket.Key, out var cached))
MergeMessages(cached.Values);
_conversationPages.TryGetValue(ticket.Key, out var page);
HasMoreMessages = page?.HasMore ?? false;
_nextBefore = page?.NextBefore;
MessageScrollVersion++;
}
private async Task RefreshAllWorkOrdersAsync(
bool loadSelectedMessages,
string preferredKey = null)
@@ -1471,8 +1571,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
var hasNewMessage = HasNewMessage(dto);
if (existing.TryGetValue(key, out var item))
{
if (!item.TryApply(dto))
continue;
// 查询期间可能先收到更新的 SSE,保留较新摘要,不能丢掉该工单。
item.TryApply(dto);
}
else
{
@@ -1498,7 +1598,15 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
SaveWorkOrderList();
if (loadSelectedMessages && SelectedTicket != null && !IsDraft)
await LoadMessagesAsync(SelectedTicket, replace: true);
{
var selected = SelectedTicket;
await FetchConversationAsync(selected.AppId, selected.WechatOrderId, force: true);
if (!IsDraft && SelectedTicket?.Key == selected.Key)
{
ShowCachedConversation(selected);
await MarkReadBestEffortAsync(selected);
}
}
}
catch (Exception ex)
{
@@ -1579,26 +1687,6 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
Messages.Add(message);
}
private void AppendMessage(TicketMessageDto dto)
{
if (dto == null)
return;
var id = string.IsNullOrWhiteSpace(dto.MessageId)
? "sequence:" + dto.SequenceNo
: dto.MessageId;
if (Messages.Any(m => string.Equals(m.MessageId, id, StringComparison.Ordinal)))
return;
var incoming = new ChatMessageViewModel(dto);
var index = 0;
while (index < Messages.Count
&& Messages[index].SequenceNo <= incoming.SequenceNo)
{
index++;
}
Messages.Insert(index, incoming);
}
private async Task MarkReadBestEffortAsync(WorkOrderSummaryViewModel ticket)
{
if (!_isWindowActive || ticket == null)
@@ -1680,21 +1768,33 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
try
{
if (notification.IsResyncRequired)
if (notification.IsResyncRequired
|| string.Equals(notification.Type, "relay.ticket.created", StringComparison.OrdinalIgnoreCase))
{
await RefreshAllWorkOrdersAsync(
loadSelectedMessages: SelectedTicket != null && !IsDraft);
// 刷新后仍恢复当前工单,不能自动切换到新工单。
await RefreshAllWorkOrdersAsync(loadSelectedMessages: true);
var created = FindTicket(notification.AppId + "|" + notification.WechatOrderId);
if (created != null && !notification.IsResyncRequired) MoveTicketToTop(created);
return;
}
if (string.IsNullOrWhiteSpace(notification.AppId)
|| string.IsNullOrWhiteSpace(notification.WechatOrderId))
|| string.IsNullOrWhiteSpace(notification.WechatOrderId)
|| !_options.AppIds.Contains(notification.AppId)) return;
if (IsRealtimeConversation)
await ApplyRealtimeNotificationAsync(notification);
else
{
return;
// History 保持原有查询摘要、刷新当前对话的流程。
var ticket = FindTicket(notification.AppId + "|" + notification.WechatOrderId);
var unreadCount = (ticket?.UnreadCount ?? 0) + 1;
ticket?.EnsureUnreadCount(unreadCount);
await RefreshSingleWorkOrderAsync(notification.AppId, notification.WechatOrderId);
ticket = FindTicket(notification.AppId + "|" + notification.WechatOrderId);
if (ticket != null && (SelectedTicket?.Key != ticket.Key || !_isWindowActive))
ticket.EnsureUnreadCount(unreadCount);
SaveWorkOrderList();
}
await RefreshSingleWorkOrderAsync(
notification.AppId,
notification.WechatOrderId);
}
catch (Exception ex)
{
@@ -1702,6 +1802,64 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
}
}
private void RememberMessage(string key, TicketMessageDto message, bool preserveExisting = false)
{
if (message == null)
return;
if (!_conversationMessages.TryGetValue(key, out var messages))
_conversationMessages[key] = messages = new Dictionary<string, TicketMessageDto>(StringComparer.Ordinal);
var id = string.IsNullOrWhiteSpace(message.MessageId)
? "sequence:" + message.SequenceNo : message.MessageId;
if (!preserveExisting || !messages.ContainsKey(id))
messages[id] = message;
}
private async Task ApplyRealtimeNotificationAsync(TicketRealtimeNotification notification)
{
var key = notification.AppId + "|" + notification.WechatOrderId;
var ticket = FindTicket(key);
// 未加载过会话:获取历史,推送消息不再额外插入。
if (!_conversationMessages.TryGetValue(key, out var cached))
{
await FetchConversationAsync(notification.AppId, notification.WechatOrderId);
ticket?.MarkHasNewMessage();
if (!IsDraft && SelectedTicket?.Key == key)
{
ShowCachedConversation(SelectedTicket);
await MarkReadBestEffortAsync(SelectedTicket);
}
SaveWorkOrderList();
return;
}
var message = notification.Message;
if (message == null) return;
var id = string.IsNullOrWhiteSpace(message.MessageId) ? "sequence:" + message.SequenceNo : message.MessageId;
var isNew = !cached.ContainsKey(id);
RememberMessage(key, message);
if (notification.Ticket != null && ticket != null)
{
var dto = notification.Ticket;
dto.AppId = notification.AppId;
dto.WechatOrderId = notification.WechatOrderId;
dto.Version = dto.Version ?? notification.Version;
dto.LastMessageAt = message.CreatedAt;
dto.LastSenderType = message.SenderType;
dto.UnreadCount = ticket.UnreadCount;
ticket.TryApply(dto);
}
if (isNew) ticket?.IncrementUnreadCount();
if (!IsDraft && SelectedTicket?.Key == key)
{
var existing = Messages.FirstOrDefault(m => m.MessageId == id);
if (existing != null) existing.UpdateFrom(message);
else Messages.Add(new ChatMessageViewModel(message));
MessageScrollVersion++;
await MarkReadBestEffortAsync(SelectedTicket);
}
TicketView.Refresh();
SaveWorkOrderList();
}
private void OnRealtimeStateChanged(
object sender,
TicketRealtimeStateChangedEventArgs args)
@@ -1801,6 +1959,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware,
private void RaiseLoadEarlierCanExecuteChanged()
{
_loadEarlierMessagesCommand.RaiseCanExecuteChanged();
_refreshMessagesCommand.RaiseCanExecuteChanged();
}
private void SetNotice(string text, bool isError)