From 6ed2cede16502e37c14679f3424f1bb9e3f87c1c Mon Sep 17 00:00:00 2001 From: chfei Date: Thu, 22 Oct 2026 09:48:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E5=8D=95=20=E5=A2=9E=E5=8A=A0=20?= =?UTF-8?q?=E6=8E=A5=E6=94=B6=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=8E=A8=E9=80=81?= =?UTF-8?q?=E7=9A=84=E6=96=B0=E5=B7=A5=E5=8D=95=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20=E6=8E=A8=E9=80=81=E5=A2=9E=E5=8A=A0=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 29WxOrderWpf/MainWindow.xaml.cs | 2 +- .../Models/TicketClientOptions.cs | 294 ++++++++++-------- .../Models/TicketRealtimeNotification.cs | 9 +- .../EventSourceTicketRealtimeService.cs | 2 + .../Services/TicketApiService.cs | 5 +- .../Services/TicketRealtimeMonitor.cs | 6 +- .../ViewModels/ChatMessageViewModel.cs | 3 + .../ViewModels/OrderSupportTicketViewModel.cs | 275 ++++++++++++---- .../ViewModels/WorkOrderSummaryViewModel.cs | 16 +- .../Views/OrderSupportTicketView.xaml.cs | 3 +- .../Views/TicketConversationView.xaml | 19 +- 11 files changed, 429 insertions(+), 205 deletions(-) diff --git a/29WxOrderWpf/MainWindow.xaml.cs b/29WxOrderWpf/MainWindow.xaml.cs index 42346fd..f32837a 100644 --- a/29WxOrderWpf/MainWindow.xaml.cs +++ b/29WxOrderWpf/MainWindow.xaml.cs @@ -1,4 +1,4 @@ -using _29WxOrderWpf.BaseWindows; +using _29WxOrderWpf.BaseWindows; using _29WxOrderWpf.Ctrls.BuyerCtrls; using _29WxOrderWpf.Ctrls.OrderCtrls; using _29WxOrderWpf.Ctrls.Settings; diff --git a/OrderSupportTicket/Models/TicketClientOptions.cs b/OrderSupportTicket/Models/TicketClientOptions.cs index 229b6d3..95be7e6 100644 --- a/OrderSupportTicket/Models/TicketClientOptions.cs +++ b/OrderSupportTicket/Models/TicketClientOptions.cs @@ -1,4 +1,4 @@ -using AllBase; +using AllBase; using AllBase.Model.DBModel; using System; using System.Collections.Generic; @@ -13,63 +13,68 @@ namespace OrderSupportTicket.Models; /// public sealed class TicketClientOptions { - public TicketClientOptions() - { - AppIds = new List(); - Orders = new List(); - } + public TicketClientOptions() + { + AppIds = new List(); + Orders = new List(); + } - /// - /// 当前拍单账号,对应接口字段 purchaseAccount。 - /// - public string UserName { get; set; } + /// + /// 当前拍单账号,对应接口字段 purchaseAccount。 + /// + public string UserName { get; set; } - /// - /// 当前账号需要订阅和查询的店铺 APPID 集合,最多 50 个。 - /// - public IList AppIds { get; set; } + /// 会话数据来源:直接修改此处默认值切换 History / Realtime。 + public TicketConversationMode ConversationMode { get; set; } = TicketConversationMode.Realtime; - /// - /// 可关联到新工单的本地订单集合。 - /// - public IList Orders { get; set; } + /// + /// 当前账号需要订阅和查询的店铺 APPID 集合,最多 50 个。 + /// + public IList AppIds { get; set; } - /// - /// 创建参数副本,避免宿主与页面共享可变集合。 - /// - public TicketClientOptions Clone() - { - return new TicketClientOptions - { - UserName = UserName, - AppIds = (AppIds ?? Enumerable.Empty()) - .Where(appId => !string.IsNullOrWhiteSpace(appId)) - .Select(appId => appId.Trim()) - .Distinct(StringComparer.Ordinal) - .ToList(), - Orders = (Orders ?? Enumerable.Empty()) - .Where(order => order != null) - .ToList() - }; - } + /// + /// 可关联到新工单的本地订单集合。 + /// + public IList Orders { get; set; } - public static TicketClientOptions FromApplicationSettings() - { - var purchaseAccount = - ConfigurationManager.AppSettings["CustomerWorkOrder.PurchaseAccount"]; - var appIds = ConfigurationManager.AppSettings["CustomerWorkOrder.AppIds"]; + /// + /// 创建参数副本,避免宿主与页面共享可变集合。 + /// + public TicketClientOptions Clone() + { + return new TicketClientOptions + { + UserName = UserName, + ConversationMode = ConversationMode, + AppIds = (AppIds ?? Enumerable.Empty()) + .Where(appId => !string.IsNullOrWhiteSpace(appId)) + .Select(appId => appId.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(), + Orders = (Orders ?? Enumerable.Empty()) + .Where(order => order != null) + .ToList() + }; + } - return new TicketClientOptions - { - UserName = purchaseAccount, - AppIds = (appIds ?? string.Empty) - .Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries) - .Select(a => a.Trim()) - .Where(a => a.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList() - }; - } + public static TicketClientOptions FromApplicationSettings() + { + var purchaseAccount = + ConfigurationManager.AppSettings["CustomerWorkOrder.PurchaseAccount"]; + var appIds = ConfigurationManager.AppSettings["CustomerWorkOrder.AppIds"]; + + return new TicketClientOptions + { + UserName = purchaseAccount, + AppIds = (appIds ?? string.Empty) + .Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries) + .Select(a => a.Trim()) + .Where(a => a.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(), + ConversationMode = TicketConversationMode.Realtime, + }; + } } /// @@ -78,101 +83,114 @@ public sealed class TicketClientOptions /// public sealed class TicketOrderOption { - /// - /// 订单所属店铺的 APPID,格式为 wx 加 16 位小写十六进制字符。 - /// - public string AppId { get; set; } + /// + /// 订单所属店铺的 APPID,格式为 wx 加 16 位小写十六进制字符。 + /// + public string AppId { get; set; } - /// - /// 微信订单号。接口要求保持为 10~64 位数字字符串,禁止转换为 Number。 - /// - public string OrderId { get; set; } + /// + /// 微信订单号。接口要求保持为 10~64 位数字字符串,禁止转换为 Number。 + /// + public string OrderId { get; set; } - /// - /// 商品名称,用于订单筛选和页面展示。 - /// - public string GoodsName { get; set; } + /// + /// 商品名称,用于订单筛选和页面展示。 + /// + public string GoodsName { get; set; } - /// - /// 商品缩略图地址,用于新建工单页展示关联商品。 - /// - public string ImageUrl { get; set; } + /// + /// 商品缩略图地址,用于新建工单页展示关联商品。 + /// + public string ImageUrl { get; set; } - /// - /// 订单金额,单位元,使用 decimal 保存原始金额,不在模型中保存格式化文本。 - /// - public decimal Amount { get; set; } + /// + /// 订单金额,单位元,使用 decimal 保存原始金额,不在模型中保存格式化文本。 + /// + public decimal Amount { get; set; } - /// - /// 订单下单时间。 - /// - public DateTime OrderedAt { get; set; } + /// + /// 订单下单时间。 + /// + public DateTime OrderedAt { get; set; } - /// - /// 下拉列表显示文本,订单号在前、商品名在后,避免长商品名遮挡订单号。 - /// - public string DisplayText - { - get - { - var product = string.IsNullOrWhiteSpace(GoodsName) - ? "未命名商品" - : GoodsName; - return $"{OrderId} · {product}"; - } - } + /// + /// 下拉列表显示文本,订单号在前、商品名在后,避免长商品名遮挡订单号。 + /// + public string DisplayText + { + get + { + var product = string.IsNullOrWhiteSpace(GoodsName) + ? "未命名商品" + : GoodsName; + return $"{OrderId} · {product}"; + } + } - /// - /// 订单详情展示文本。商品名已包含在 中,这里只展示金额和下单时间。 - /// - public string ProductDescription - { - get - { - var amount = "¥ " + Amount.ToString("N2", CultureInfo.GetCultureInfo("zh-CN")); - var orderedAt = OrderedAt == default - ? "下单时间未知" - : OrderedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture); - return $"金额 {amount} · {orderedAt}"; - } - } + /// + /// 订单详情展示文本。商品名已包含在 中,这里只展示金额和下单时间。 + /// + public string ProductDescription + { + get + { + var amount = "¥ " + Amount.ToString("N2", CultureInfo.GetCultureInfo("zh-CN")); + var orderedAt = OrderedAt == default + ? "下单时间未知" + : OrderedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture); + return $"金额 {amount} · {orderedAt}"; + } + } - /// - /// 从本地订单生成工单关联订单信息,统一商品名、缩略图、金额和下单时间的取值规则。 - /// - public static TicketOrderOption FromWechatOrder( - oms_wei_order_info order, - string appId) - { - if (order == null) - return null; + /// + /// 从本地订单生成工单关联订单信息,统一商品名、缩略图、金额和下单时间的取值规则。 + /// + public static TicketOrderOption FromWechatOrder( + oms_wei_order_info order, + string appId) + { + if (order == null) + return null; - var goodsName = string.Join( - "、", - (order.orderItems ?? Enumerable.Empty()) - .Select(item => item.title) - .Where(title => !string.IsNullOrWhiteSpace(title)) - .Distinct()); - if (goodsName.Length > 200) - goodsName = goodsName.Substring(0, 200) + "…"; + var goodsName = string.Join( + "、", + (order.orderItems ?? Enumerable.Empty()) + .Select(item => item.title) + .Where(title => !string.IsNullOrWhiteSpace(title)) + .Distinct()); + if (goodsName.Length > 200) + goodsName = goodsName.Substring(0, 200) + "…"; - return new TicketOrderOption - { - AppId = appId?.Trim(), - OrderId = order.order_id, - GoodsName = goodsName, - ImageUrl = (order.orderItems ?? Enumerable.Empty()) - .Select(item => item.thumb_img) - .FirstOrDefault(url => !string.IsNullOrWhiteSpace(url)), - Amount = (order.order_price ?? 0) / 100m, - OrderedAt = order.create_time > 0 - ? BaseClass.StampToDateTime(order.create_time) - : default - }; - } + return new TicketOrderOption + { + AppId = appId?.Trim(), + OrderId = order.order_id, + GoodsName = goodsName, + ImageUrl = (order.orderItems ?? Enumerable.Empty()) + .Select(item => item.thumb_img) + .FirstOrDefault(url => !string.IsNullOrWhiteSpace(url)), + Amount = (order.order_price ?? 0) / 100m, + OrderedAt = order.create_time > 0 + ? BaseClass.StampToDateTime(order.create_time) + : default + }; + } - /// - /// 当前工单的唯一订单键,由 APPID 和微信订单号共同组成。 - /// - public string OrderKey => $"{AppId}|{OrderId}"; + /// + /// 当前工单的唯一订单键,由 APPID 和微信订单号共同组成。 + /// + public string OrderKey => $"{AppId}|{OrderId}"; +} + +/// History 点击加载历史;Realtime 仅使用推送和发送回执构建会话。 +public enum TicketConversationMode +{ + /// + /// 点击加载历史 + /// + History, + /// + /// 仅使用推送和发送回执构建会话 + /// + Realtime } diff --git a/OrderSupportTicket/Models/TicketRealtimeNotification.cs b/OrderSupportTicket/Models/TicketRealtimeNotification.cs index 8ff2725..a33389a 100644 --- a/OrderSupportTicket/Models/TicketRealtimeNotification.cs +++ b/OrderSupportTicket/Models/TicketRealtimeNotification.cs @@ -1,9 +1,10 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; +using OrderSupportTicket.Models.Api; namespace OrderSupportTicket.Models; /// -/// SSE 实时通知。通知只包含摘要定位信息,收到后需要重新查询对应工单。 +/// SSE 实时通知。包含工单摘要与完整消息,可直接增量构建会话。 /// public sealed class TicketRealtimeNotification { @@ -49,6 +50,10 @@ public sealed class TicketRealtimeNotification ////[JsonProperty("occurredAt")] public string OccurredAt { get; set; } + public WorkOrderSummaryDto Ticket { get; set; } + + public TicketMessageDto Message { get; set; } + /// /// 是否要求客户端执行全量重新同步。 /// diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs index 99b1446..428ce4e 100644 --- a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs +++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using OrderSupportTicket.Models; using OrderSupportTicket.Models.Api; using System; +using System.Diagnostics; using System.Linq; using System.Net; using System.Net.Http; @@ -137,6 +138,7 @@ public sealed class EventSourceTicketRealtimeService TicketRealtimeNotification notification; try { + Debug.WriteLine(data); notification = JsonConvert.DeserializeObject(data, TicketApiService.JsonSettings); } catch (JsonException) diff --git a/OrderSupportTicket/Services/TicketApiService.cs b/OrderSupportTicket/Services/TicketApiService.cs index af77607..9437100 100644 --- a/OrderSupportTicket/Services/TicketApiService.cs +++ b/OrderSupportTicket/Services/TicketApiService.cs @@ -5,6 +5,7 @@ using OrderSupportTicket.Models; using OrderSupportTicket.Models.Api; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Net; @@ -128,7 +129,7 @@ public sealed class TicketApiService Limit = limit, Before = string.IsNullOrWhiteSpace(before) ? null : before }; - + Debug.WriteLine($"{wechatOrderId}加载所有对话"); var path = ApiRoot + "/messages"; return await PostAsync(path, request); @@ -456,7 +457,7 @@ public sealed class TicketApiService EnsureSuccessBody(html); try { - var result = JsonConvert.DeserializeObject(html, JsonSettings); + var result = JsonConvert.DeserializeObject(html, JsonSettings); if (result == null) throw new JsonSerializationException("接口响应为空。"); return result; diff --git a/OrderSupportTicket/Services/TicketRealtimeMonitor.cs b/OrderSupportTicket/Services/TicketRealtimeMonitor.cs index aac9ff8..7b938c4 100644 --- a/OrderSupportTicket/Services/TicketRealtimeMonitor.cs +++ b/OrderSupportTicket/Services/TicketRealtimeMonitor.cs @@ -1,4 +1,4 @@ -using OrderSupportTicket.Models; +using OrderSupportTicket.Models; using System; using System.Collections.Generic; using System.Linq; @@ -109,6 +109,10 @@ public sealed class TicketRealtimeMonitor : IDisposable return; } + if (!string.Equals(_optionsFingerprint, fingerprint, StringComparison.Ordinal)) + { + _seenEventIds.Clear(); + } _options = options; _optionsFingerprint = fingerprint; } diff --git a/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs b/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs index 4458e16..5e17527 100644 --- a/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs +++ b/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs @@ -153,6 +153,9 @@ public sealed class ChatMessageViewModel : BindableBaseEx if (dto == null) return; + SequenceNo = dto.SequenceNo; + SenderType = dto.SenderType; + SenderDisplayName = string.IsNullOrWhiteSpace(dto.SenderName) ? (IsCustomer ? "我" : "客服") : dto.SenderName; diff --git a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs index 7046e34..690136f 100644 --- a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs +++ b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs @@ -42,6 +42,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, private readonly AsyncDelegateCommand _sendMessageCommand; private readonly AsyncDelegateCommand _removePendingAttachmentCommand; private readonly AsyncDelegateCommand _loadEarlierMessagesCommand; + private readonly AsyncDelegateCommand _refreshMessagesCommand; private readonly AsyncDelegateCommand _retryRealtimeCommand; private readonly DelegateCommand _addImagesCommand; private readonly HashSet _seenEventIds = @@ -50,6 +51,12 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, new Dictionary(StringComparer.Ordinal); private Dictionary _savedWorkOrders = new Dictionary(StringComparer.Ordinal); + // 按 APPID 和订单号隔离各工单消息,切换会话不丢失推送。 + private readonly Dictionary> _conversationMessages = + new Dictionary>(StringComparer.Ordinal); + private readonly Dictionary _conversationPages = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _conversationLoads = new Dictionary(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, /// public ICommand LoadEarlierMessagesCommand => _loadEarlierMessagesCommand; + /// 强制重新获取当前工单消息,两种会话模式均不使用缓存代替请求。 + public ICommand RefreshMessagesCommand => _refreshMessagesCommand; + /// /// 手动重新连接 SSE 的命令。 /// @@ -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(); + } } /// @@ -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()) + RememberMessage(ticket.Key, message); + _conversationPages[ticket.Key] = response; MergeMessages(response.Items ?? new List()); 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()); - 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(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(); + // 只在历史请求成功后建立缓存;首次推送触发的历史已包含该消息,不再追加。 + _conversationMessages[key] = new Dictionary(StringComparer.Ordinal); + foreach (var message in response.Items ?? new List()) + 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(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) diff --git a/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs b/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs index b199720..130d324 100644 --- a/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs +++ b/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs @@ -1,4 +1,4 @@ -using AllBase.Helper; +using AllBase.Helper; using OrderSupportTicket.Models.Api; using OrderSupportTicket.Services; using System; @@ -296,6 +296,8 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx public void ApplySendResult(string status, string version) { + if (!IsAtLeastCurrentVersion(version)) + return; if (!string.IsNullOrWhiteSpace(status)) { Status = status; @@ -309,6 +311,18 @@ public sealed class WorkOrderSummaryViewModel : BindableBaseEx HasNewMessage = false; } + public void EnsureUnreadCount(int count) + { + UnreadCount = Math.Max(UnreadCount, count); + if (UnreadCount > 0) HasNewMessage = true; + } + + public void IncrementUnreadCount() + { + UnreadCount++; + HasNewMessage = true; + } + public void MarkRead() { UnreadCount = 0; diff --git a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs index 9255a8f..3de3f8e 100644 --- a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs +++ b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs @@ -1,4 +1,4 @@ -using OrderSupportTicket.Models; +using OrderSupportTicket.Models; using OrderSupportTicket.Services; using OrderSupportTicket.ViewModels; using System; @@ -39,6 +39,7 @@ public partial class OrderSupportTicketView : UserControl return new TicketClientOptions { UserName = userName, + ConversationMode = configured.ConversationMode, AppIds = appIds, Orders = [ diff --git a/OrderSupportTicket/Views/TicketConversationView.xaml b/OrderSupportTicket/Views/TicketConversationView.xaml index 989ecf0..a264bed 100644 --- a/OrderSupportTicket/Views/TicketConversationView.xaml +++ b/OrderSupportTicket/Views/TicketConversationView.xaml @@ -1,4 +1,4 @@ - + @@ -76,6 +77,22 @@ +