diff --git a/29WxOrder.sln b/29WxOrder.sln index 56fb0f0..98ea212 100644 --- a/29WxOrder.sln +++ b/29WxOrder.sln @@ -25,6 +25,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".test", ".test", "{14261B9B EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WXOrder.Test", "test\WXOrder.Test\WXOrder.Test.csproj", "{3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderSupportTicket", "OrderSupportTicket\OrderSupportTicket.csproj", "{22D31944-2087-437A-B374-C0874E9EC53A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -117,6 +119,22 @@ Global {3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}.Release|x64.Build.0 = Release|Any CPU {3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}.Release|x86.ActiveCfg = Release|Any CPU {3790EC79-EA1D-4660-AB0D-EA0DE4FF77B7}.Release|x86.Build.0 = Release|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|x64.ActiveCfg = Debug|x64 + {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|x64.Build.0 = Debug|x64 + {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|x86.ActiveCfg = Debug|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Debug|x86.Build.0 = Debug|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Release|Any CPU.Build.0 = Release|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Release|x64.ActiveCfg = Release|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Release|x64.Build.0 = Release|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Release|x86.ActiveCfg = Release|Any CPU + {22D31944-2087-437A-B374-C0874E9EC53A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/OrderSupportTicket/Behaviors/ListBoxInteractionBehavior.cs b/OrderSupportTicket/Behaviors/ListBoxInteractionBehavior.cs new file mode 100644 index 0000000..091ce91 --- /dev/null +++ b/OrderSupportTicket/Behaviors/ListBoxInteractionBehavior.cs @@ -0,0 +1,121 @@ +using System; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; + +namespace OrderSupportTicket.Behaviors; + +/// +/// 仅承载视图交互:列表点击命令、消息列表滚动到底部。 +/// +public static class ListBoxInteractionBehavior +{ + public static readonly DependencyProperty SelectedItemCommandProperty = + DependencyProperty.RegisterAttached( + "SelectedItemCommand", + typeof(ICommand), + typeof(ListBoxInteractionBehavior), + new PropertyMetadata(null, OnSelectedItemCommandChanged)); + + public static readonly DependencyProperty ScrollVersionProperty = + DependencyProperty.RegisterAttached( + "ScrollVersion", + typeof(int), + typeof(ListBoxInteractionBehavior), + new PropertyMetadata(0, OnScrollVersionChanged)); + + public static void SetSelectedItemCommand(DependencyObject element, ICommand value) + { + element.SetValue(SelectedItemCommandProperty, value); + } + + public static ICommand GetSelectedItemCommand(DependencyObject element) + { + return (ICommand)element.GetValue(SelectedItemCommandProperty); + } + + public static void SetScrollVersion(DependencyObject element, int value) + { + element.SetValue(ScrollVersionProperty, value); + } + + public static int GetScrollVersion(DependencyObject element) + { + return (int)element.GetValue(ScrollVersionProperty); + } + + private static void OnSelectedItemCommandChanged( + DependencyObject d, + DependencyPropertyChangedEventArgs e) + { + if (!(d is ListBox listBox)) + return; + + listBox.PreviewMouseLeftButtonUp -= OnPreviewMouseLeftButtonUp; + listBox.PreviewKeyUp -= OnPreviewKeyUp; + if (e.NewValue is ICommand) + { + listBox.PreviewMouseLeftButtonUp += OnPreviewMouseLeftButtonUp; + listBox.PreviewKeyUp += OnPreviewKeyUp; + } + } + + private static void OnPreviewMouseLeftButtonUp( + object sender, + MouseButtonEventArgs e) + { + ExecuteForSelectedItem(sender as ListBox); + } + + private static void OnPreviewKeyUp(object sender, KeyEventArgs e) + { + if (e.Key != Key.Enter && e.Key != Key.Space) + return; + ExecuteForSelectedItem(sender as ListBox); + } + + private static void ExecuteForSelectedItem(ListBox listBox) + { + if (listBox == null || listBox.SelectedItem == null) + return; + var command = GetSelectedItemCommand(listBox); + if (command != null && command.CanExecute(listBox.SelectedItem)) + command.Execute(listBox.SelectedItem); + } + + private static void OnScrollVersionChanged( + DependencyObject d, + DependencyPropertyChangedEventArgs e) + { + if (!(d is ListBox listBox)) + return; + + listBox.Dispatcher.BeginInvoke( + new Action(() => + { + var scrollViewer = FindVisualChild(listBox); + if (scrollViewer != null) + scrollViewer.ScrollToEnd(); + }), + DispatcherPriority.Background); + } + + private static T FindVisualChild(DependencyObject parent) + where T : DependencyObject + { + if (parent == null) + return null; + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is T match) + return match; + var nested = FindVisualChild(child); + if (nested != null) + return nested; + } + return null; + } +} diff --git a/OrderSupportTicket/Models/Api/WorkOrderContracts.cs b/OrderSupportTicket/Models/Api/WorkOrderContracts.cs new file mode 100644 index 0000000..3fc6678 --- /dev/null +++ b/OrderSupportTicket/Models/Api/WorkOrderContracts.cs @@ -0,0 +1,549 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace OrderSupportTicket.Models.Api; + +/// +/// 创建工单请求。 +/// +[DataContract] +internal sealed class CreateWorkOrderRequest +{ + /// + /// 拍单账号。 + /// + [DataMember(Name = "purchaseAccount")] + public string PurchaseAccount { get; set; } + + /// + /// 订单所属店铺 APPID。 + /// + [DataMember(Name = "appId")] + public string AppId { get; set; } + + /// + /// 微信订单号,保持为数字字符串。 + /// + [DataMember(Name = "wechatOrderId")] + public string WechatOrderId { get; set; } + + /// + /// 首条问题描述,长度 1~2000 字。 + /// + [DataMember(Name = "description")] + public string Description { get; set; } + + /// + /// 可选的创建幂等编号。 + /// + [DataMember(Name = "clientRequestId", EmitDefaultValue = false)] + public string ClientRequestId { get; set; } +} + +/// +/// 工单摘要列表请求。 +/// +[DataContract] +internal sealed class ListWorkOrdersRequest +{ + /// + /// 拍单账号。 + /// + [DataMember(Name = "purchaseAccount")] + public string PurchaseAccount { get; set; } + + /// + /// 要查询的店铺 APPID 数组。 + /// + [DataMember(Name = "appIds")] + public IList AppIds { get; set; } + + /// + /// 可选的微信订单号精确筛选条件。 + /// + [DataMember(Name = "wechatOrderId", EmitDefaultValue = false)] + public string WechatOrderId { get; set; } + + /// + /// 可选的工单状态筛选条件。 + /// + [DataMember(Name = "status", EmitDefaultValue = false)] + public string Status { get; set; } + + /// + /// 本页最大返回数量,范围 1~100。 + /// + [DataMember(Name = "limit")] + public int Limit { get; set; } + + /// + /// 下一页游标,使用服务端返回的原始值。 + /// + [DataMember(Name = "cursor", EmitDefaultValue = false)] + public string Cursor { get; set; } +} + +/// +/// 查询订单留言板请求。 +/// +[DataContract] +internal sealed class GetMessagesRequest +{ + /// + /// 拍单账号。 + /// + [DataMember(Name = "purchaseAccount")] + public string PurchaseAccount { get; set; } + /// + /// 微信订单号,保持为数字字符串。 + /// + [DataMember(Name = "wechatOrderId")] + public string WechatOrderId { get; set; } + /// + /// 订单所属店铺 APPID。 + /// + [DataMember(Name = "appId")] + public string AppId { get; set; } + + /// + /// 本页最大返回数量,范围 1~100。 + /// + [DataMember(Name = "limit")] + public int Limit { get; set; } + + /// + /// 加载更早留言时使用的 before 游标。 + /// + [DataMember(Name = "before", EmitDefaultValue = false)] + public string Before { get; set; } +} + +/// +/// 提交客户留言请求。 +/// +[DataContract] +internal sealed class SendMessageRequest +{ + /// + /// 拍单账号。 + /// + [DataMember(Name = "purchaseAccount")] + public string PurchaseAccount { get; set; } + + /// + /// 订单所属店铺 APPID。 + /// + [DataMember(Name = "appId")] + public string AppId { get; set; } + + /// + /// 留言幂等编号。 + /// + [DataMember(Name = "clientMessageId")] + public string ClientMessageId { get; set; } + + /// + /// 留言正文,长度 1~4000 字。 + /// + [DataMember(Name = "text")] + public string Text { get; set; } +} + +/// +/// 标记留言已读请求。 +/// +[DataContract] +internal sealed class MarkReadRequest +{ + /// + /// 拍单账号。 + /// + [DataMember(Name = "purchaseAccount")] + public string PurchaseAccount { get; set; } + + /// + /// 订单所属店铺 APPID。 + /// + [DataMember(Name = "appId")] + public string AppId { get; set; } + + /// + /// 已读到的最大留言序号。 + /// + [DataMember(Name = "throughSequenceNo")] + public int ThroughSequenceNo { get; set; } +} + +/// +/// 工单摘要分页响应。 +/// +[DataContract] +public sealed class WorkOrderListResponse +{ + /// + /// 请求是否成功。 + /// + [DataMember(Name = "success")] + public bool Success { get; set; } + + /// + /// 当前页工单摘要。 + /// + [DataMember(Name = "items")] + public List Items { get; set; } + + /// + /// 是否还有下一页。 + /// + [DataMember(Name = "hasMore")] + public bool HasMore { get; set; } + + /// + /// 下一页游标。 + /// + [DataMember(Name = "nextCursor")] + public string NextCursor { get; set; } + + /// + /// 接口返回的身份校验标记,当前恒为 false。 + /// + [DataMember(Name = "identityVerified")] + public bool IdentityVerified { get; set; } +} + +/// +/// 单个订单的工单摘要。 +/// +[DataContract] +public sealed class WorkOrderSummaryDto +{ + /// + /// 订单所属店铺 APPID。 + /// + [DataMember(Name = "appId")] + public string AppId { get; set; } + + /// + /// 微信订单号,保持为数字字符串。 + /// + [DataMember(Name = "wechatOrderId")] + public string WechatOrderId { get; set; } + + /// + /// 工单状态编码。 + /// + [DataMember(Name = "status")] + public string Status { get; set; } + + /// + /// 工单状态中文文本。 + /// + [DataMember(Name = "statusText")] + public string StatusText { get; set; } + + /// + /// 工单创建时间,由接口以 ISO 8601 字符串传输。 + /// + [DataMember(Name = "createdAt")] + public string CreatedAt { get; set; } + + /// + /// 工单更新时间,由接口以 ISO 8601 字符串传输。 + /// + [DataMember(Name = "updatedAt")] + public string UpdatedAt { get; set; } + + /// + /// 最近一条公开留言时间,由接口以 ISO 8601 字符串传输。 + /// + [DataMember(Name = "lastMessageAt")] + public string LastMessageAt { get; set; } + + /// + /// 最近一次客服正式回复时间;从未回复时为 null。 + /// + [DataMember(Name = "lastReplyAt")] + public string LastReplyAt { get; set; } + + /// + /// 最近一条公开留言的发送方类型。 + /// + [DataMember(Name = "lastSenderType")] + public string LastSenderType { get; set; } + + /// + /// 未读留言数量。 + /// + [DataMember(Name = "unreadCount")] + public int UnreadCount { get; set; } + + /// + /// 工单版本号,按接口返回的字符串原样保存。 + /// + [DataMember(Name = "version")] + public string Version { get; set; } +} + +/// +/// 订单留言板响应。 +/// +[DataContract] +public sealed class WorkOrderMessagesResponse +{ + /// + /// 请求是否成功。 + /// + [DataMember(Name = "success")] + public bool Success { get; set; } + + /// + /// 订单所属店铺 APPID。 + /// + [DataMember(Name = "appId")] + public string AppId { get; set; } + + /// + /// 微信订单号。 + /// + [DataMember(Name = "wechatOrderId")] + public string WechatOrderId { get; set; } + + /// + /// 当前页公开留言,按 sequenceNo 升序排列。 + /// + [DataMember(Name = "items")] + public List Items { get; set; } + + /// + /// 是否还有更早的留言。 + /// + [DataMember(Name = "hasMore")] + public bool HasMore { get; set; } + + /// + /// 加载更早留言时使用的 before 游标。 + /// + [DataMember(Name = "nextBefore")] + public string NextBefore { get; set; } + + /// + /// 接口返回的身份校验标记,当前恒为 false。 + /// + [DataMember(Name = "identityVerified")] + public bool IdentityVerified { get; set; } +} + +/// +/// 单条公开留言。 +/// +[DataContract] +public sealed class TicketMessageDto +{ + /// + /// 消息唯一 ID,用于去重。 + /// + [DataMember(Name = "messageId")] + public string MessageId { get; set; } + + /// + /// 工单内连续递增的消息序号。 + /// + [DataMember(Name = "sequenceNo")] + public int SequenceNo { get; set; } + + /// + /// 发送方类型,例如 customer。 + /// + [DataMember(Name = "senderType")] + public string SenderType { get; set; } + + /// + /// 发送方显示名称。 + /// + [DataMember(Name = "senderName")] + public string SenderName { get; set; } + + /// + /// 留言正文;消息撤回后为空字符串。 + /// + [DataMember(Name = "text")] + public string Text { get; set; } + + /// + /// 消息是否已撤回。 + /// + [DataMember(Name = "recalled")] + public bool Recalled { get; set; } + + /// + /// 消息发送时间,由接口以 ISO 8601 字符串传输。 + /// + [DataMember(Name = "createdAt")] + public string CreatedAt { get; set; } +} + +/// +/// 创建工单响应。 +/// +[DataContract] +public sealed class CreateWorkOrderResponse +{ + /// + /// 请求是否成功。 + /// + [DataMember(Name = "success")] + public bool Success { get; set; } + + /// + /// 创建结果,取值 created、existing 或 reopened。 + /// + [DataMember(Name = "operation")] + public string Operation { get; set; } + + /// + /// 订单所属店铺 APPID。 + /// + [DataMember(Name = "appId")] + public string AppId { get; set; } + + /// + /// 微信订单号。 + /// + [DataMember(Name = "wechatOrderId")] + public string WechatOrderId { get; set; } + + /// + /// 操作后的工单状态。 + /// + [DataMember(Name = "status")] + public string Status { get; set; } + + /// + /// 创建或重开时追加的首条消息 ID。 + /// + [DataMember(Name = "messageId")] + public string MessageId { get; set; } + + /// + /// 操作后的工单版本号字符串。 + /// + [DataMember(Name = "version")] + public string Version { get; set; } + + /// + /// 是否为幂等编号对应的历史回执。 + /// + [DataMember(Name = "replayed")] + public bool Replayed { get; set; } + + /// + /// 接口返回的身份校验标记,当前恒为 false。 + /// + [DataMember(Name = "identityVerified")] + public bool IdentityVerified { get; set; } +} + +/// +/// 提交客户留言响应。 +/// +[DataContract] +public sealed class SendMessageResponse +{ + /// + /// 请求是否成功。 + /// + [DataMember(Name = "success")] + public bool Success { get; set; } + + /// + /// 订单所属店铺 APPID。 + /// + [DataMember(Name = "appId")] + public string AppId { get; set; } + + /// + /// 微信订单号。 + /// + [DataMember(Name = "wechatOrderId")] + public string WechatOrderId { get; set; } + + /// + /// 服务端正式生成的消息对象。 + /// + [DataMember(Name = "message")] + public TicketMessageDto Message { get; set; } + + /// + /// 留言后的工单状态。 + /// + [DataMember(Name = "status")] + public string Status { get; set; } + + /// + /// 留言后的工单版本号字符串。 + /// + [DataMember(Name = "version")] + public string Version { get; set; } + + /// + /// 本次留言是否重新开启了已完结工单。 + /// + [DataMember(Name = "reopened")] + public bool Reopened { get; set; } + + /// + /// 是否为幂等编号对应的历史回执。 + /// + [DataMember(Name = "replayed")] + public bool Replayed { get; set; } + + /// + /// 接口返回的身份校验标记,当前恒为 false。 + /// + [DataMember(Name = "identityVerified")] + public bool IdentityVerified { get; set; } +} + +/// +/// 通用成功状态响应,仅用于在解析具体业务对象前检查顶层 success。 +/// +[DataContract] +internal sealed class ApiSuccessResponse +{ + /// + /// 请求是否成功。 + /// + [DataMember(Name = "success")] + public bool Success { get; set; } +} + +/// +/// 工单接口错误响应。 +/// +[DataContract] +internal sealed class ApiErrorResponse +{ + /// + /// 请求是否成功,错误响应通常为 false。 + /// + [DataMember(Name = "success")] + public bool Success { get; set; } + + /// + /// 可展示给用户的错误说明。 + /// + [DataMember(Name = "message")] + public string Message { get; set; } + + /// + /// 兼容旧接口的错误说明字段。 + /// + [DataMember(Name = "error")] + public string Error { get; set; } + + /// + /// 服务端错误编码。 + /// + [DataMember(Name = "code")] + public string Code { get; set; } +} diff --git a/OrderSupportTicket/Models/TicketClientOptions.cs b/OrderSupportTicket/Models/TicketClientOptions.cs new file mode 100644 index 0000000..19e3b8b --- /dev/null +++ b/OrderSupportTicket/Models/TicketClientOptions.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Configuration; +using System.Globalization; +using System.Linq; + +namespace OrderSupportTicket.Models; + +/// +/// 工单页运行参数。APPID 与拍单账号由宿主程序注入,页面内不硬编码账号。 +/// +public sealed class TicketClientOptions +{ + public TicketClientOptions() + { + AppIds = new List(); + Orders = new List(); + } + + /// + /// 当前拍单账号,对应接口字段 purchaseAccount。 + /// + public string UserName { get; set; } + + /// + /// 当前账号需要订阅和查询的店铺 APPID 集合,最多 50 个。 + /// + public IList AppIds { get; set; } + + /// + /// 可关联到新工单的本地订单集合。 + /// + public IList Orders { get; set; } + + 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() + }; + } +} + +/// +/// 新建工单时需要在本地订单列表中关联的订单。 +/// 微信订单号始终是字符串,禁止转换为 Number。 +/// +public sealed class TicketOrderOption +{ + /// + /// 订单所属店铺的 APPID,格式为 wx 加 16 位小写十六进制字符。 + /// + public string AppId { get; set; } + + /// + /// 微信订单号。接口要求保持为 10~64 位数字字符串,禁止转换为 Number。 + /// + public string OrderId { get; set; } + + /// + /// 商品名称,用于订单筛选和页面展示。 + /// + public string GoodsName { get; set; } + + /// + /// 订单金额,单位元,使用 decimal 保存原始金额,不在模型中保存格式化文本。 + /// + public decimal Amount { get; set; } + + /// + /// 订单下单时间。 + /// + public DateTime OrderedAt { get; set; } + + /// + /// 下拉列表显示文本,订单号在前、商品名在后,避免长商品名遮挡订单号。 + /// + public string DisplayText + { + get + { + var product = string.IsNullOrWhiteSpace(GoodsName) + ? "未命名商品" + : GoodsName; + return $"{OrderId} · {product}"; + } + } + + /// + /// 订单详情展示文本,包含商品名、金额和下单时间。 + /// + public string ProductDescription + { + get + { + var product = string.IsNullOrWhiteSpace(GoodsName) + ? "未提供商品名称" + : GoodsName; + var amount = "¥ " + Amount.ToString("N2", CultureInfo.GetCultureInfo("zh-CN")); + var orderedAt = OrderedAt == default + ? "下单时间未知" + : OrderedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture); + return $"商品:{product}\n金额:{amount} 下单时间:{orderedAt}"; + } + } + + /// + /// 当前工单的唯一订单键,由 APPID 和微信订单号共同组成。 + /// + public string OrderKey => $"{AppId}|{OrderId}"; +} diff --git a/OrderSupportTicket/Models/TicketRealtimeNotification.cs b/OrderSupportTicket/Models/TicketRealtimeNotification.cs new file mode 100644 index 0000000..f9b19e2 --- /dev/null +++ b/OrderSupportTicket/Models/TicketRealtimeNotification.cs @@ -0,0 +1,133 @@ +using System.Runtime.Serialization; + +namespace OrderSupportTicket.Models; + +/// +/// SSE 实时通知。通知只包含摘要定位信息,收到后需要重新查询对应工单。 +/// +[DataContract] +public sealed class TicketRealtimeNotification +{ + /// + /// 服务端事件 ID,用于至少一次投递时的去重。 + /// + [DataMember(Name = "eventId")] + public string EventId { get; set; } + + /// + /// 事件类型,例如 relay.ticket.message_added 或 resync_required。 + /// + [DataMember(Name = "type")] + public string Type { get; set; } + + /// + /// 发生变更的店铺 APPID。 + /// + [DataMember(Name = "appId")] + public string AppId { get; set; } + + /// + /// 发生变更的微信订单号,保持为数字字符串。 + /// + [DataMember(Name = "wechatOrderId")] + public string WechatOrderId { get; set; } + + /// + /// 新增消息的消息 ID。 + /// + [DataMember(Name = "messageId")] + public string MessageId { get; set; } + + /// + /// 工单版本号,按服务端返回的字符串原样保存。 + /// + [DataMember(Name = "version")] + public string Version { get; set; } + + /// + /// 事件发生时间,保留服务端 ISO 8601 字符串。 + /// + [DataMember(Name = "occurredAt")] + public string OccurredAt { get; set; } + + /// + /// 是否要求客户端执行全量重新同步。 + /// + public bool IsResyncRequired => + string.Equals(Type, "resync_required", System.StringComparison.OrdinalIgnoreCase); +} + +/// +/// SSE 连接状态。 +/// +public enum TicketRealtimeState +{ + /// + /// 未连接。 + /// + Disconnected, + + /// + /// 正在建立连接。 + /// + Connecting, + + /// + /// 已连接并接收事件。 + /// + Connected, + + /// + /// 连接中断,正在自动重连。 + /// + Reconnecting +} + +/// +/// SSE 通知事件参数。 +/// +public sealed class TicketRealtimeNotificationEventArgs : System.EventArgs +{ + /// + /// 创建 SSE 通知事件参数。 + /// + /// 收到的 SSE 通知。 + public TicketRealtimeNotificationEventArgs(TicketRealtimeNotification notification) + { + Notification = notification; + } + + /// + /// 收到的 SSE 通知。 + /// + public TicketRealtimeNotification Notification { get; } +} + +/// +/// SSE 连接状态变化事件参数。 +/// +public sealed class TicketRealtimeStateChangedEventArgs : System.EventArgs +{ + /// + /// 创建 SSE 连接状态变化事件参数。 + /// + /// 变化后的连接状态。 + /// 可选的连接状态说明。 + public TicketRealtimeStateChangedEventArgs( + TicketRealtimeState state, + string message = null) + { + State = state; + Message = message; + } + + /// + /// 变化后的连接状态。 + /// + public TicketRealtimeState State { get; } + + /// + /// 连接状态说明。 + /// + public string Message { get; } +} diff --git a/OrderSupportTicket/Models/WorkOrderListCache.cs b/OrderSupportTicket/Models/WorkOrderListCache.cs new file mode 100644 index 0000000..b615b64 --- /dev/null +++ b/OrderSupportTicket/Models/WorkOrderListCache.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace OrderSupportTicket.Models; + +/// +/// 工单列表本地缓存,仅用于比较服务器列表是否有新数据。 +/// +public sealed class WorkOrderListCache +{ + /// + /// 缓存对应的登录账号。 + /// + public string UserName { get; set; } + + /// + /// 用于判断新消息的订单时间缓存项。 + /// + public List Items { get; set; } = + new List(); +} + +/// +/// 单个订单的新消息比较信息。 +/// +public sealed class WorkOrderListCacheItem +{ + /// + /// 微信订单号。 + /// + public string OrderId { get; set; } + + /// + /// 最后一条公开消息时间,用于判断是否有任意新消息。 + /// + public DateTimeOffset LastMessageAt { get; set; } +} diff --git a/OrderSupportTicket/OrderSupportTicket.csproj b/OrderSupportTicket/OrderSupportTicket.csproj new file mode 100644 index 0000000..ce49198 --- /dev/null +++ b/OrderSupportTicket/OrderSupportTicket.csproj @@ -0,0 +1,32 @@ + + + + net462 + 12.0 + true + true + disable + x64 + AnyCPU;x64 + + + + + + + + + + + + + + + + + ..\DllReference\NaiBa.Util.dll + true + + + + diff --git a/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs new file mode 100644 index 0000000..62eccf1 --- /dev/null +++ b/OrderSupportTicket/Services/EventSourceTicketRealtimeService.cs @@ -0,0 +1,206 @@ +using LaunchDarkly.EventSource; +using OrderSupportTicket.Models; +using System; +using System.IO; +using System.Linq; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Threading.Tasks; + +namespace OrderSupportTicket.Services; + +/// +/// 基于 LaunchDarkly.EventSource 的 SSE 客户端。 +/// net462 可使用;断线自动退避重连,并携带服务端事件 ID 进行补偿。 +/// +public sealed class EventSourceTicketRealtimeService : ITicketRealtimeService +{ + 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 NotificationReceived; + + public event EventHandler StateChanged; + + /// + /// 当前 SSE 连接状态。 + /// + 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)); + + 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; + + lock (_sync) + { + _running = true; + _eventSource = source; + } + + SetState(TicketRealtimeState.Connecting, "连接中…"); + _ = RunAsync(source); + } + + public Task StopAsync() + { + StopCore(); + SetState(TicketRealtimeState.Disconnected, "已断开"); + return Task.FromResult(0); + } + + public void Dispose() + { + if (_disposed) + return; + StopCore(); + _disposed = true; + } + + private async Task RunAsync(EventSource source) + { + try + { + await source.StartAsync().ConfigureAwait(false); + 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 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 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; + + TicketRealtimeNotification notification; + try + { + var serializer = new DataContractJsonSerializer( + typeof(TicketRealtimeNotification)); + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(data))) + { + notification = (TicketRealtimeNotification)serializer.ReadObject(stream); + } + } + catch (SerializationException) + { + return; + } + + 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)); + } + + 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(); + } + + 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); + + return new Uri( + TicketApiService.ServerUrl + + EventsPath + + "?" + + string.Join("&", query), + UriKind.Absolute); + } +} diff --git a/OrderSupportTicket/Services/ITicketApiService.cs b/OrderSupportTicket/Services/ITicketApiService.cs new file mode 100644 index 0000000..e5c6866 --- /dev/null +++ b/OrderSupportTicket/Services/ITicketApiService.cs @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..2dc950c --- /dev/null +++ b/OrderSupportTicket/Services/ITicketRealtimeService.cs @@ -0,0 +1,36 @@ +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/TicketApiService.cs b/OrderSupportTicket/Services/TicketApiService.cs new file mode 100644 index 0000000..b04533a --- /dev/null +++ b/OrderSupportTicket/Services/TicketApiService.cs @@ -0,0 +1,432 @@ +using AllBase; +using OrderSupportTicket.Models; +using OrderSupportTicket.Models.Api; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace OrderSupportTicket.Services; + +/// +/// 拍单软件工单 API v2 客户端。对应 CLIENT-TICKET-API-V2 正式订单协议。 +/// +public sealed class TicketApiService : ITicketApiService +{ + internal const string ServerUrl = "https://ai.e57.cn"; + private const string ApiRoot = "/api/open/v2/customer-work-orders"; + private static readonly Regex AppIdRegex = + new Regex(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex OrderIdRegex = + new Regex(@"^[0-9]{10,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex PurchaseAccountRegex = + new Regex(@"^[\p{L}\p{N}_.@-]{2,120}$", RegexOptions.Compiled); + private static readonly Regex OperationIdRegex = + new Regex(@"^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// 获取工单列表 + /// + /// + /// + /// + /// + /// + /// + /// + public async Task GetWorkOrdersAsync( + TicketClientOptions options, + string wechatOrderId = null, + string status = null, + int limit = 20, + string cursor = null) + { + ValidateOptions(options); + if (!string.IsNullOrWhiteSpace(wechatOrderId)) + ValidateWechatOrderId(wechatOrderId); + if (limit < 1 || limit > 100) + throw new ArgumentOutOfRangeException(nameof(limit), "limit 必须为 1~100。"); + + var request = new ListWorkOrdersRequest + { + PurchaseAccount = options.UserName, + AppIds = NormalizeAppIds(options.AppIds), + WechatOrderId = string.IsNullOrWhiteSpace(wechatOrderId) ? null : wechatOrderId, + Status = string.IsNullOrWhiteSpace(status) ? null : status, + Limit = limit, + Cursor = string.IsNullOrWhiteSpace(cursor) ? null : cursor + }; + + return await PostAsync( + ApiRoot + "/list", + request).ConfigureAwait(false); + } + /// + /// 获取工单消息 + /// + /// + /// + /// + /// + /// + /// + /// + public async Task GetMessagesAsync( + TicketClientOptions options, + string appId, + string wechatOrderId, + string before = null, + int limit = 50) + { + ValidateOptions(options); + ValidateAppId(appId); + ValidateWechatOrderId(wechatOrderId); + if (limit < 1 || limit > 100) + throw new ArgumentOutOfRangeException(nameof(limit), "limit 必须为 1~100。"); + + var request = new GetMessagesRequest + { + PurchaseAccount = options.UserName, + AppId = appId, + WechatOrderId = wechatOrderId, + Limit = limit, + Before = string.IsNullOrWhiteSpace(before) ? null : before + }; + + var path = ApiRoot + + "/messages"; + return await PostAsync(path, request) + .ConfigureAwait(false); + } + /// + /// 创建工单 + /// + /// + /// + /// + /// + /// + public async Task CreateWorkOrderAsync( + TicketClientOptions options, + TicketOrderOption order, + string description, + string clientRequestId = null) + { + ValidateOptions(options); + ValidateOrder(order); + ValidateDescription(description); + ValidateOperationId(clientRequestId, "clientRequestId", required: false); + + var request = new CreateWorkOrderRequest + { + PurchaseAccount = options.UserName, + AppId = order.AppId, + WechatOrderId = order.OrderId, + Description = description, + ClientRequestId = string.IsNullOrWhiteSpace(clientRequestId) + ? null + : clientRequestId + }; + return await PostAsync( + ApiRoot, + request); + } + /// + /// 发送工单消息 + /// + /// + /// + /// + /// + /// + /// + public async Task SendMessageAsync( + TicketClientOptions options, + WorkOrderSummaryDto workOrder, + string text, + string clientMessageId) + { + ValidateOptions(options); + if (workOrder == null) + throw new ArgumentNullException(nameof(workOrder)); + ValidateAppId(workOrder.AppId); + ValidateWechatOrderId(workOrder.WechatOrderId); + ValidateMessageText(text); + ValidateOperationId(clientMessageId, "clientMessageId", required: true); + + var request = new SendMessageRequest + { + PurchaseAccount = options.UserName, + AppId = workOrder.AppId, + ClientMessageId = clientMessageId, + Text = text + }; + var path = ApiRoot + + "/orders/" + + Uri.EscapeDataString(workOrder.WechatOrderId) + + "/messages"; + return await PostAsync(path, request) + .ConfigureAwait(false); + } + + public async Task MarkReadAsync( + TicketClientOptions options, + string appId, + string wechatOrderId, + int throughSequenceNo) + { + ValidateOptions(options); + ValidateAppId(appId); + ValidateWechatOrderId(wechatOrderId); + if (throughSequenceNo < 1) + return; + + var request = new MarkReadRequest + { + PurchaseAccount = options.UserName, + AppId = appId, + ThroughSequenceNo = throughSequenceNo + }; + var path = ApiRoot + + "/orders/" + + Uri.EscapeDataString(wechatOrderId) + + "/read"; + var html = await PostBodyAsync(path, request).ConfigureAwait(false); + EnsureSuccessBody(html); + } + + public static void ValidateOptions(TicketClientOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + if (string.IsNullOrWhiteSpace(options.UserName) + || !PurchaseAccountRegex.IsMatch(options.UserName)) + { + throw new ArgumentException( + "purchaseAccount 必填,且只能为 2~120 位中英文、数字或 _.@-。", + nameof(options)); + } + NormalizeAppIds(options.AppIds); + } + + public static void ValidateAppId(string appId) + { + if (string.IsNullOrWhiteSpace(appId) || !AppIdRegex.IsMatch(appId)) + throw new ArgumentException( + "appId 必须为 wx 加 16 位小写十六进制字符。", + nameof(appId)); + } + + public static void ValidateWechatOrderId(string wechatOrderId) + { + if (string.IsNullOrWhiteSpace(wechatOrderId) + || !OrderIdRegex.IsMatch(wechatOrderId)) + { + throw new ArgumentException( + "wechatOrderId 必须保持为 10~64 位数字字符串。", + nameof(wechatOrderId)); + } + } + + public static void ValidateOrder(TicketOrderOption order) + { + if (order == null) + throw new ArgumentNullException(nameof(order)); + ValidateAppId(order.AppId); + ValidateWechatOrderId(order.OrderId); + } + + private static async Task PostAsync( + string path, + object request) + { + var html = await PostBodyAsync(path, request).ConfigureAwait(false); + return DeserializeResponse(html); + } + + private static async Task PostBodyAsync( + string path, + object request) + { + var url = BuildUri(path); + // 请求 DTO 使用 DataMember 驼峰字段,先序列化后走 GetHtml 的字符串重载。 + var json = Serialize(request); + var html = await GetHtml.PostJsonAsync(url, json).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(html)) + throw RequestFailed("工单接口未返回数据。"); + return html; + } + + private static void EnsureSuccessBody(string html) + { + if (string.IsNullOrWhiteSpace(html)) + throw RequestFailed("工单接口未返回数据。"); + + ApiSuccessResponse response; + try + { + response = Deserialize(html); + } + catch (SerializationException) + { + throw RequestFailed(html); + } + + if (response != null && !response.Success) + { + throw RequestFailed( + ExtractErrorMessage(html) + ?? "工单接口请求失败。", + html); + } + } + + private static T DeserializeResponse(string html) + { + EnsureSuccessBody(html); + try + { + var result = Deserialize(html); + if (result == null) + throw new SerializationException("接口响应为空。"); + return result; + } + catch (SerializationException) + { + throw RequestFailed("工单接口返回了无法解析的数据。", html); + } + } + + private static string ExtractErrorMessage(string body) + { + if (string.IsNullOrWhiteSpace(body)) + return null; + + try + { + var error = Deserialize(body); + return error == null + ? null + : error.Message + ?? error.Error + ?? error.Code; + } + catch (SerializationException) + { + return null; + } + } + + private static string Serialize(object value) + { + var serializer = new DataContractJsonSerializer(value.GetType()); + using (var stream = new MemoryStream()) + { + serializer.WriteObject(stream, value); + return Encoding.UTF8.GetString(stream.ToArray()); + } + } + + private static T Deserialize(string json) + { + var serializer = new DataContractJsonSerializer(typeof(T)); + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(json ?? string.Empty))) + { + return (T)serializer.ReadObject(stream); + } + } + + private static string BuildUri(string path) + { + return ServerUrl + path; + } + + private static TicketApiException RequestFailed( + string message, + string responseBody = null) + { + return new TicketApiException( + ExtractStatusCode(responseBody), + message, + responseBody); + } + + private static int ExtractStatusCode(string responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + return 0; + + try + { + var error = Deserialize(responseBody); + if (error != null && int.TryParse(error.Code, out var code)) + return code; + } + catch (SerializationException) + { + // GetHtml 在网络异常时会返回普通错误文本,此时没有 HTTP 状态码。 + } + return 0; + } + + private static List NormalizeAppIds(IEnumerable appIds) + { + var normalized = (appIds ?? Enumerable.Empty()) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Select(a => a.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (normalized.Count == 0) + throw new ArgumentException("appIds 至少需要一个 APPID。", nameof(appIds)); + if (normalized.Count > 50) + throw new ArgumentException("appIds 最多支持 50 个 APPID。", nameof(appIds)); + foreach (var appId in normalized) + ValidateAppId(appId); + return normalized; + } + + private static void ValidateDescription(string description) + { + if (string.IsNullOrWhiteSpace(description) + || description.Length < 1 + || description.Length > 2000) + { + throw new ArgumentException("description 必须为 1~2000 字。", nameof(description)); + } + } + + private static void ValidateMessageText(string text) + { + if (string.IsNullOrWhiteSpace(text) + || text.Length < 1 + || text.Length > 4000) + { + throw new ArgumentException("text 必须为 1~4000 字。", nameof(text)); + } + } + + private static void ValidateOperationId( + string value, + string parameterName, + bool required) + { + if (string.IsNullOrWhiteSpace(value)) + { + if (required) + throw new ArgumentException(parameterName + " 必填。", parameterName); + return; + } + if (!OperationIdRegex.IsMatch(value)) + { + throw new ArgumentException( + parameterName + " 必须为 1~128 位字母、数字或 _.:-。", + parameterName); + } + } + +} diff --git a/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs b/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs new file mode 100644 index 0000000..fb93383 --- /dev/null +++ b/OrderSupportTicket/ViewModels/ChatMessageViewModel.cs @@ -0,0 +1,135 @@ +using OrderSupportTicket.Models.Api; +using System; +using System.Globalization; +using WPFCommon.Models; + +namespace OrderSupportTicket.ViewModels; + +/// +/// 工单留言板中的单条消息。 +/// +public sealed class ChatMessageViewModel : BindableBaseEx +{ + private string _messageId; + private int _sequenceNo; + private string _senderType; + private string _senderDisplayName; + private string _content; + private bool _isRecalled; + private DateTimeOffset _createdAt; + + public ChatMessageViewModel(TicketMessageDto dto) + { + if (dto == null) + throw new ArgumentNullException(nameof(dto)); + MessageId = dto.MessageId; + SequenceNo = dto.SequenceNo; + SenderType = dto.SenderType; + IsRecalled = dto.Recalled; + SenderDisplayName = string.IsNullOrWhiteSpace(dto.SenderName) + ? (IsCustomer ? "我" : "客服") + : dto.SenderName; + Content = dto.Recalled + ? "该消息已撤回" + : (dto.Text ?? string.Empty); + CreatedAt = ParseTimestamp(dto.CreatedAt); + } + + /// + /// 消息唯一 ID。 + /// + public string MessageId + { + get => _messageId; + private set => SetValue(ref _messageId, value); + } + + /// + /// 工单内连续递增的消息序号。 + /// + public int SequenceNo + { + get => _sequenceNo; + private set => SetValue(ref _sequenceNo, value); + } + + /// + /// 消息发送方类型。 + /// + public string SenderType + { + get => _senderType; + private set + { + if (SetValue(ref _senderType, value)) + RaisePropertyChanged(nameof(IsCustomer)); + } + } + + /// + /// 消息发送方显示名称。 + /// + public string SenderDisplayName + { + get => _senderDisplayName; + private set => SetValue(ref _senderDisplayName, value); + } + + /// + /// 消息展示内容;撤回消息显示固定提示文本。 + /// + public string Content + { + get => _content; + private set => SetValue(ref _content, value); + } + + /// + /// 消息是否已撤回。 + /// + public bool IsRecalled + { + get => _isRecalled; + private set => SetValue(ref _isRecalled, value); + } + + /// + /// 消息发送时间。 + /// + public DateTimeOffset CreatedAt + { + get => _createdAt; + private set + { + if (SetValue(ref _createdAt, value)) + RaisePropertyChanged(nameof(CreatedAtText)); + } + } + + /// + /// 当前消息是否由客户发送。 + /// + public bool IsCustomer => + string.Equals(SenderType, "customer", StringComparison.OrdinalIgnoreCase); + + /// + /// 适合聊天界面展示的本地时间文本。 + /// + public string CreatedAtText => + CreatedAt == default + ? string.Empty + : CreatedAt.ToLocalTime().ToString("MM-dd HH:mm", CultureInfo.CurrentCulture); + + private static DateTimeOffset ParseTimestamp(string value) + { + if (DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, + out var parsed)) + { + return parsed; + } + return default; + } +} diff --git a/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs new file mode 100644 index 0000000..303bd77 --- /dev/null +++ b/OrderSupportTicket/ViewModels/OrderSupportTicketViewModel.cs @@ -0,0 +1,1388 @@ +using NaiBa.Util.Helper; +using OrderSupportTicket.Models; +using OrderSupportTicket.Models.Api; +using OrderSupportTicket.Services; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using System.Windows.Threading; +using WPFCommon.Models; +using WxOrder.Framework.Commons; + +namespace OrderSupportTicket.ViewModels; + +/// +/// 工单页总 ViewModel,负责工单查询、留言操作、订单筛选和 SSE 刷新。 +/// +public sealed class OrderSupportTicketViewModel : BindableBaseEx, IDisposable +{ + private const string SavedListConfigName = "OrderSupportTicket"; + private readonly ITicketApiService _api; + private readonly ITicketRealtimeService _realtime; + private readonly Dispatcher _dispatcher; + private readonly DelegateCommand _newTicketCommand; + private readonly DelegateCommand _clearOrderFilterCommand; + private readonly AsyncDelegateCommand _selectTicketCommand; + private readonly AsyncDelegateCommand _createTicketCommand; + private readonly AsyncDelegateCommand _sendMessageCommand; + private readonly AsyncDelegateCommand _loadEarlierMessagesCommand; + private readonly AsyncDelegateCommand _retryRealtimeCommand; + private readonly HashSet _seenEventIds = + new HashSet(StringComparer.Ordinal); + private readonly Dictionary _lastReadSequences = + new Dictionary(StringComparer.Ordinal); + private Dictionary _savedWorkOrders = + new Dictionary(StringComparer.Ordinal); + private bool _hasSavedCache; + private TicketClientOptions _options; + private bool _configured; + private bool _initialized; + private bool _disposed; + private WorkOrderSummaryViewModel _selectedTicket; + private TicketOrderOption _selectedOrder; + private string _chatText; + private string _draftText; + private string _orderFilterText = string.Empty; + private bool _isDraft; + private bool _isBusy; + private bool _isLoading; + private bool _isLoadingMessages; + private bool _hasMoreMessages; + private string _nextBefore; + private string _noticeText; + private bool _isNoticeError; + private bool _hasNewData; + private string _newDataText; + private TicketRealtimeState _sseState; + private string _sseStatusText; + private int _messageScrollVersion; + private string _pendingCreateRequestId; + private string _pendingCreateFingerprint; + private string _pendingMessageId; + private string _pendingMessageFingerprint; + private int _selectionVersion; + + public OrderSupportTicketViewModel( + ITicketApiService api, + ITicketRealtimeService realtime, + TicketClientOptions options = null) + { + _api = api ?? throw new ArgumentNullException(nameof(api)); + _realtime = realtime ?? throw new ArgumentNullException(nameof(realtime)); + _dispatcher = System.Windows.Application.Current?.Dispatcher + ?? Dispatcher.CurrentDispatcher; + + Tickets = new ObservableCollection(); + Messages = new ObservableCollection(); + AvailableOrders = new ObservableCollection(); + FilteredOrders = new ObservableCollection(); + + _newTicketCommand = new DelegateCommand(OpenNewDraft, () => !IsBusy); + _clearOrderFilterCommand = new DelegateCommand( + () => OrderFilterText = string.Empty, + () => !string.IsNullOrWhiteSpace(OrderFilterText)); + _selectTicketCommand = new AsyncDelegateCommand( + SelectTicketAsync, + ticket => ticket != null && !IsBusy); + _createTicketCommand = new AsyncDelegateCommand( + CreateTicketAsync, + CanCreateTicket); + _sendMessageCommand = new AsyncDelegateCommand( + SendMessageAsync, + CanSendMessage); + _loadEarlierMessagesCommand = new AsyncDelegateCommand( + LoadEarlierMessagesAsync, + () => !IsBusy && SelectedTicket != null && HasMoreMessages); + _retryRealtimeCommand = new AsyncDelegateCommand( + RestartRealtimeAsync, + () => !IsBusy && _configured); + + _realtime.NotificationReceived += OnNotificationReceived; + _realtime.StateChanged += OnRealtimeStateChanged; + SseState = TicketRealtimeState.Disconnected; + SseStatusText = "实时推送:未连接"; + + if (options != null) + Configure(options); + } + + /// + /// 当前账号和 APPID 下的历史工单。 + /// + public ObservableCollection Tickets { get; } + + /// + /// 当前选中工单的公开留言。 + /// + public ObservableCollection Messages { get; } + + /// + /// 宿主注入的全部可关联订单。 + /// + public ObservableCollection AvailableOrders { get; } + + /// + /// 经过订单号或商品名筛选后的关联订单。 + /// + public ObservableCollection FilteredOrders { get; } + + /// + /// 打开新建工单草稿的命令。 + /// + public ICommand NewTicketCommand => _newTicketCommand; + + /// + /// 清除关联订单筛选条件的命令。 + /// + public ICommand ClearOrderFilterCommand => _clearOrderFilterCommand; + + /// + /// 选择并加载历史工单的命令。 + /// + public ICommand SelectTicketCommand => _selectTicketCommand; + + /// + /// 提交新工单的命令。 + /// + public ICommand CreateTicketCommand => _createTicketCommand; + + /// + /// 提交客户留言的命令。 + /// + public ICommand SendMessageCommand => _sendMessageCommand; + + /// + /// 加载当前工单更早留言的命令。 + /// + public ICommand LoadEarlierMessagesCommand => _loadEarlierMessagesCommand; + + /// + /// 手动重新连接 SSE 的命令。 + /// + public ICommand RetryRealtimeCommand => _retryRealtimeCommand; + + /// + /// 当前正在查看的历史工单。 + /// + public WorkOrderSummaryViewModel SelectedTicket + { + get => _selectedTicket; + private set + { + if (SetValue(ref _selectedTicket, value)) + { + RaisePropertyChanged(nameof(IsChatVisible)); + RaisePropertyChanged(nameof(IsEmptyVisible)); + RaisePropertyChanged(nameof(ComposerPlaceholder)); + RaiseSendCanExecuteChanged(); + RaiseLoadEarlierCanExecuteChanged(); + } + } + } + + /// + /// 新建工单时选中的关联订单。 + /// + public TicketOrderOption SelectedOrder + { + get => _selectedOrder; + set + { + if (SetValue(ref _selectedOrder, value)) + { + RaisePropertyChanged(nameof(SelectedOrderDescription)); + RaiseCreateCanExecuteChanged(); + } + } + } + + /// + /// 当前工单输入框中的留言文本。 + /// + public string ChatText + { + get => _chatText; + set + { + if (SetValue(ref _chatText, value ?? string.Empty)) + { + RaisePropertyChanged(nameof(ChatCharacterCountText)); + RaiseSendCanExecuteChanged(); + } + } + } + + /// + /// 新建工单输入框中的问题描述。 + /// + public string DraftText + { + get => _draftText; + set + { + if (SetValue(ref _draftText, value ?? string.Empty)) + { + RaisePropertyChanged(nameof(DraftCharacterCountText)); + RaiseCreateCanExecuteChanged(); + } + } + } + + /// + /// 关联订单筛选关键词,可匹配订单号或商品名。 + /// + public string OrderFilterText + { + get => _orderFilterText; + set + { + if (SetValue(ref _orderFilterText, value ?? string.Empty)) + { + RefreshFilteredOrders(); + _clearOrderFilterCommand.RaiseCanExecuteChanged(); + } + } + } + + /// + /// 当前是否处于新建工单草稿状态。 + /// + public bool IsDraft + { + get => _isDraft; + private set + { + if (SetValue(ref _isDraft, value)) + { + RaisePropertyChanged(nameof(IsChatVisible)); + RaisePropertyChanged(nameof(IsEmptyVisible)); + RaisePropertyChanged(nameof(IsDraftVisible)); + RaiseCreateCanExecuteChanged(); + } + } + } + + /// + /// 是否正在执行创建、留言或刷新操作。 + /// + public bool IsBusy + { + get => _isBusy; + private set + { + if (SetValue(ref _isBusy, value)) + { + RaisePropertyChanged(nameof(IsNotBusy)); + RaiseSendCanExecuteChanged(); + RaiseCreateCanExecuteChanged(); + RaiseLoadEarlierCanExecuteChanged(); + _newTicketCommand.RaiseCanExecuteChanged(); + _retryRealtimeCommand.RaiseCanExecuteChanged(); + _selectTicketCommand.RaiseCanExecuteChanged(); + } + } + } + + /// + /// 是否正在加载工单摘要列表。 + /// + public bool IsLoading + { + get => _isLoading; + private set => SetValue(ref _isLoading, value); + } + + /// + /// 是否正在加载当前工单留言。 + /// + public bool IsLoadingMessages + { + get => _isLoadingMessages; + private set => SetValue(ref _isLoadingMessages, value); + } + + /// + /// 当前留言板是否还有更早留言。 + /// + public bool HasMoreMessages + { + get => _hasMoreMessages; + private set + { + if (SetValue(ref _hasMoreMessages, value)) + RaiseLoadEarlierCanExecuteChanged(); + } + } + + /// + /// 当前操作提示或错误信息。 + /// + public string NoticeText + { + get => _noticeText; + private set => SetValue(ref _noticeText, value); + } + + /// + /// 当前提示是否为错误类型。 + /// + public bool IsNoticeError + { + get => _isNoticeError; + private set => SetValue(ref _isNoticeError, value); + } + + /// + /// 当前页面是否检测到服务器有新的工单消息。 + /// + public bool HasNewData + { + get => _hasNewData; + private set => SetValue(ref _hasNewData, value); + } + + /// + /// 新数据提示文本。 + /// + public string NewDataText + { + get => _newDataText; + private set => SetValue(ref _newDataText, value); + } + + /// + /// SSE 当前连接状态。 + /// + public TicketRealtimeState SseState + { + get => _sseState; + private set => SetValue(ref _sseState, value); + } + + /// + /// SSE 当前状态的中文显示文本。 + /// + public string SseStatusText + { + get => _sseStatusText; + private set => SetValue(ref _sseStatusText, value); + } + + /// + /// 触发消息列表滚动到底部的版本号。 + /// + public int MessageScrollVersion + { + get => _messageScrollVersion; + private set => SetValue(ref _messageScrollVersion, value); + } + + /// + /// 是否显示当前工单的聊天面板。 + /// + public bool IsChatVisible => !IsDraft && SelectedTicket != null; + + /// + /// 是否显示新建工单面板。 + /// + public bool IsDraftVisible => IsDraft; + + /// + /// 是否显示未选择工单时的空状态。 + /// + public bool IsEmptyVisible => !IsDraft && SelectedTicket == null; + + /// + /// 当前是否没有执行中的操作。 + /// + public bool IsNotBusy => !IsBusy; + + /// + /// 是否存在任何可关联订单。 + /// + public bool HasAvailableOrders => AvailableOrders.Count > 0; + + /// + /// 当前筛选结果中是否存在订单。 + /// + public bool HasFilteredOrders => FilteredOrders.Count > 0; + + /// + /// 关联订单总数或筛选结果数量文本。 + /// + public string OrderFilterSummaryText => + HasAvailableOrders + ? (string.IsNullOrWhiteSpace(OrderFilterText) + ? $"共 {FilteredOrders.Count} 个订单" + : $"匹配 {FilteredOrders.Count} 个订单") + : "暂无可关联订单"; + + /// + /// 留言输入框字数统计文本。 + /// + public string ChatCharacterCountText => (ChatText ?? string.Empty).Length + " / 4000"; + + /// + /// 新建工单输入框字数统计文本。 + /// + public string DraftCharacterCountText => (DraftText ?? string.Empty).Length + " / 2000"; + + /// + /// 当前工单输入框提示文本。 + /// + public string ComposerPlaceholder => + SelectedTicket != null && SelectedTicket.IsCompleted + ? "工单已完结,继续留言将重新开启" + : "输入留言"; + + /// + /// 当前关联订单的商品、金额和下单时间说明。 + /// + public string SelectedOrderDescription => + SelectedOrder?.ProductDescription ?? "请选择关联订单"; + + public void Configure(TicketClientOptions options) + { + if (_disposed) + throw new ObjectDisposedException(nameof(OrderSupportTicketViewModel)); + + _ = _realtime.StopAsync(); + SseState = TicketRealtimeState.Disconnected; + SseStatusText = "实时推送:未连接"; + _configured = false; + _initialized = false; + _options = CloneOptions(options); + HasNewData = false; + NewDataText = string.Empty; + + IsDraft = false; + SelectedTicket = null; + Tickets.Clear(); + Messages.Clear(); + AvailableOrders.Clear(); + foreach (var order in _options.Orders ?? Enumerable.Empty()) + { + if (order != null) + AvailableOrders.Add(order); + } + OrderFilterText = string.Empty; + RefreshFilteredOrders(); + RaisePropertyChanged(nameof(HasAvailableOrders)); + + if (_options.AppIds == null || _options.AppIds.Count == 0) + { + _options.AppIds = AvailableOrders + .Select(o => o.AppId) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + + LoadSavedOrderTimes(); + + try + { + TicketApiService.ValidateOptions(_options); + if (_options.AppIds.Count == 0) + throw new ArgumentException("appIds 至少需要一个 APPID。"); + _configured = true; + NoticeText = string.Empty; + IsNoticeError = false; + } + catch (Exception ex) + { + SetNotice( + "工单页配置不完整:" + ex.Message + + " 请通过 TicketClientOptions 注入拍单账号和 APPID。", + true); + } + + RaiseCreateCanExecuteChanged(); + RaiseLoadEarlierCanExecuteChanged(); + _retryRealtimeCommand.RaiseCanExecuteChanged(); + } + + public async Task InitializeAsync() + { + if (_disposed) + return; + if (!_configured) + { + SetNotice( + "未配置拍单账号或 APPID,工单页无法连接服务器。", + true); + return; + } + + try + { + var started = false; + if (!_initialized || _realtime.State == TicketRealtimeState.Disconnected) + { + _realtime.Start(_options); + started = true; + } + + if (!_initialized || started) + { + await RefreshAllWorkOrdersAsync(loadSelectedMessages: true); + _initialized = true; + } + } + catch (Exception ex) + { + SetNotice(DescribeException(ex), true); + } + } + + public async Task DeactivateAsync() + { + SaveWorkOrderList(); + await _realtime.StopAsync(); + } + + public void OpenNewTicket(TicketOrderOption order) + { + if (order == null) + return; + if (!AvailableOrders.Any(o => + string.Equals(o.OrderKey, order.OrderKey, StringComparison.Ordinal))) + { + AvailableOrders.Add(order); + RaisePropertyChanged(nameof(HasAvailableOrders)); + } + OrderFilterText = string.Empty; + RefreshFilteredOrders(); + SelectedOrder = AvailableOrders.First(o => + string.Equals(o.OrderKey, order.OrderKey, StringComparison.Ordinal)); + OpenNewDraft(); + } + + public void Dispose() + { + if (_disposed) + return; + SaveWorkOrderList(); + _disposed = true; + _realtime.NotificationReceived -= OnNotificationReceived; + _realtime.StateChanged -= OnRealtimeStateChanged; + _realtime.Dispose(); + } + + private void OpenNewDraft() + { + IsDraft = true; + SelectedTicket = null; + Messages.Clear(); + HasMoreMessages = false; + _nextBefore = null; + if (SelectedOrder == null) + SelectedOrder = FilteredOrders.FirstOrDefault(); + if (AvailableOrders.Count == 0) + { + SetNotice( + "没有可关联的本地订单。请通过 TicketClientOptions.Orders 注入订单后再新建工单。", + true); + } + else if (FilteredOrders.Count == 0) + { + SetNotice( + "当前筛选条件下没有匹配订单,请调整订单号或商品名关键词。", + true); + } + else + { + ClearNotice(); + } + } + + private async Task SelectTicketAsync(WorkOrderSummaryViewModel ticket) + { + if (ticket == null || IsBusy) + return; + + IsDraft = false; + SelectedTicket = ticket; + await LoadMessagesAsync(ticket, replace: true); + } + + private async Task CreateTicketAsync() + { + if (!CanCreateTicket()) + return; + + var description = (DraftText ?? string.Empty).Trim(); + var fingerprint = SelectedOrder.OrderKey + "|" + description; + if (!string.Equals( + fingerprint, + _pendingCreateFingerprint, + StringComparison.Ordinal)) + { + _pendingCreateFingerprint = fingerprint; + _pendingCreateRequestId = NewOperationId("create"); + } + + IsBusy = true; + ClearNotice(); + try + { + var result = await _api.CreateWorkOrderAsync( + _options, + SelectedOrder, + description, + null); + + var targetKey = result.AppId + "|" + result.WechatOrderId; + var operation = (result.Operation ?? string.Empty).ToLowerInvariant(); + var isExisting = operation == "existing"; + if (isExisting) + { + ChatText = description; + SetNotice( + "该订单已有未完结工单,服务端未重复追加描述。已打开原工单,请点击“发送”提交本次补充。", + false); + } + else if (operation == "reopened") + { + SetNotice("原工单已重新开启,本次问题已加入留言板。", false); + } + else + { + SetNotice("工单已提交,等待客服回复。", false); + } + + _pendingCreateFingerprint = null; + _pendingCreateRequestId = null; + DraftText = string.Empty; + IsDraft = false; + await RefreshAllWorkOrdersAsync( + loadSelectedMessages: false, + preferredKey: targetKey); + var target = Tickets.FirstOrDefault(t => + string.Equals(t.Key, targetKey, StringComparison.Ordinal)); + if (target != null) + { + SelectedTicket = target; + await LoadMessagesAsync(target, replace: true); + } + } + catch (Exception ex) + { + SetNotice(DescribeException(ex), true); + } + finally + { + IsBusy = false; + } + } + + private async Task SendMessageAsync() + { + if (!CanSendMessage()) + return; + + var ticket = SelectedTicket; + var text = (ChatText ?? string.Empty).Trim(); + var fingerprint = ticket.Key + "|" + text; + if (!string.Equals( + fingerprint, + _pendingMessageFingerprint, + StringComparison.Ordinal)) + { + _pendingMessageFingerprint = fingerprint; + _pendingMessageId = NewOperationId("send"); + } + + IsBusy = true; + ClearNotice(); + try + { + var result = await _api.SendMessageAsync( + _options, + ToSummaryDto(ticket), + text, + _pendingMessageId); + + if (result.Message != null) + AppendMessage(result.Message); + ticket.ApplySendResult(result.Status, result.Version); + MoveTicketToTop(ticket); + ChatText = string.Empty; + _pendingMessageFingerprint = null; + _pendingMessageId = null; + SetNotice( + result.Reopened + ? "留言已发送,已完结工单已重新开启。" + : "留言已发送。", + false); + await MarkReadBestEffortAsync(ticket); + SaveWorkOrderList(); + MessageScrollVersion++; + } + catch (TicketApiException ex) + when (ex.StatusCode == 409) + { + SetNotice("工单状态或数据版本发生冲突,正在刷新最新内容。", true); + _pendingMessageFingerprint = null; + _pendingMessageId = null; + await RefreshAllWorkOrdersAsync( + loadSelectedMessages: false, + preferredKey: ticket.Key); + var refreshed = Tickets.FirstOrDefault(t => + string.Equals(t.Key, ticket.Key, StringComparison.Ordinal)); + if (refreshed != null) + { + SelectedTicket = refreshed; + await LoadMessagesAsync(refreshed, replace: true); + } + } + catch (Exception ex) + { + SetNotice(DescribeException(ex), true); + } + finally + { + IsBusy = false; + } + } + + private async Task LoadEarlierMessagesAsync() + { + var ticket = SelectedTicket; + if (ticket == null || !HasMoreMessages || string.IsNullOrWhiteSpace(_nextBefore)) + return; + + IsBusy = true; + try + { + var response = await _api.GetMessagesAsync( + _options, + ticket.AppId, + ticket.WechatOrderId, + _nextBefore, + 50); + MergeMessages(response.Items ?? new List()); + HasMoreMessages = response.HasMore; + _nextBefore = response.NextBefore; + } + catch (Exception ex) + { + SetNotice(DescribeException(ex), true); + } + finally + { + IsBusy = false; + } + } + + private async Task RestartRealtimeAsync() + { + if (!_configured) + return; + + IsBusy = true; + try + { + await _realtime.StopAsync(); + _realtime.Start(_options); + await RefreshAllWorkOrdersAsync(loadSelectedMessages: false); + if (SelectedTicket != null && !IsDraft) + await LoadMessagesAsync(SelectedTicket, replace: true); + ClearNotice(); + } + catch (Exception ex) + { + SetNotice(DescribeException(ex), true); + } + finally + { + IsBusy = false; + } + } + + private async Task LoadMessagesAsync( + WorkOrderSummaryViewModel ticket, + bool replace) + { + var version = ++_selectionVersion; + IsLoadingMessages = true; + try + { + var response = await _api.GetMessagesAsync( + _options, + ticket.AppId, + ticket.WechatOrderId, + null, + 50); + if (version != _selectionVersion + || SelectedTicket == null + || !string.Equals(ticket.Key, SelectedTicket.Key, StringComparison.Ordinal)) + { + return; + } + + if (replace) + Messages.Clear(); + MergeMessages(response.Items ?? new List()); + HasMoreMessages = response.HasMore; + _nextBefore = response.NextBefore; + await MarkReadBestEffortAsync(ticket); + MessageScrollVersion++; + } + catch (Exception ex) + { + if (replace) + Messages.Clear(); + HasMoreMessages = false; + _nextBefore = null; + SetNotice(DescribeException(ex), true); + } + finally + { + IsLoadingMessages = false; + } + } + + private async Task RefreshAllWorkOrdersAsync( + bool loadSelectedMessages, + string preferredKey = null) + { + IsLoading = true; + try + { + var incoming = new List(); + string cursor = null; + var page = 0; + do + { + var response = await _api.GetWorkOrdersAsync( + _options, + limit: 100, + cursor: cursor); + incoming.AddRange(response.Items ?? new List()); + cursor = response.NextCursor; + page++; + if (!response.HasMore || string.IsNullOrWhiteSpace(cursor)) + break; + } + while (page < 20); + + var currentKey = SelectedTicket?.Key; + var existing = Tickets.ToDictionary(t => t.Key, StringComparer.Ordinal); + var merged = new List(); + foreach (var dto in incoming) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.AppId) + || string.IsNullOrWhiteSpace(dto.WechatOrderId)) + { + continue; + } + var key = dto.AppId + "|" + dto.WechatOrderId; + var hasNewMessage = HasNewMessage(dto); + if (existing.TryGetValue(key, out var item)) + { + if (!item.TryApply(dto)) + continue; + } + else + { + item = new WorkOrderSummaryViewModel(dto); + } + if (hasNewMessage) + item.MarkHasNewMessage(); + if (merged.All(t => !string.Equals(t.Key, item.Key, StringComparison.Ordinal))) + merged.Add(item); + } + + Tickets.Clear(); + foreach (var item in merged.OrderByDescending(t => t.UpdatedAt)) + Tickets.Add(item); + + var target = FindTicket(preferredKey) + ?? FindTicket(currentKey) + ?? Tickets.FirstOrDefault(); + if (!IsDraft) + SelectedTicket = target; + + SaveWorkOrderList(); + + if (loadSelectedMessages && SelectedTicket != null && !IsDraft) + await LoadMessagesAsync(SelectedTicket, replace: true); + } + catch (Exception ex) + { + SetNotice(DescribeException(ex), true); + } + finally + { + IsLoading = false; + } + } + + private async Task RefreshSingleWorkOrderAsync(string appId, string wechatOrderId) + { + if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(wechatOrderId)) + return; + + var options = CloneOptions(_options); + options.AppIds = new List { appId }; + var response = await _api.GetWorkOrdersAsync( + options, + wechatOrderId, + limit: 1); + var dto = response.Items?.FirstOrDefault(); + if (dto == null) + return; + + var key = dto.AppId + "|" + dto.WechatOrderId; + var hasNewMessage = HasNewMessage(dto); + var existing = FindTicket(key); + if (existing == null) + { + existing = new WorkOrderSummaryViewModel(dto); + Tickets.Add(existing); + } + else if (!existing.TryApply(dto)) + { + return; + } + + if (hasNewMessage) + existing.MarkHasNewMessage(); + SaveWorkOrderList(); + ReorderTickets(); + if (SelectedTicket != null + && string.Equals(SelectedTicket.Key, key, StringComparison.Ordinal) + && !IsDraft) + { + await LoadMessagesAsync(SelectedTicket, replace: true); + } + } + + private void MergeMessages(IEnumerable items) + { + var byId = Messages + .GroupBy(m => m.MessageId) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + foreach (var dto in items) + { + if (dto == null) + continue; + var id = dto.MessageId; + if (string.IsNullOrWhiteSpace(id)) + id = "sequence:" + dto.SequenceNo; + if (!byId.ContainsKey(id)) + byId[id] = new ChatMessageViewModel(dto); + } + + var ordered = byId.Values + .OrderBy(m => m.SequenceNo) + .ThenBy(m => m.CreatedAt) + .ToList(); + Messages.Clear(); + foreach (var message in ordered) + 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) + { + var maxSequence = Messages.Count == 0 + ? 0 + : Messages.Max(m => m.SequenceNo); + if (maxSequence <= 0) + return; + if (_lastReadSequences.TryGetValue(ticket.Key, out var lastRead) + && maxSequence <= lastRead) + { + ticket.MarkRead(); + return; + } + + try + { + await _api.MarkReadAsync( + _options, + ticket.AppId, + ticket.WechatOrderId, + maxSequence); + _lastReadSequences[ticket.Key] = maxSequence; + ticket.MarkRead(); + SaveWorkOrderList(); + } + catch + { + // 已读失败不影响留言展示;下次打开工单会重新尝试。 + } + } + + private void OnNotificationReceived( + object sender, + TicketRealtimeNotificationEventArgs args) + { + var notification = args?.Notification; + if (notification == null) + return; + + _dispatcher.BeginInvoke(new Action(() => + { + _ = HandleNotificationAsync(notification); + })); + } + + private async Task HandleNotificationAsync(TicketRealtimeNotification notification) + { + if (_disposed || !_configured) + return; + + if (!string.IsNullOrWhiteSpace(notification.EventId)) + { + if (!_seenEventIds.Add(notification.EventId)) + return; + if (_seenEventIds.Count > 5000) + _seenEventIds.Clear(); + } + + try + { + if (notification.IsResyncRequired) + { + await RefreshAllWorkOrdersAsync( + loadSelectedMessages: SelectedTicket != null && !IsDraft); + return; + } + + if (string.IsNullOrWhiteSpace(notification.AppId) + || string.IsNullOrWhiteSpace(notification.WechatOrderId)) + { + return; + } + await RefreshSingleWorkOrderAsync( + notification.AppId, + notification.WechatOrderId); + } + catch (Exception ex) + { + SetNotice("实时通知已收到,但刷新工单失败:" + DescribeException(ex), true); + } + } + + private void OnRealtimeStateChanged( + object sender, + TicketRealtimeStateChangedEventArgs args) + { + _dispatcher.BeginInvoke(new Action(() => + { + if (_disposed || args == null) + return; + SseState = args.State; + SseStatusText = args.State switch + { + TicketRealtimeState.Connected => "实时推送:已连接", + TicketRealtimeState.Connecting => "实时推送:连接中…", + TicketRealtimeState.Reconnecting => "实时推送:重连中…", + _ => "实时推送:已断开" + }; + })); + } + + private bool CanCreateTicket() + { + return !IsBusy + && IsDraft + && SelectedOrder != null + && !string.IsNullOrWhiteSpace(DraftText) + && DraftText.Trim().Length <= 2000; + } + + private void RefreshFilteredOrders() + { + var selectedKey = SelectedOrder?.OrderKey; + var keyword = (OrderFilterText ?? string.Empty).Trim(); + var matches = string.IsNullOrWhiteSpace(keyword) + ? AvailableOrders + : AvailableOrders.Where(order => + ContainsIgnoreCase(order.OrderId, keyword) + || ContainsIgnoreCase(order.GoodsName, keyword)); + + FilteredOrders.Clear(); + foreach (var order in matches) + FilteredOrders.Add(order); + + SelectedOrder = FilteredOrders.FirstOrDefault(order => + string.Equals( + order.OrderKey, + selectedKey, + StringComparison.Ordinal)) + ?? FilteredOrders.FirstOrDefault(); + + RaisePropertyChanged(nameof(HasFilteredOrders)); + RaisePropertyChanged(nameof(OrderFilterSummaryText)); + } + + private static bool ContainsIgnoreCase(string source, string keyword) + { + return !string.IsNullOrWhiteSpace(source) + && source.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private bool CanSendMessage() + { + return !IsBusy + && SelectedTicket != null + && !string.IsNullOrWhiteSpace(ChatText) + && ChatText.Trim().Length <= 4000; + } + + private void RaiseSendCanExecuteChanged() + { + _sendMessageCommand.RaiseCanExecuteChanged(); + } + + private void RaiseCreateCanExecuteChanged() + { + _createTicketCommand.RaiseCanExecuteChanged(); + } + + private void RaiseLoadEarlierCanExecuteChanged() + { + _loadEarlierMessagesCommand.RaiseCanExecuteChanged(); + } + + private void SetNotice(string text, bool isError) + { + NoticeText = text ?? string.Empty; + IsNoticeError = isError; + } + + private void ClearNotice() + { + NoticeText = string.Empty; + IsNoticeError = false; + } + + private WorkOrderSummaryViewModel FindTicket(string key) + { + if (string.IsNullOrWhiteSpace(key)) + return null; + return Tickets.FirstOrDefault(t => + string.Equals(t.Key, key, StringComparison.Ordinal)); + } + + private void MoveTicketToTop(WorkOrderSummaryViewModel ticket) + { + var index = Tickets.IndexOf(ticket); + if (index > 0) + Tickets.Move(index, 0); + } + + private void ReorderTickets() + { + var ordered = Tickets.OrderByDescending(t => t.UpdatedAt).ToList(); + for (var target = 0; target < ordered.Count; target++) + { + var current = Tickets.IndexOf(ordered[target]); + if (current != target) + Tickets.Move(current, target); + } + } + + private static WorkOrderSummaryDto ToSummaryDto(WorkOrderSummaryViewModel ticket) + { + return new WorkOrderSummaryDto + { + AppId = ticket.AppId, + WechatOrderId = ticket.WechatOrderId, + Status = ticket.Status, + Version = ticket.Version + }; + } + + private void LoadSavedOrderTimes() + { + _savedWorkOrders.Clear(); + _hasSavedCache = false; + 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)) + return; + + foreach (var saved in cache.Items) + { + if (saved == null + || string.IsNullOrWhiteSpace(saved.OrderId)) + { + continue; + } + + _savedWorkOrders[saved.OrderId] = saved; + } + _hasSavedCache = true; + } + + private void UpdateSavedWorkOrders() + { + _savedWorkOrders = Tickets + .GroupBy(t => t.WechatOrderId, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => ToCacheItem(group.First()), + StringComparer.Ordinal); + + var newCount = Tickets.Count(ticket => ticket.HasNewMessage); + HasNewData = newCount > 0; + NewDataText = newCount > 0 + ? $"有 {newCount} 个工单有新数据" + : string.Empty; + } + + private void SaveWorkOrderList() + { + if (string.IsNullOrWhiteSpace(_options?.UserName)) + return; + + UpdateSavedWorkOrders(); + var cache = new WorkOrderListCache + { + UserName = _options.UserName, + Items = _savedWorkOrders.Values + .OrderByDescending(item => item.LastMessageAt) + .ToList() + }; + + try + { + ConfigHelper.SaveConfig(SavedListConfigName, cache); + _hasSavedCache = true; + } + catch + { + // 本地缓存写入失败不应影响工单接口和页面操作。 + } + } + + private bool HasNewMessage(WorkOrderSummaryDto dto) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.WechatOrderId)) + return false; + if (!_savedWorkOrders.TryGetValue(dto.WechatOrderId, out var saved)) + return _hasSavedCache; + + return ParseTimestamp(dto.LastMessageAt) > saved.LastMessageAt; + } + + private static WorkOrderListCacheItem ToCacheItem( + WorkOrderSummaryViewModel ticket) + { + return new WorkOrderListCacheItem + { + OrderId = ticket.WechatOrderId, + LastMessageAt = ticket.LastMessageAt + }; + } + + 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) + { + return prefix + + "-" + + DateTime.UtcNow.ToString("yyyyMMddHHmmss") + + "-" + + Guid.NewGuid().ToString("N"); + } + + private static TicketClientOptions CloneOptions(TicketClientOptions options) + { + if (options == null) + return new TicketClientOptions(); + return new TicketClientOptions + { + UserName = options.UserName, + AppIds = (options.AppIds ?? Enumerable.Empty()) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Select(a => a.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList(), + Orders = (options.Orders ?? Enumerable.Empty()) + .Where(o => o != null) + .ToList() + }; + } + + private static string DescribeException(Exception ex) + { + if (ex is TicketApiException apiException) + { + var hint = apiException.StatusCode switch + { + 400 => "请求格式错误。", + 404 => "该 APPID 和订单下尚无工单。", + 409 => "数据版本或幂等编号冲突。", + 413 => "请求内容超过大小限制。", + 422 => "参数或订单归属校验失败。", + 429 => "请求过于频繁,请稍后重试。", + 503 => "工单服务暂未开启或数据库繁忙。", + _ => null + }; + return string.IsNullOrWhiteSpace(hint) + ? apiException.Message + : hint + " " + apiException.Message; + } + if (ex is TimeoutException) + return ex.Message; + if (ex is ArgumentException) + return ex.Message; + return "操作失败:" + ex.Message; + } +} diff --git a/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs b/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs new file mode 100644 index 0000000..663bf19 --- /dev/null +++ b/OrderSupportTicket/ViewModels/WorkOrderSummaryViewModel.cs @@ -0,0 +1,289 @@ +using OrderSupportTicket.Models.Api; +using System; +using System.Globalization; +using WPFCommon.Models; + +namespace OrderSupportTicket.ViewModels; + +/// +/// 历史工单列表中的单个工单摘要。 +/// +public sealed class WorkOrderSummaryViewModel : BindableBaseEx +{ + private string _appId; + private string _wechatOrderId; + private string _status; + private string _statusText; + private DateTimeOffset _updatedAt; + private DateTimeOffset _lastMessageAt; + private DateTimeOffset? _lastReplyAt; + private string _lastSenderType; + private int _unreadCount; + private bool _hasNewMessage; + private string _version; + + public WorkOrderSummaryViewModel(WorkOrderSummaryDto dto) + { + if (dto == null) + throw new ArgumentNullException(nameof(dto)); + Apply(dto); + } + + /// + /// 订单所属店铺 APPID。 + /// + public string AppId + { + get => _appId; + private set => SetValue(ref _appId, value); + } + + /// + /// 微信订单号,也是客户端使用的工单号。 + /// + public string WechatOrderId + { + get => _wechatOrderId; + private set => SetValue(ref _wechatOrderId, value); + } + + /// + /// 工单状态编码。 + /// + public string Status + { + get => _status; + private set + { + if (SetValue(ref _status, value)) + RaisePropertyChanged(nameof(IsCompleted)); + } + } + + /// + /// 工单状态中文文本。 + /// + public string StatusText + { + get => _statusText; + private set => SetValue(ref _statusText, value); + } + + /// + /// 工单最后更新时间。 + /// + public DateTimeOffset UpdatedAt + { + get => _updatedAt; + private set + { + if (SetValue(ref _updatedAt, value)) + RaisePropertyChanged(nameof(UpdatedAtText)); + } + } + + /// + /// 最近一条公开留言的时间。 + /// + public DateTimeOffset LastMessageAt + { + get => _lastMessageAt; + private set => SetValue(ref _lastMessageAt, value); + } + + /// + /// 最近一次客服正式回复时间。 + /// + public DateTimeOffset? LastReplyAt + { + get => _lastReplyAt; + private set => SetValue(ref _lastReplyAt, value); + } + + /// + /// 最近一条留言的发送方类型。 + /// + public string LastSenderType + { + get => _lastSenderType; + private set + { + if (SetValue(ref _lastSenderType, value)) + RaisePropertyChanged(nameof(PreviewText)); + } + } + + /// + /// 当前工单的未读留言数量。 + /// + public int UnreadCount + { + get => _unreadCount; + private set => SetValue(ref _unreadCount, value); + } + + /// + /// 本次服务器刷新是否检测到比本地缓存更新的公开消息。 + /// + public bool HasNewMessage + { + get => _hasNewMessage; + private set + { + if (SetValue(ref _hasNewMessage, value)) + RaisePropertyChanged(nameof(PreviewText)); + } + } + + /// + /// 工单版本号字符串。 + /// + public string Version + { + get => _version; + private set => SetValue(ref _version, value); + } + + /// + /// 用于合并工单的 APPID 与订单号组合键。 + /// + public string Key => AppId + "|" + WechatOrderId; + + /// + /// 工单是否已完结。 + /// + public bool IsCompleted => + string.Equals(Status, "completed", StringComparison.OrdinalIgnoreCase); + + /// + /// 适合列表展示的本地时间文本。 + /// + public string UpdatedAtText => + UpdatedAt == default + ? string.Empty + : UpdatedAt.ToLocalTime().ToString("MM-dd HH:mm", CultureInfo.CurrentCulture); + + /// + /// 不读取留言正文时使用的最近动态摘要。 + /// + public string PreviewText + { + get + { + if (HasNewMessage) + return "有新消息,点击查看"; + if (string.Equals( + LastSenderType, + "customer", + StringComparison.OrdinalIgnoreCase)) + { + return "客户最近有新留言"; + } + if (LastReplyAt.HasValue) + return "客服最近已回复"; + return "暂无公开留言"; + } + } + + public bool TryApply(WorkOrderSummaryDto dto) + { + if (dto == null || !string.Equals(Key, dto.AppId + "|" + dto.WechatOrderId, + StringComparison.Ordinal)) + { + return false; + } + + if (!IsAtLeastCurrentVersion(dto.Version)) + return false; + Apply(dto); + return true; + } + + public void ApplySendResult(string status, string version) + { + if (!string.IsNullOrWhiteSpace(status)) + { + Status = status; + StatusText = GetStatusText(status); + } + if (!string.IsNullOrWhiteSpace(version)) + Version = version; + UpdatedAt = DateTimeOffset.Now; + LastMessageAt = UpdatedAt; + LastSenderType = "customer"; + HasNewMessage = false; + } + + public void MarkRead() + { + UnreadCount = 0; + HasNewMessage = false; + } + + /// + /// 标记检测到新消息,并确保列表显示未读角标。 + /// + public void MarkHasNewMessage() + { + HasNewMessage = true; + if (UnreadCount <= 0) + UnreadCount = 1; + } + + private void Apply(WorkOrderSummaryDto dto) + { + AppId = dto.AppId; + WechatOrderId = dto.WechatOrderId; + Status = dto.Status; + StatusText = string.IsNullOrWhiteSpace(dto.StatusText) + ? GetStatusText(dto.Status) + : dto.StatusText; + UpdatedAt = ParseTimestamp(dto.UpdatedAt); + LastMessageAt = ParseTimestamp(dto.LastMessageAt); + LastReplyAt = ParseNullableTimestamp(dto.LastReplyAt); + LastSenderType = dto.LastSenderType; + UnreadCount = Math.Max(0, dto.UnreadCount); + Version = dto.Version; + } + + private bool IsAtLeastCurrentVersion(string incomingVersion) + { + if (string.IsNullOrWhiteSpace(incomingVersion)) + return true; + if (!long.TryParse(incomingVersion, out var incoming)) + return true; + if (!long.TryParse(Version, out var current)) + return true; + return incoming >= current; + } + + private static DateTimeOffset ParseTimestamp(string value) + { + if (DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, + out var parsed)) + { + return parsed; + } + return default; + } + + private static DateTimeOffset? ParseNullableTimestamp(string value) + { + var parsed = ParseTimestamp(value); + return parsed == default ? (DateTimeOffset?)null : parsed; + } + + private static string GetStatusText(string status) + { + if (string.Equals(status, "waiting_reply", StringComparison.OrdinalIgnoreCase)) + return "待客服回复"; + if (string.Equals(status, "replied", StringComparison.OrdinalIgnoreCase)) + return "客服已回复"; + if (string.Equals(status, "completed", StringComparison.OrdinalIgnoreCase)) + return "已完结"; + return string.IsNullOrWhiteSpace(status) ? "状态未知" : status; + } +} diff --git a/OrderSupportTicket/Views/OrderSupportTicketView.xaml b/OrderSupportTicket/Views/OrderSupportTicketView.xaml new file mode 100644 index 0000000..8a1b1d6 --- /dev/null +++ b/OrderSupportTicket/Views/OrderSupportTicketView.xaml @@ -0,0 +1,912 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + +