From 8e0cc20649d35142967408603c6ccc5773dd8060 Mon Sep 17 00:00:00 2001 From: chfei Date: Mon, 19 Oct 2026 14:35:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=A5=E5=8D=95Bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 29WxOrderWpf/MainWindow.xaml.cs | 57 +++-- .../EventSourceTicketRealtimeService.cs | 2 +- .../Services/ITicketApiService.cs | 87 ------- .../Services/ITicketRealtimeService.cs | 36 --- .../Services/TicketApiException.cs | 32 +++ .../Services/TicketApiService.cs | 2 +- .../Services/TicketRealtimeMonitor.cs | 34 ++- OrderSupportTicket/Services/TicketService.cs | 229 ++++++++++++++++++ .../Services/WorkOrderCacheService.cs | 149 ++++++++++++ .../Services/WorkOrderUnreadService.cs | 75 ++++++ .../ViewModels/OrderSupportTicketViewModel.cs | 106 +++----- .../Views/OrderSupportTicketView.xaml.cs | 29 +-- 12 files changed, 602 insertions(+), 236 deletions(-) delete mode 100644 OrderSupportTicket/Services/ITicketApiService.cs delete mode 100644 OrderSupportTicket/Services/ITicketRealtimeService.cs create mode 100644 OrderSupportTicket/Services/TicketApiException.cs create mode 100644 OrderSupportTicket/Services/TicketService.cs create mode 100644 OrderSupportTicket/Services/WorkOrderCacheService.cs create mode 100644 OrderSupportTicket/Services/WorkOrderUnreadService.cs diff --git a/29WxOrderWpf/MainWindow.xaml.cs b/29WxOrderWpf/MainWindow.xaml.cs index b15ec5a..3e488aa 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; @@ -59,6 +59,7 @@ namespace _29WxOrderWpf public static MainWindow Instance { get; private set; } private readonly TicketRealtimeMonitor _ticketMonitor = TicketRealtimeMonitor.Current; + private readonly WorkOrderUnreadService _workOrderUnreadService = new WorkOrderUnreadService(); private TicketClientOptions _ticketOptions; private OrderSupportTicketView _orderSupportView; @@ -597,8 +598,10 @@ namespace _29WxOrderWpf { try { - _ticketMonitor.Start(GetTicketOptions().Clone()); + var options = GetTicketOptions().Clone(); + _ticketMonitor.Start(options); UpdateOrderSupportBadge(); + _ = LoadHistoricalTicketUnreadAsync(options); } catch (Exception ex) { @@ -606,6 +609,23 @@ namespace _29WxOrderWpf } } + /// + /// 启动后根据服务端工单列表和历史缓存补齐未读角标。 + /// + private async Task LoadHistoricalTicketUnreadAsync(TicketClientOptions options) + { + try + { + var unreadByTicket = await _workOrderUnreadService + .GetUnreadByTicketAsync(options); + _ticketMonitor.MergeUnread(unreadByTicket); + } + catch (Exception ex) + { + LogSave.Add("TicketUnreadLoadError:" + ex); + } + } + private TicketClientOptions GetTicketOptions() { if (_ticketOptions != null) @@ -614,7 +634,7 @@ namespace _29WxOrderWpf _ticketOptions = new TicketClientOptions { UserName = BaseClass.UserName, - AppIds = GlobalConfig.ShopLoginList.Select(a=>a.ShopId).ToList(), + AppIds = GlobalConfig.ShopLoginList.Select(a => a.ShopId).ToList(), Orders = [] }; @@ -673,10 +693,11 @@ namespace _29WxOrderWpf if (_orderSupportView == null) { _orderSupportView = new OrderSupportTicketView( - new TicketApiService(), - _ticketMonitor.RealtimeService, - GetTicketOptions().Clone(), - ownsRealtime: false); + new TicketService( + new TicketApiService(), + _ticketMonitor.RealtimeService, + ownsRealtime: false), + GetTicketOptions().Clone()); } return _orderSupportView; @@ -722,16 +743,18 @@ namespace _29WxOrderWpf { if (borderOrderSupportUnread == null) return; - - borderOrderSupportUnread.Visibility = unreadCount > 0 - ? Visibility.Visible - : Visibility.Collapsed; - tbOrderSupportUnread.Text = unreadCount > 99 - ? "99+" - : unreadCount.ToString(); - btOrderSupport.ToolTip = unreadCount > 0 - ? $"客户工单({unreadCount} 条未读消息)" - : "客户工单"; + this.Dispatcher.Invoke(() => + { + borderOrderSupportUnread.Visibility = unreadCount > 0 + ? Visibility.Visible + : Visibility.Collapsed; + tbOrderSupportUnread.Text = unreadCount > 99 + ? "99+" + : unreadCount.ToString(); + btOrderSupport.ToolTip = unreadCount > 0 + ? $"客户工单({unreadCount} 条未读消息)" + : "客户工单"; + }); } private void MainWindow_Closed(object sender, EventArgs e) diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs index eb5bcf9..c251ad4 100644 --- a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs +++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs @@ -12,7 +12,7 @@ namespace OrderSupportTicket.Services; /// 基于 LaunchDarkly.EventSource 的 SSE 客户端。 /// net462 可使用;断线自动退避重连,并携带服务端事件 ID 进行补偿。 /// -public sealed class EventSourceTicketRealtimeService : ITicketRealtimeService +public sealed class EventSourceTicketRealtimeService { private const string EventsPath = "/api/open/v2/customer-work-orders/events"; private readonly object _sync = new object(); diff --git a/OrderSupportTicket/Services/ITicketApiService.cs b/OrderSupportTicket/Services/ITicketApiService.cs deleted file mode 100644 index e5c6866..0000000 --- a/OrderSupportTicket/Services/ITicketApiService.cs +++ /dev/null @@ -1,87 +0,0 @@ -using OrderSupportTicket.Models; -using OrderSupportTicket.Models.Api; -using System.Threading.Tasks; - -namespace OrderSupportTicket.Services; - -/// -/// 客户工单 v2 接口。 -/// -public interface ITicketApiService -{ - /// - /// 查询工单摘要列表。 - /// - Task GetWorkOrdersAsync( - TicketClientOptions options, - string wechatOrderId = null, - string status = null, - int limit = 20, - string cursor = null); - - /// - /// 查询指定订单的公开留言板。 - /// - Task GetMessagesAsync( - TicketClientOptions options, - string appId, - string wechatOrderId, - string before = null, - int limit = 50); - - /// - /// 提交问题或创建工单。 - /// - Task CreateWorkOrderAsync( - TicketClientOptions options, - TicketOrderOption order, - string description, - string clientRequestId = null); - - /// - /// 提交客户留言。 - /// - Task SendMessageAsync( - TicketClientOptions options, - WorkOrderSummaryDto workOrder, - string text, - string clientMessageId); - - /// - /// 标记当前订单留言已读。 - /// - Task MarkReadAsync( - TicketClientOptions options, - string appId, - string wechatOrderId, - int throughSequenceNo); -} - -/// -/// 工单接口调用失败时返回的异常。 -/// -public sealed class TicketApiException : System.Exception -{ - /// - /// 创建工单接口异常。 - /// - public TicketApiException( - int statusCode, - string message, - string responseBody = null) - : base(message) - { - StatusCode = statusCode; - ResponseBody = responseBody; - } - - /// - /// 服务端返回的状态码;无法从 GetHtml 响应中识别时为 0。 - /// - public int StatusCode { get; } - - /// - /// 服务端返回的原始响应正文。 - /// - public string ResponseBody { get; } -} diff --git a/OrderSupportTicket/Services/ITicketRealtimeService.cs b/OrderSupportTicket/Services/ITicketRealtimeService.cs deleted file mode 100644 index 2dc950c..0000000 --- a/OrderSupportTicket/Services/ITicketRealtimeService.cs +++ /dev/null @@ -1,36 +0,0 @@ -using OrderSupportTicket.Models; -using System; -using System.Threading.Tasks; - -namespace OrderSupportTicket.Services; - -/// -/// 工单 SSE 实时通知服务。 -/// -public interface ITicketRealtimeService : IDisposable -{ - /// - /// 收到工单变更摘要通知时触发。 - /// - event EventHandler NotificationReceived; - - /// - /// SSE 连接状态变化时触发。 - /// - event EventHandler StateChanged; - - /// - /// 当前 SSE 连接状态。 - /// - TicketRealtimeState State { get; } - - /// - /// 使用指定工单参数建立 SSE 连接。 - /// - void Start(TicketClientOptions options); - - /// - /// 停止 SSE 连接。 - /// - Task StopAsync(); -} diff --git a/OrderSupportTicket/Services/TicketApiException.cs b/OrderSupportTicket/Services/TicketApiException.cs new file mode 100644 index 0000000..e78e14e --- /dev/null +++ b/OrderSupportTicket/Services/TicketApiException.cs @@ -0,0 +1,32 @@ +using System; + +namespace OrderSupportTicket.Services; + +/// +/// 工单接口调用失败时返回的异常。 +/// +public sealed class TicketApiException : Exception +{ + /// + /// 创建工单接口异常。 + /// + public TicketApiException( + int statusCode, + string message, + string responseBody = null) + : base(message) + { + StatusCode = statusCode; + ResponseBody = responseBody; + } + + /// + /// 服务端返回的状态码;无法从 GetHtml 响应中识别时为 0。 + /// + public int StatusCode { get; } + + /// + /// 服务端返回的原始响应正文。 + /// + public string ResponseBody { get; } +} \ No newline at end of file diff --git a/OrderSupportTicket/Services/TicketApiService.cs b/OrderSupportTicket/Services/TicketApiService.cs index 9d78453..65a5230 100644 --- a/OrderSupportTicket/Services/TicketApiService.cs +++ b/OrderSupportTicket/Services/TicketApiService.cs @@ -15,7 +15,7 @@ namespace OrderSupportTicket.Services; /// /// 拍单软件工单 API v2 客户端。对应 CLIENT-TICKET-API-V2 正式订单协议。 /// -public sealed class TicketApiService : ITicketApiService +public sealed class TicketApiService { internal static string ServerUrl = "https://ai.e57.cn"; private const string ApiRoot = "/api/open/v2/customer-work-orders"; diff --git a/OrderSupportTicket/Services/TicketRealtimeMonitor.cs b/OrderSupportTicket/Services/TicketRealtimeMonitor.cs index dee49e5..bf3691d 100644 --- a/OrderSupportTicket/Services/TicketRealtimeMonitor.cs +++ b/OrderSupportTicket/Services/TicketRealtimeMonitor.cs @@ -40,7 +40,7 @@ public sealed class TicketRealtimeMonitor : IDisposable /// /// 供工单页复用的实时服务,避免重复建立 SSE 连接。 /// - public ITicketRealtimeService RealtimeService { get; } + public EventSourceTicketRealtimeService RealtimeService { get; } /// /// 收到新的工单留言通知时触发。 @@ -132,6 +132,38 @@ public sealed class TicketRealtimeMonitor : IDisposable RaiseUnreadCountChanged(); } + /// + /// 合并历史未读数据。与实时事件产生的未读取较大值,避免重复计数。 + /// + /// + /// 键为 "appId|wechatOrderId",值为该工单的未读消息数。 + /// + public void MergeUnread(IDictionary 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(); + } + /// /// 清空全部未读提示。 /// diff --git a/OrderSupportTicket/Services/TicketService.cs b/OrderSupportTicket/Services/TicketService.cs new file mode 100644 index 0000000..e063320 --- /dev/null +++ b/OrderSupportTicket/Services/TicketService.cs @@ -0,0 +1,229 @@ +using OrderSupportTicket.Models; +using OrderSupportTicket.Models.Api; +using System; +using System.Threading.Tasks; + +namespace OrderSupportTicket.Services; + +/// +/// 工单业务服务,聚合工单 API 客户端与 SSE 实时服务。 +/// +public sealed class TicketService : IDisposable +{ + private readonly TicketApiService _api; + private readonly EventSourceTicketRealtimeService _realtime; + private bool _disposed; + + /// + /// 创建使用默认依赖的工单业务服务。 + /// + public TicketService(bool ownsRealtime = true) + : this( + new TicketApiService(), + new EventSourceTicketRealtimeService(), + ownsRealtime) + { + } + + /// + /// 创建工单业务服务。 + /// + /// 工单 API 客户端。 + /// 工单实时通知服务。 + /// + /// 是否由当前实例负责实时连接的停止与释放;共享连接应传入 false。 + /// + public TicketService( + TicketApiService api, + EventSourceTicketRealtimeService realtime, + bool ownsRealtime = true) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + _realtime = realtime ?? throw new ArgumentNullException(nameof(realtime)); + OwnsRealtime = ownsRealtime; + + _realtime.NotificationReceived += OnNotificationReceived; + _realtime.StateChanged += OnStateChanged; + } + + /// + /// 收到工单变更摘要通知时触发。 + /// + public event EventHandler NotificationReceived; + + /// + /// SSE 连接状态变化时触发。 + /// + public event EventHandler StateChanged; + + /// + /// 当前 SSE 连接状态。 + /// + public TicketRealtimeState RealtimeState => _realtime.State; + + /// + /// 是否由当前服务实例管理实时连接的生命周期。 + /// + public bool OwnsRealtime { get; } + + /// + /// 校验工单页运行参数。 + /// + public void ValidateOptions(TicketClientOptions options) + { + ThrowIfDisposed(); + TicketApiService.ValidateOptions(options); + } + + /// + /// 查询工单摘要列表。 + /// + public Task GetWorkOrdersAsync( + TicketClientOptions options, + string wechatOrderId = null, + string status = null, + int limit = 20, + string cursor = null) + { + ThrowIfDisposed(); + return _api.GetWorkOrdersAsync( + options, + wechatOrderId, + status, + limit, + cursor); + } + + /// + /// 查询指定订单的公开留言板。 + /// + public Task GetMessagesAsync( + TicketClientOptions options, + string appId, + string wechatOrderId, + string before = null, + int limit = 50) + { + ThrowIfDisposed(); + return _api.GetMessagesAsync( + options, + appId, + wechatOrderId, + before, + limit); + } + + /// + /// 提交问题或创建工单。 + /// + public Task CreateWorkOrderAsync( + TicketClientOptions options, + TicketOrderOption order, + string description, + string clientRequestId = null) + { + ThrowIfDisposed(); + return _api.CreateWorkOrderAsync( + options, + order, + description, + clientRequestId); + } + + /// + /// 提交客户留言。 + /// + public Task SendMessageAsync( + TicketClientOptions options, + WorkOrderSummaryDto workOrder, + string text, + string clientMessageId) + { + ThrowIfDisposed(); + return _api.SendMessageAsync( + options, + workOrder, + text, + clientMessageId); + } + + /// + /// 标记当前订单留言已读。 + /// + public Task MarkReadAsync( + TicketClientOptions options, + string appId, + string wechatOrderId, + int throughSequenceNo) + { + ThrowIfDisposed(); + return _api.MarkReadAsync( + options, + appId, + wechatOrderId, + throughSequenceNo); + } + + /// + /// 使用指定工单参数建立 SSE 连接。 + /// + public void StartRealtime(TicketClientOptions options) + { + ThrowIfDisposed(); + ValidateOptions(options); + _realtime.Start(options); + } + + /// + /// 停止 SSE 连接;共享连接由宿主管理时,调用方应先检查 。 + /// + public Task StopRealtimeAsync() + { + ThrowIfDisposed(); + return _realtime.StopAsync(); + } + + /// + /// 停止并重新建立 SSE 连接。 + /// + public async Task RestartRealtimeAsync(TicketClientOptions options) + { + ThrowIfDisposed(); + await _realtime.StopAsync(); + ThrowIfDisposed(); + StartRealtime(options); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + _realtime.NotificationReceived -= OnNotificationReceived; + _realtime.StateChanged -= OnStateChanged; + + if (OwnsRealtime) + _realtime.Dispose(); + } + + private void OnNotificationReceived( + object sender, + TicketRealtimeNotificationEventArgs args) + { + NotificationReceived?.Invoke(this, args); + } + + private void OnStateChanged( + object sender, + TicketRealtimeStateChangedEventArgs args) + { + StateChanged?.Invoke(this, args); + } + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(TicketService)); + } +} \ No newline at end of file diff --git a/OrderSupportTicket/Services/WorkOrderCacheService.cs b/OrderSupportTicket/Services/WorkOrderCacheService.cs new file mode 100644 index 0000000..91e32a3 --- /dev/null +++ b/OrderSupportTicket/Services/WorkOrderCacheService.cs @@ -0,0 +1,149 @@ +using OrderSupportTicket.Models; +using OrderSupportTicket.Models.Api; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; + +namespace OrderSupportTicket.Services; + +/// +/// 工单列表本地缓存的读取与未读比较。 +/// +public sealed class WorkOrderCacheService +{ + private const string ConfigName = "ordersupportticket"; + private static readonly object SyncRoot = new object(); + + /// + /// 读取指定账号的工单列表缓存。 + /// + public WorkOrderListCache Load(string userName) + { + if (string.IsNullOrWhiteSpace(userName)) + return null; + + try + { + lock (SyncRoot) + { + var path = NaiBa.Util.Helper.ConfigHelper.GetConfigUserPath(ConfigName); + if (!File.Exists(path)) + return null; + + var cache = NaiBa.Util.Helper.ConfigHelper + .LoadConfig(ConfigName); + if (cache?.Items == null) + return null; + if (!string.Equals(cache.UserName, userName, StringComparison.Ordinal)) + return null; + + return cache; + } + } + catch + { + // 本地缓存损坏或读取失败时按无缓存处理。 + return null; + } + } + + /// + /// 保存工单列表缓存。 + /// + public void Save(WorkOrderListCache cache) + { + if (cache == null) + throw new ArgumentNullException(nameof(cache)); + if (string.IsNullOrWhiteSpace(cache.UserName)) + throw new ArgumentException("缓存缺少登录账号。", nameof(cache)); + + lock (SyncRoot) + { + NaiBa.Util.Helper.ConfigHelper.SaveConfig(ConfigName, cache); + } + } + + /// + /// 根据服务端工单列表和历史缓存计算每个工单的未读消息数。 + /// 返回值的键为 "appId|wechatOrderId"。 + /// + public Dictionary BuildUnreadMap( + IEnumerable workOrders, + WorkOrderListCache cache) + { + var result = new Dictionary(StringComparer.Ordinal); + var savedItems = (cache?.Items ?? new List()) + .Where(item => item != null && !string.IsNullOrWhiteSpace(item.OrderId)) + .GroupBy(item => item.OrderId, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => group.First(), + StringComparer.Ordinal); + var hasSavedCache = cache?.Items != null && cache.Items.Count > 0; + + foreach (var dto in workOrders ?? Enumerable.Empty()) + { + if (dto == null + || string.IsNullOrWhiteSpace(dto.AppId) + || string.IsNullOrWhiteSpace(dto.WechatOrderId)) + { + continue; + } + + var hasNewMessage = !hasSavedCache; + if (hasSavedCache) + { + if (savedItems.TryGetValue(dto.WechatOrderId, out var saved)) + { + hasNewMessage = ParseTimestamp(dto.LastMessageAt) > saved.LastMessageAt; + } + else + { + // 缓存中没有该工单,说明是新增工单。 + hasNewMessage = true; + } + } + + if (!hasNewMessage) + continue; + + // 有历史基线时,时间更新但服务端未读数为 0 也按 1 条提示; + // 首次运行没有基线时只信任服务端未读数,避免把历史工单全部标成未读。 + var unreadCount = hasSavedCache + ? Math.Max(1, dto.UnreadCount) + : dto.UnreadCount; + if (unreadCount <= 0) + continue; + + result[BuildTicketKey(dto.AppId, dto.WechatOrderId)] = unreadCount; + } + + return result; + } + + /// + /// 构建工单实时通知使用的唯一键。 + /// + public static string BuildTicketKey(string appId, string wechatOrderId) + { + return appId + "|" + wechatOrderId; + } + + /// + /// 解析服务端 ISO 8601 时间字符串。 + /// + public static DateTimeOffset ParseTimestamp(string value) + { + if (DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, + out var parsed)) + { + return parsed; + } + return default; + } +} \ No newline at end of file diff --git a/OrderSupportTicket/Services/WorkOrderUnreadService.cs b/OrderSupportTicket/Services/WorkOrderUnreadService.cs new file mode 100644 index 0000000..a9b7549 --- /dev/null +++ b/OrderSupportTicket/Services/WorkOrderUnreadService.cs @@ -0,0 +1,75 @@ +using OrderSupportTicket.Models; +using OrderSupportTicket.Models.Api; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace OrderSupportTicket.Services; + +/// +/// 启动时从服务端工单列表和历史本地缓存计算未读消息。 +/// +public sealed class WorkOrderUnreadService +{ + private const int PageSize = 100; + private const int MaxPages = 20; + private readonly TicketApiService _api; + private readonly WorkOrderCacheService _cache; + + /// + /// 使用默认工单 API 和缓存服务创建未读计算服务。 + /// + public WorkOrderUnreadService() + : this(new TicketApiService(), new WorkOrderCacheService()) + { + } + + /// + /// 创建未读计算服务。 + /// + public WorkOrderUnreadService( + TicketApiService api, + WorkOrderCacheService cache) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + } + + /// + /// 获取每个工单的未读消息数。返回值的键为 "appId|wechatOrderId"。 + /// + public async Task> GetUnreadByTicketAsync( + TicketClientOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + TicketApiService.ValidateOptions(options); + var cache = _cache.Load(options.UserName); + var workOrders = new List(); + string cursor = null; + var page = 0; + + do + { + var response = await _api.GetWorkOrdersAsync( + options, + limit: PageSize, + cursor: cursor).ConfigureAwait(false); + if (response?.Items != null) + workOrders.AddRange(response.Items); + + cursor = response?.NextCursor; + page++; + if (response == null + || !response.HasMore + || string.IsNullOrWhiteSpace(cursor)) + { + break; + } + } + while (page < MaxPages); + + return _cache.BuildUnreadMap(workOrders, cache); + } +} \ No newline at end of file diff --git a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs index 1560a0a..5df20d1 100644 --- a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs +++ b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs @@ -1,6 +1,5 @@ using AllBase.Helper; using AllBase.Model.DBModel; -using NaiBa.Util.Helper; using OrderSupportTicket.Models; using OrderSupportTicket.Models.Api; using OrderSupportTicket.Services; @@ -9,7 +8,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.Globalization; -using System.IO; using System.Linq; using System.Threading.Tasks; using System.Windows.Input; @@ -32,9 +30,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, /// public const string OrderParameterKey = "order"; - private const string SavedListConfigName = "ordersupportticket"; - private readonly ITicketApiService _api; - private readonly ITicketRealtimeService _realtime; + private readonly TicketService _ticketService; + private readonly WorkOrderCacheService _cacheService = new WorkOrderCacheService(); private readonly Dispatcher _dispatcher; private readonly AsyncDelegateCommand _selectTicketCommand; private readonly AsyncDelegateCommand _createTicketCommand; @@ -52,7 +49,6 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, private bool _configured; private bool _initialized; private bool _disposed; - private readonly bool _ownsRealtime; private string _pendingOpenKey; private WorkOrderSummaryViewModel _selectedTicket; private TicketOrderOption _selectedOrder; @@ -82,14 +78,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, private int _selectionVersion; public OrderSupportTicketViewModel( - ITicketApiService api, - ITicketRealtimeService realtime, - TicketClientOptions options = null, - bool ownsRealtime = true) + TicketService ticketService, + TicketClientOptions options = null) { - _api = api ?? throw new ArgumentNullException(nameof(api)); - _realtime = realtime ?? throw new ArgumentNullException(nameof(realtime)); - _ownsRealtime = ownsRealtime; + _ticketService = ticketService + ?? throw new ArgumentNullException(nameof(ticketService)); _dispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; @@ -112,10 +105,10 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, _retryRealtimeCommand = new AsyncDelegateCommand( RestartRealtimeAsync, () => !IsBusy && _configured); - _realtime.NotificationReceived += OnNotificationReceived; - _realtime.StateChanged += OnRealtimeStateChanged; - SseState = TicketRealtimeState.Disconnected; - SseStatusText = "实时推送:未连接"; + _ticketService.NotificationReceived += OnNotificationReceived; + _ticketService.StateChanged += OnRealtimeStateChanged; + SseState = _ticketService.RealtimeState; + SseStatusText = GetRealtimeStatusText(SseState); if (options != null) Configure(options); @@ -636,17 +629,16 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, if (_disposed) throw new ObjectDisposedException(nameof(OrderSupportTicketViewModel)); - if (_ownsRealtime) + if (_ticketService.OwnsRealtime) { - _ = _realtime.StopAsync(); + _ = _ticketService.StopRealtimeAsync(); SseState = TicketRealtimeState.Disconnected; - SseStatusText = GetRealtimeStatusText(SseState); } else { - SseState = _realtime.State; - SseStatusText = GetRealtimeStatusText(SseState); + SseState = _ticketService.RealtimeState; } + SseStatusText = GetRealtimeStatusText(SseState); _configured = false; _initialized = false; _pendingOpenKey = null; @@ -680,7 +672,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, { try { - TicketApiService.ValidateOptions(_options); + _ticketService.ValidateOptions(_options); if (_options.AppIds == null || _options.AppIds.Count == 0) throw new ArgumentException("appIds 至少需要一个 APPID。"); _configured = true; @@ -718,11 +710,11 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, var started = false; // 共享连接由宿主统一维护,页面首次打开时不能因为 _initialized=false 重启连接。 var shouldStartRealtime = - _realtime.State == TicketRealtimeState.Disconnected - || (_ownsRealtime && !_initialized); + _ticketService.RealtimeState == TicketRealtimeState.Disconnected + || (_ticketService.OwnsRealtime && !_initialized); if (shouldStartRealtime) { - _realtime.Start(_options); + _ticketService.StartRealtime(_options); started = true; } @@ -743,8 +735,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, public async Task DeactivateAsync() { SaveWorkOrderList(); - if (_ownsRealtime) - await _realtime.StopAsync(); + if (_ticketService.OwnsRealtime) + await _ticketService.StopRealtimeAsync(); } public void OpenNewTicket(TicketOrderOption order) @@ -834,10 +826,9 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, return; SaveWorkOrderList(); _disposed = true; - _realtime.NotificationReceived -= OnNotificationReceived; - _realtime.StateChanged -= OnRealtimeStateChanged; - if (_ownsRealtime) - _realtime.Dispose(); + _ticketService.NotificationReceived -= OnNotificationReceived; + _ticketService.StateChanged -= OnRealtimeStateChanged; + _ticketService.Dispose(); } private void OpenNewDraft() @@ -938,7 +929,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, ClearNotice(); try { - var result = await _api.CreateWorkOrderAsync( + var result = await _ticketService.CreateWorkOrderAsync( _options, SelectedOrder, description, @@ -1010,7 +1001,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, ClearNotice(); try { - var result = await _api.SendMessageAsync( + var result = await _ticketService.SendMessageAsync( _options, ToSummaryDto(ticket), text, @@ -1068,7 +1059,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, IsBusy = true; try { - var response = await _api.GetMessagesAsync( + var response = await _ticketService.GetMessagesAsync( _options, ticket.AppId, ticket.WechatOrderId, @@ -1096,8 +1087,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, IsBusy = true; try { - await _realtime.StopAsync(); - _realtime.Start(_options); + await _ticketService.RestartRealtimeAsync(_options); await RefreshAllWorkOrdersAsync(loadSelectedMessages: false); if (SelectedTicket != null && !IsDraft) await LoadMessagesAsync(SelectedTicket, replace: true); @@ -1121,7 +1111,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, IsLoadingMessages = true; try { - var response = await _api.GetMessagesAsync( + var response = await _ticketService.GetMessagesAsync( _options, ticket.AppId, ticket.WechatOrderId, @@ -1168,7 +1158,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, var page = 0; do { - var response = await _api.GetWorkOrdersAsync( + var response = await _ticketService.GetWorkOrdersAsync( _options, limit: 100, cursor: cursor); @@ -1240,7 +1230,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, var options = CloneOptions(_options); options.AppIds = new List { appId }; - var response = await _api.GetWorkOrdersAsync( + var response = await _ticketService.GetWorkOrdersAsync( options, wechatOrderId, limit: 1); @@ -1338,7 +1328,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, try { - await _api.MarkReadAsync( + await _ticketService.MarkReadAsync( _options, ticket.AppId, ticket.WechatOrderId, @@ -1671,21 +1661,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, if (string.IsNullOrWhiteSpace(_options?.UserName)) return; - if (!File.Exists(ConfigHelper.GetConfigUserPath(SavedListConfigName))) - return; - - WorkOrderListCache cache; - try - { - cache = ConfigHelper.LoadConfig(SavedListConfigName); - } - catch - { - return; - } - - if (cache?.Items == null - || !string.Equals(cache.UserName, _options.UserName, StringComparison.Ordinal)) + var cache = _cacheService.Load(_options.UserName); + if (cache?.Items == null) return; foreach (var saved in cache.Items) @@ -1735,7 +1712,7 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, try { - ConfigHelper.SaveConfig(SavedListConfigName, cache); + _cacheService.Save(cache); _hasSavedCache = true; } catch @@ -1751,7 +1728,8 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, if (!_savedWorkOrders.TryGetValue(dto.WechatOrderId, out var saved)) return _hasSavedCache; - return ParseTimestamp(dto.LastMessageAt) > saved.LastMessageAt; + return WorkOrderCacheService.ParseTimestamp(dto.LastMessageAt) + > saved.LastMessageAt; } private static WorkOrderListCacheItem ToCacheItem( @@ -1767,18 +1745,6 @@ public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDialogAware, }; } - private static DateTimeOffset ParseTimestamp(string value) - { - if (DateTimeOffset.TryParse( - value, - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal, - out var parsed)) - { - return parsed; - } - return default; - } private static string NewOperationId(string prefix) { diff --git a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs index 4ce2cc3..123b8d4 100644 --- a/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs +++ b/OrderSupportTicket/Views/OrderSupportTicketView.xaml.cs @@ -18,38 +18,21 @@ public partial class OrderSupportTicketView : UserControl private Window _hostWindow; public OrderSupportTicketView() - : this( - new TicketApiService(), - new EventSourceTicketRealtimeService(), - CreateDefaultOptions(), - ownsRealtime: true) - { - } - - public OrderSupportTicketView( - ITicketApiService api, - ITicketRealtimeService realtime, - TicketClientOptions options = null) - : this(api, realtime, options, ownsRealtime: true) + : this(new TicketService(), CreateDefaultOptions()) { } /// - /// 创建工单页。ownsRealtime 为 false 时复用宿主提供的共享 SSE 连接, - /// 页面关闭不会停止该连接。 + /// 使用工单业务服务创建工单页。 /// public OrderSupportTicketView( - ITicketApiService api, - ITicketRealtimeService realtime, - TicketClientOptions options, - bool ownsRealtime) + TicketService ticketService, + TicketClientOptions options = null) { InitializeComponent(); ViewModel = new OrderSupportTicketViewModel( - api, - realtime, - options ?? CreateDefaultOptions(), - ownsRealtime); + ticketService, + options ?? CreateDefaultOptions()); DataContext = ViewModel; Loaded += OnLoaded; Unloaded += OnUnloaded;