修复工单Bug
This commit is contained in:
1 parent
7982f7e348
commit
8e0cc20649
12 files changed
+602
-236
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动后根据服务端工单列表和历史缓存补齐未读角标。
|
||||
/// </summary>
|
||||
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)
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace OrderSupportTicket.Services;
|
||||
/// 基于 LaunchDarkly.EventSource 的 SSE 客户端。
|
||||
/// net462 可使用;断线自动退避重连,并携带服务端事件 ID 进行补偿。
|
||||
/// </summary>
|
||||
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();
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OrderSupportTicket.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 客户工单 v2 接口。
|
||||
/// </summary>
|
||||
public interface ITicketApiService
|
||||
{
|
||||
/// <summary>
|
||||
/// 查询工单摘要列表。
|
||||
/// </summary>
|
||||
Task<WorkOrderListResponse> GetWorkOrdersAsync(
|
||||
TicketClientOptions options,
|
||||
string wechatOrderId = null,
|
||||
string status = null,
|
||||
int limit = 20,
|
||||
string cursor = null);
|
||||
|
||||
/// <summary>
|
||||
/// 查询指定订单的公开留言板。
|
||||
/// </summary>
|
||||
Task<WorkOrderMessagesResponse> GetMessagesAsync(
|
||||
TicketClientOptions options,
|
||||
string appId,
|
||||
string wechatOrderId,
|
||||
string before = null,
|
||||
int limit = 50);
|
||||
|
||||
/// <summary>
|
||||
/// 提交问题或创建工单。
|
||||
/// </summary>
|
||||
Task<CreateWorkOrderResponse> CreateWorkOrderAsync(
|
||||
TicketClientOptions options,
|
||||
TicketOrderOption order,
|
||||
string description,
|
||||
string clientRequestId = null);
|
||||
|
||||
/// <summary>
|
||||
/// 提交客户留言。
|
||||
/// </summary>
|
||||
Task<SendMessageResponse> SendMessageAsync(
|
||||
TicketClientOptions options,
|
||||
WorkOrderSummaryDto workOrder,
|
||||
string text,
|
||||
string clientMessageId);
|
||||
|
||||
/// <summary>
|
||||
/// 标记当前订单留言已读。
|
||||
/// </summary>
|
||||
Task MarkReadAsync(
|
||||
TicketClientOptions options,
|
||||
string appId,
|
||||
string wechatOrderId,
|
||||
int throughSequenceNo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 工单接口调用失败时返回的异常。
|
||||
/// </summary>
|
||||
public sealed class TicketApiException : System.Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建工单接口异常。
|
||||
/// </summary>
|
||||
public TicketApiException(
|
||||
int statusCode,
|
||||
string message,
|
||||
string responseBody = null)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ResponseBody = responseBody;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 服务端返回的状态码;无法从 GetHtml 响应中识别时为 0。
|
||||
/// </summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务端返回的原始响应正文。
|
||||
/// </summary>
|
||||
public string ResponseBody { get; }
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using OrderSupportTicket.Models;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OrderSupportTicket.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 工单 SSE 实时通知服务。
|
||||
/// </summary>
|
||||
public interface ITicketRealtimeService : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// 收到工单变更摘要通知时触发。
|
||||
/// </summary>
|
||||
event EventHandler<TicketRealtimeNotificationEventArgs> NotificationReceived;
|
||||
|
||||
/// <summary>
|
||||
/// SSE 连接状态变化时触发。
|
||||
/// </summary>
|
||||
event EventHandler<TicketRealtimeStateChangedEventArgs> StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 当前 SSE 连接状态。
|
||||
/// </summary>
|
||||
TicketRealtimeState State { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定工单参数建立 SSE 连接。
|
||||
/// </summary>
|
||||
void Start(TicketClientOptions options);
|
||||
|
||||
/// <summary>
|
||||
/// 停止 SSE 连接。
|
||||
/// </summary>
|
||||
Task StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
|
||||
namespace OrderSupportTicket.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 工单接口调用失败时返回的异常。
|
||||
/// </summary>
|
||||
public sealed class TicketApiException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建工单接口异常。
|
||||
/// </summary>
|
||||
public TicketApiException(
|
||||
int statusCode,
|
||||
string message,
|
||||
string responseBody = null)
|
||||
: base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ResponseBody = responseBody;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 服务端返回的状态码;无法从 GetHtml 响应中识别时为 0。
|
||||
/// </summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务端返回的原始响应正文。
|
||||
/// </summary>
|
||||
public string ResponseBody { get; }
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace OrderSupportTicket.Services;
|
||||
/// <summary>
|
||||
/// 拍单软件工单 API v2 客户端。对应 CLIENT-TICKET-API-V2 正式订单协议。
|
||||
/// </summary>
|
||||
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";
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class TicketRealtimeMonitor : IDisposable
|
||||
/// <summary>
|
||||
/// 供工单页复用的实时服务,避免重复建立 SSE 连接。
|
||||
/// </summary>
|
||||
public ITicketRealtimeService RealtimeService { get; }
|
||||
public EventSourceTicketRealtimeService RealtimeService { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 收到新的工单留言通知时触发。
|
||||
@@ -132,6 +132,38 @@ public sealed class TicketRealtimeMonitor : IDisposable
|
||||
RaiseUnreadCountChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 合并历史未读数据。与实时事件产生的未读取较大值,避免重复计数。
|
||||
/// </summary>
|
||||
/// <param name="unreadByTicket">
|
||||
/// 键为 "appId|wechatOrderId",值为该工单的未读消息数。
|
||||
/// </param>
|
||||
public void MergeUnread(IDictionary<string, int> unreadByTicket)
|
||||
{
|
||||
if (unreadByTicket == null || unreadByTicket.Count == 0)
|
||||
return;
|
||||
|
||||
var changed = false;
|
||||
lock (_sync)
|
||||
{
|
||||
foreach (var item in unreadByTicket)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Key) || item.Value <= 0)
|
||||
continue;
|
||||
|
||||
_unreadMessages.TryGetValue(item.Key, out var current);
|
||||
if (item.Value <= current)
|
||||
continue;
|
||||
|
||||
_unreadMessages[item.Key] = item.Value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed)
|
||||
RaiseUnreadCountChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空全部未读提示。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OrderSupportTicket.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 工单业务服务,聚合工单 API 客户端与 SSE 实时服务。
|
||||
/// </summary>
|
||||
public sealed class TicketService : IDisposable
|
||||
{
|
||||
private readonly TicketApiService _api;
|
||||
private readonly EventSourceTicketRealtimeService _realtime;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// 创建使用默认依赖的工单业务服务。
|
||||
/// </summary>
|
||||
public TicketService(bool ownsRealtime = true)
|
||||
: this(
|
||||
new TicketApiService(),
|
||||
new EventSourceTicketRealtimeService(),
|
||||
ownsRealtime)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建工单业务服务。
|
||||
/// </summary>
|
||||
/// <param name="api">工单 API 客户端。</param>
|
||||
/// <param name="realtime">工单实时通知服务。</param>
|
||||
/// <param name="ownsRealtime">
|
||||
/// 是否由当前实例负责实时连接的停止与释放;共享连接应传入 false。
|
||||
/// </param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收到工单变更摘要通知时触发。
|
||||
/// </summary>
|
||||
public event EventHandler<TicketRealtimeNotificationEventArgs> NotificationReceived;
|
||||
|
||||
/// <summary>
|
||||
/// SSE 连接状态变化时触发。
|
||||
/// </summary>
|
||||
public event EventHandler<TicketRealtimeStateChangedEventArgs> StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 当前 SSE 连接状态。
|
||||
/// </summary>
|
||||
public TicketRealtimeState RealtimeState => _realtime.State;
|
||||
|
||||
/// <summary>
|
||||
/// 是否由当前服务实例管理实时连接的生命周期。
|
||||
/// </summary>
|
||||
public bool OwnsRealtime { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 校验工单页运行参数。
|
||||
/// </summary>
|
||||
public void ValidateOptions(TicketClientOptions options)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
TicketApiService.ValidateOptions(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询工单摘要列表。
|
||||
/// </summary>
|
||||
public Task<WorkOrderListResponse> GetWorkOrdersAsync(
|
||||
TicketClientOptions options,
|
||||
string wechatOrderId = null,
|
||||
string status = null,
|
||||
int limit = 20,
|
||||
string cursor = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _api.GetWorkOrdersAsync(
|
||||
options,
|
||||
wechatOrderId,
|
||||
status,
|
||||
limit,
|
||||
cursor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询指定订单的公开留言板。
|
||||
/// </summary>
|
||||
public Task<WorkOrderMessagesResponse> GetMessagesAsync(
|
||||
TicketClientOptions options,
|
||||
string appId,
|
||||
string wechatOrderId,
|
||||
string before = null,
|
||||
int limit = 50)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _api.GetMessagesAsync(
|
||||
options,
|
||||
appId,
|
||||
wechatOrderId,
|
||||
before,
|
||||
limit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交问题或创建工单。
|
||||
/// </summary>
|
||||
public Task<CreateWorkOrderResponse> CreateWorkOrderAsync(
|
||||
TicketClientOptions options,
|
||||
TicketOrderOption order,
|
||||
string description,
|
||||
string clientRequestId = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _api.CreateWorkOrderAsync(
|
||||
options,
|
||||
order,
|
||||
description,
|
||||
clientRequestId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提交客户留言。
|
||||
/// </summary>
|
||||
public Task<SendMessageResponse> SendMessageAsync(
|
||||
TicketClientOptions options,
|
||||
WorkOrderSummaryDto workOrder,
|
||||
string text,
|
||||
string clientMessageId)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _api.SendMessageAsync(
|
||||
options,
|
||||
workOrder,
|
||||
text,
|
||||
clientMessageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记当前订单留言已读。
|
||||
/// </summary>
|
||||
public Task MarkReadAsync(
|
||||
TicketClientOptions options,
|
||||
string appId,
|
||||
string wechatOrderId,
|
||||
int throughSequenceNo)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _api.MarkReadAsync(
|
||||
options,
|
||||
appId,
|
||||
wechatOrderId,
|
||||
throughSequenceNo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用指定工单参数建立 SSE 连接。
|
||||
/// </summary>
|
||||
public void StartRealtime(TicketClientOptions options)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ValidateOptions(options);
|
||||
_realtime.Start(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止 SSE 连接;共享连接由宿主管理时,调用方应先检查 <see cref="OwnsRealtime"/>。
|
||||
/// </summary>
|
||||
public Task StopRealtimeAsync()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return _realtime.StopAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止并重新建立 SSE 连接。
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 工单列表本地缓存的读取与未读比较。
|
||||
/// </summary>
|
||||
public sealed class WorkOrderCacheService
|
||||
{
|
||||
private const string ConfigName = "ordersupportticket";
|
||||
private static readonly object SyncRoot = new object();
|
||||
|
||||
/// <summary>
|
||||
/// 读取指定账号的工单列表缓存。
|
||||
/// </summary>
|
||||
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<WorkOrderListCache>(ConfigName);
|
||||
if (cache?.Items == null)
|
||||
return null;
|
||||
if (!string.Equals(cache.UserName, userName, StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
return cache;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 本地缓存损坏或读取失败时按无缓存处理。
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存工单列表缓存。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据服务端工单列表和历史缓存计算每个工单的未读消息数。
|
||||
/// 返回值的键为 "appId|wechatOrderId"。
|
||||
/// </summary>
|
||||
public Dictionary<string, int> BuildUnreadMap(
|
||||
IEnumerable<WorkOrderSummaryDto> workOrders,
|
||||
WorkOrderListCache cache)
|
||||
{
|
||||
var result = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var savedItems = (cache?.Items ?? new List<WorkOrderListCacheItem>())
|
||||
.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<WorkOrderSummaryDto>())
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建工单实时通知使用的唯一键。
|
||||
/// </summary>
|
||||
public static string BuildTicketKey(string appId, string wechatOrderId)
|
||||
{
|
||||
return appId + "|" + wechatOrderId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析服务端 ISO 8601 时间字符串。
|
||||
/// </summary>
|
||||
public static DateTimeOffset ParseTimestamp(string value)
|
||||
{
|
||||
if (DateTimeOffset.TryParse(
|
||||
value,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
return default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using OrderSupportTicket.Models;
|
||||
using OrderSupportTicket.Models.Api;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OrderSupportTicket.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 启动时从服务端工单列表和历史本地缓存计算未读消息。
|
||||
/// </summary>
|
||||
public sealed class WorkOrderUnreadService
|
||||
{
|
||||
private const int PageSize = 100;
|
||||
private const int MaxPages = 20;
|
||||
private readonly TicketApiService _api;
|
||||
private readonly WorkOrderCacheService _cache;
|
||||
|
||||
/// <summary>
|
||||
/// 使用默认工单 API 和缓存服务创建未读计算服务。
|
||||
/// </summary>
|
||||
public WorkOrderUnreadService()
|
||||
: this(new TicketApiService(), new WorkOrderCacheService())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建未读计算服务。
|
||||
/// </summary>
|
||||
public WorkOrderUnreadService(
|
||||
TicketApiService api,
|
||||
WorkOrderCacheService cache)
|
||||
{
|
||||
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取每个工单的未读消息数。返回值的键为 "appId|wechatOrderId"。
|
||||
/// </summary>
|
||||
public async Task<Dictionary<string, int>> GetUnreadByTicketAsync(
|
||||
TicketClientOptions options)
|
||||
{
|
||||
if (options == null)
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
|
||||
TicketApiService.ValidateOptions(options);
|
||||
var cache = _cache.Load(options.UserName);
|
||||
var workOrders = new List<WorkOrderSummaryDto>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
/// </summary>
|
||||
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<WorkOrderSummaryViewModel> _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<string> { 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<WorkOrderListCache>(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)
|
||||
{
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建工单页。ownsRealtime 为 false 时复用宿主提供的共享 SSE 连接,
|
||||
/// 页面关闭不会停止该连接。
|
||||
/// 使用工单业务服务创建工单页。
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user