增加 sse服务端测试
This commit is contained in:
1 parent
fe87aece4e
commit
d3488c98f5
10 files changed
+2009
No files matched your search
@@ -0,0 +1,779 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using OrderSupportTicket.TestServer.Contracts;
|
||||
using OrderSupportTicket.TestServer.Services;
|
||||
|
||||
namespace OrderSupportTicket.TestServer.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 纯内存工单存储。所有账号、工单、留言和幂等回执仅在当前进程内有效。
|
||||
/// </summary>
|
||||
public sealed class InMemoryTicketStore
|
||||
{
|
||||
private const string SeedAccount = "18990991234";
|
||||
private const string SeedAppId1 = "wx316b1291351558aa";
|
||||
private const string SeedAppId2 = "wx7999743c3d64c05c";
|
||||
private const string SeedOrderId1 = "9000000000000000001";
|
||||
private const string SeedOrderId2 = "9000000000000000002";
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly TicketEventHub _events;
|
||||
private readonly Dictionary<string, WorkOrderState> _orders =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, CreateWorkOrderResponse> _createReceipts =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, SendMessageResponse> _sendReceipts =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public InMemoryTicketStore(TicketEventHub events)
|
||||
{
|
||||
_events = events;
|
||||
Reset();
|
||||
}
|
||||
|
||||
public ApiSuccessResponse Reset()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_orders.Clear();
|
||||
_createReceipts.Clear();
|
||||
_sendReceipts.Clear();
|
||||
Seed();
|
||||
}
|
||||
|
||||
return new ApiSuccessResponse();
|
||||
}
|
||||
|
||||
public WorkOrderListResponse List(ListWorkOrdersRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
|
||||
var limit = ContractValidator.Limit(request.Limit, 20);
|
||||
var status = ContractValidator.Status(request.Status);
|
||||
var appIds = NormalizeAppIds(request.AppIds);
|
||||
var wechatOrderId = string.IsNullOrWhiteSpace(request.WechatOrderId)
|
||||
? null
|
||||
: ContractValidator.WechatOrderId(request.WechatOrderId);
|
||||
var cursor = DecodeCursor(request.Cursor);
|
||||
|
||||
List<WorkOrderState> ordered;
|
||||
lock (_gate)
|
||||
{
|
||||
ordered = _orders.Values
|
||||
.Where(item => string.Equals(
|
||||
item.PurchaseAccount,
|
||||
purchaseAccount,
|
||||
StringComparison.Ordinal))
|
||||
.Where(item => appIds.Contains(item.AppId))
|
||||
.Where(item => wechatOrderId == null
|
||||
|| string.Equals(
|
||||
item.WechatOrderId,
|
||||
wechatOrderId,
|
||||
StringComparison.Ordinal))
|
||||
.Where(item => status == null
|
||||
|| string.Equals(item.Status, status, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.ThenBy(item => item.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var remaining = cursor == null
|
||||
? ordered
|
||||
: ordered
|
||||
.Where(item => item.UpdatedAt < cursor.UpdatedAt
|
||||
|| (item.UpdatedAt == cursor.UpdatedAt
|
||||
&& string.CompareOrdinal(item.Key, cursor.Key) > 0))
|
||||
.ToList();
|
||||
|
||||
var page = remaining.Take(limit + 1).ToList();
|
||||
var hasMore = page.Count > limit;
|
||||
if (hasMore)
|
||||
{
|
||||
page.RemoveAt(page.Count - 1);
|
||||
}
|
||||
|
||||
return new WorkOrderListResponse
|
||||
{
|
||||
Items = page.Select(ToSummaryDto).ToList(),
|
||||
HasMore = hasMore,
|
||||
NextCursor = hasMore && page.Count > 0
|
||||
? EncodeCursor(page[^1])
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
public WorkOrderMessagesResponse GetMessages(GetMessagesRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
|
||||
var appId = ContractValidator.AppId(request.AppId);
|
||||
var wechatOrderId = ContractValidator.WechatOrderId(request.WechatOrderId);
|
||||
var limit = ContractValidator.Limit(request.Limit, 50);
|
||||
var before = ParseBefore(request.Before);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var order = FindOrderOrThrow(purchaseAccount, appId, wechatOrderId);
|
||||
var messages = order.Messages
|
||||
.Where(message => before == null || message.SequenceNo < before.Value)
|
||||
.OrderBy(message => message.SequenceNo)
|
||||
.ToList();
|
||||
var hasMore = messages.Count > limit;
|
||||
var page = hasMore
|
||||
? messages.Skip(messages.Count - limit).ToList()
|
||||
: messages;
|
||||
|
||||
return new WorkOrderMessagesResponse
|
||||
{
|
||||
AppId = appId,
|
||||
WechatOrderId = wechatOrderId,
|
||||
Items = page.Select(ToMessageDto).ToList(),
|
||||
HasMore = hasMore,
|
||||
NextBefore = hasMore && page.Count > 0
|
||||
? page[0].SequenceNo.ToString(CultureInfo.InvariantCulture)
|
||||
: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public CreateWorkOrderResponse Create(CreateWorkOrderRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
|
||||
var appId = ContractValidator.AppId(request.AppId);
|
||||
var wechatOrderId = ContractValidator.WechatOrderId(request.WechatOrderId);
|
||||
var description = ContractValidator.Description(request.Description);
|
||||
var clientRequestId = ContractValidator.OptionalOperationId(
|
||||
request.ClientRequestId,
|
||||
nameof(request.ClientRequestId));
|
||||
var receiptKey = clientRequestId == null
|
||||
? null
|
||||
: purchaseAccount + "|" + clientRequestId;
|
||||
TicketRealtimeNotificationDto? notification = null;
|
||||
CreateWorkOrderResponse response;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (receiptKey != null
|
||||
&& _createReceipts.TryGetValue(receiptKey, out var existingReceipt))
|
||||
{
|
||||
return CloneCreateResponse(existingReceipt, replayed: true);
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var order = FindOrder(purchaseAccount, appId, wechatOrderId);
|
||||
if (order == null)
|
||||
{
|
||||
order = new WorkOrderState
|
||||
{
|
||||
PurchaseAccount = purchaseAccount,
|
||||
AppId = appId,
|
||||
WechatOrderId = wechatOrderId,
|
||||
Status = "waiting_reply",
|
||||
Version = 1,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
_orders[order.Key] = order;
|
||||
var message = AddMessage(order, "customer", purchaseAccount, description, now);
|
||||
response = new CreateWorkOrderResponse
|
||||
{
|
||||
Operation = "created",
|
||||
AppId = appId,
|
||||
WechatOrderId = wechatOrderId,
|
||||
Status = order.Status,
|
||||
MessageId = message.MessageId,
|
||||
Version = order.Version.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
notification = BuildNotification(
|
||||
"relay.ticket.message_added",
|
||||
order,
|
||||
message,
|
||||
now);
|
||||
}
|
||||
else if (string.Equals(order.Status, "completed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
order.Status = "waiting_reply";
|
||||
var message = AddMessage(order, "customer", purchaseAccount, description, now);
|
||||
order.Version++;
|
||||
order.UpdatedAt = now;
|
||||
response = new CreateWorkOrderResponse
|
||||
{
|
||||
Operation = "reopened",
|
||||
AppId = appId,
|
||||
WechatOrderId = wechatOrderId,
|
||||
Status = order.Status,
|
||||
MessageId = message.MessageId,
|
||||
Version = order.Version.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
notification = BuildNotification(
|
||||
"relay.ticket.message_added",
|
||||
order,
|
||||
message,
|
||||
now);
|
||||
}
|
||||
else
|
||||
{
|
||||
response = new CreateWorkOrderResponse
|
||||
{
|
||||
Operation = "existing",
|
||||
AppId = appId,
|
||||
WechatOrderId = wechatOrderId,
|
||||
Status = order.Status,
|
||||
MessageId = null,
|
||||
Version = order.Version.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
if (receiptKey != null)
|
||||
{
|
||||
_createReceipts[receiptKey] = CloneCreateResponse(response, replayed: false);
|
||||
}
|
||||
}
|
||||
|
||||
if (notification != null)
|
||||
{
|
||||
_events.Publish(purchaseAccount, notification);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public SendMessageResponse Send(string wechatOrderId, SendMessageRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
|
||||
var appId = ContractValidator.AppId(request.AppId);
|
||||
var orderId = ContractValidator.WechatOrderId(wechatOrderId);
|
||||
var clientMessageId = ContractValidator.OperationId(
|
||||
request.ClientMessageId,
|
||||
nameof(request.ClientMessageId));
|
||||
var text = ContractValidator.MessageText(request.Text);
|
||||
var receiptKey = purchaseAccount + "|" + clientMessageId;
|
||||
TicketRealtimeNotificationDto? notification = null;
|
||||
SendMessageResponse response;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_sendReceipts.TryGetValue(receiptKey, out var existingReceipt))
|
||||
{
|
||||
return CloneSendResponse(existingReceipt, replayed: true);
|
||||
}
|
||||
|
||||
var order = FindOrderOrThrow(purchaseAccount, appId, orderId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var reopened = string.Equals(
|
||||
order.Status,
|
||||
"completed",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
order.Status = "waiting_reply";
|
||||
var message = AddMessage(order, "customer", purchaseAccount, text, now);
|
||||
order.Version++;
|
||||
order.UpdatedAt = now;
|
||||
response = new SendMessageResponse
|
||||
{
|
||||
AppId = appId,
|
||||
WechatOrderId = orderId,
|
||||
Message = ToMessageDto(message),
|
||||
Status = order.Status,
|
||||
Version = order.Version.ToString(CultureInfo.InvariantCulture),
|
||||
Reopened = reopened
|
||||
};
|
||||
_sendReceipts[receiptKey] = CloneSendResponse(response, replayed: false);
|
||||
notification = BuildNotification(
|
||||
"relay.ticket.message_added",
|
||||
order,
|
||||
message,
|
||||
now);
|
||||
}
|
||||
|
||||
_events.Publish(purchaseAccount, notification);
|
||||
return response;
|
||||
}
|
||||
|
||||
public ApiSuccessResponse MarkRead(string wechatOrderId, MarkReadRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
|
||||
var appId = ContractValidator.AppId(request.AppId);
|
||||
var orderId = ContractValidator.WechatOrderId(wechatOrderId);
|
||||
if (request.ThroughSequenceNo < 1)
|
||||
{
|
||||
throw ContractValidator.BadRequest(
|
||||
"throughSequenceNo 必须大于 0。",
|
||||
"invalid_sequence_no");
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var order = FindOrderOrThrow(purchaseAccount, appId, orderId);
|
||||
order.ReadThroughSequenceNo = Math.Max(
|
||||
order.ReadThroughSequenceNo,
|
||||
request.ThroughSequenceNo);
|
||||
}
|
||||
|
||||
return new ApiSuccessResponse();
|
||||
}
|
||||
|
||||
public TestSupportReplyResponse SupportReply(TestSupportReplyRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
|
||||
var appId = ContractValidator.AppId(request.AppId);
|
||||
var wechatOrderId = ContractValidator.WechatOrderId(request.WechatOrderId);
|
||||
var text = ContractValidator.MessageText(request.Text);
|
||||
var senderName = string.IsNullOrWhiteSpace(request.SenderName)
|
||||
? "测试客服"
|
||||
: request.SenderName.Trim();
|
||||
TicketRealtimeNotificationDto? notification = null;
|
||||
TestSupportReplyResponse response;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var order = FindOrderOrThrow(purchaseAccount, appId, wechatOrderId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
order.Status = "replied";
|
||||
var message = AddMessage(order, "support", senderName, text, now);
|
||||
order.Version++;
|
||||
order.UpdatedAt = now;
|
||||
response = new TestSupportReplyResponse
|
||||
{
|
||||
Message = ToMessageDto(message),
|
||||
Status = order.Status,
|
||||
Version = order.Version.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
notification = BuildNotification(
|
||||
"relay.ticket.message_added",
|
||||
order,
|
||||
message,
|
||||
now);
|
||||
}
|
||||
|
||||
_events.Publish(purchaseAccount, notification);
|
||||
return response;
|
||||
}
|
||||
|
||||
public WorkOrderSummaryDto Complete(TestCompleteTicketRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
|
||||
var appId = ContractValidator.AppId(request.AppId);
|
||||
var wechatOrderId = ContractValidator.WechatOrderId(request.WechatOrderId);
|
||||
TicketRealtimeNotificationDto notification;
|
||||
WorkOrderSummaryDto response;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var order = FindOrderOrThrow(purchaseAccount, appId, wechatOrderId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
order.Status = "completed";
|
||||
order.Version++;
|
||||
order.UpdatedAt = now;
|
||||
response = ToSummaryDto(order);
|
||||
notification = BuildNotification(
|
||||
"relay.ticket.status_changed",
|
||||
order,
|
||||
null,
|
||||
now);
|
||||
}
|
||||
|
||||
_events.Publish(purchaseAccount, notification);
|
||||
return response;
|
||||
}
|
||||
|
||||
public TestStateResponse Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return new TestStateResponse
|
||||
{
|
||||
Orders = _orders.Values
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.Select(item => new TestWorkOrderStateDto
|
||||
{
|
||||
PurchaseAccount = item.PurchaseAccount,
|
||||
AppId = item.AppId,
|
||||
WechatOrderId = item.WechatOrderId,
|
||||
Status = item.Status,
|
||||
Version = item.Version.ToString(CultureInfo.InvariantCulture),
|
||||
ReadThroughSequenceNo = item.ReadThroughSequenceNo,
|
||||
Messages = item.Messages
|
||||
.OrderBy(message => message.SequenceNo)
|
||||
.Select(ToMessageDto)
|
||||
.ToList()
|
||||
})
|
||||
.ToList(),
|
||||
SseSubscribers = _events.SubscriberCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void Seed()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var order1 = new WorkOrderState
|
||||
{
|
||||
PurchaseAccount = SeedAccount,
|
||||
AppId = SeedAppId1,
|
||||
WechatOrderId = SeedOrderId1,
|
||||
Status = "replied",
|
||||
Version = 2,
|
||||
CreatedAt = now.AddHours(-4),
|
||||
UpdatedAt = now.AddHours(-1),
|
||||
ReadThroughSequenceNo = 0
|
||||
};
|
||||
AddMessage(
|
||||
order1,
|
||||
"customer",
|
||||
SeedAccount,
|
||||
"你好,请问这个订单什么时候发货?",
|
||||
now.AddHours(-4));
|
||||
AddMessage(
|
||||
order1,
|
||||
"support",
|
||||
"测试客服",
|
||||
"您好,订单已为您加急处理,预计今天发出。",
|
||||
now.AddHours(-1));
|
||||
_orders[order1.Key] = order1;
|
||||
|
||||
var order2 = new WorkOrderState
|
||||
{
|
||||
PurchaseAccount = SeedAccount,
|
||||
AppId = SeedAppId2,
|
||||
WechatOrderId = SeedOrderId2,
|
||||
Status = "waiting_reply",
|
||||
Version = 1,
|
||||
CreatedAt = now.AddHours(-2),
|
||||
UpdatedAt = now.AddHours(-2),
|
||||
ReadThroughSequenceNo = 0
|
||||
};
|
||||
AddMessage(
|
||||
order2,
|
||||
"customer",
|
||||
SeedAccount,
|
||||
"抽纸包装破损了,可以补发吗?",
|
||||
now.AddHours(-2));
|
||||
_orders[order2.Key] = order2;
|
||||
}
|
||||
|
||||
private static HashSet<string> NormalizeAppIds(IEnumerable<string>? appIds)
|
||||
{
|
||||
var normalized = (appIds ?? Enumerable.Empty<string>())
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(ContractValidator.AppId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
throw ContractValidator.BadRequest(
|
||||
"appIds 至少需要一个 APPID。",
|
||||
"invalid_app_ids");
|
||||
}
|
||||
|
||||
if (normalized.Count > 50)
|
||||
{
|
||||
throw ContractValidator.BadRequest(
|
||||
"appIds 最多支持 50 个 APPID。",
|
||||
"invalid_app_ids");
|
||||
}
|
||||
|
||||
return normalized.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private WorkOrderState? FindOrder(
|
||||
string purchaseAccount,
|
||||
string appId,
|
||||
string wechatOrderId)
|
||||
{
|
||||
var key = WorkOrderState.CreateKey(purchaseAccount, appId, wechatOrderId);
|
||||
return _orders.TryGetValue(key, out var order) ? order : null;
|
||||
}
|
||||
|
||||
private WorkOrderState FindOrderOrThrow(
|
||||
string purchaseAccount,
|
||||
string appId,
|
||||
string wechatOrderId)
|
||||
{
|
||||
return FindOrder(purchaseAccount, appId, wechatOrderId)
|
||||
?? throw ContractValidator.NotFound(
|
||||
"该账号、APPID 和微信订单下尚无工单。");
|
||||
}
|
||||
|
||||
private static MessageState AddMessage(
|
||||
WorkOrderState order,
|
||||
string senderType,
|
||||
string senderName,
|
||||
string text,
|
||||
DateTimeOffset createdAt)
|
||||
{
|
||||
var sequenceNo = order.Messages.Count == 0
|
||||
? 1
|
||||
: order.Messages.Max(message => message.SequenceNo) + 1;
|
||||
var message = new MessageState
|
||||
{
|
||||
MessageId = "msg-" + Guid.NewGuid().ToString("N"),
|
||||
SequenceNo = sequenceNo,
|
||||
SenderType = senderType,
|
||||
SenderName = senderName,
|
||||
Text = text,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
order.Messages.Add(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static WorkOrderSummaryDto ToSummaryDto(WorkOrderState order)
|
||||
{
|
||||
var lastMessage = order.Messages
|
||||
.OrderByDescending(message => message.SequenceNo)
|
||||
.FirstOrDefault();
|
||||
var lastReply = order.Messages
|
||||
.Where(message => !string.Equals(
|
||||
message.SenderType,
|
||||
"customer",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.Where(message => !message.Recalled)
|
||||
.OrderByDescending(message => message.SequenceNo)
|
||||
.FirstOrDefault();
|
||||
var unreadCount = order.Messages.Count(message =>
|
||||
!string.Equals(
|
||||
message.SenderType,
|
||||
"customer",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
&& !message.Recalled
|
||||
&& message.SequenceNo > order.ReadThroughSequenceNo);
|
||||
|
||||
return new WorkOrderSummaryDto
|
||||
{
|
||||
AppId = order.AppId,
|
||||
WechatOrderId = order.WechatOrderId,
|
||||
Status = order.Status,
|
||||
StatusText = GetStatusText(order.Status),
|
||||
CreatedAt = FormatTimestamp(order.CreatedAt),
|
||||
UpdatedAt = FormatTimestamp(order.UpdatedAt),
|
||||
LastMessageAt = FormatTimestamp(
|
||||
lastMessage?.CreatedAt ?? order.CreatedAt),
|
||||
LastReplyAt = lastReply == null
|
||||
? null
|
||||
: FormatTimestamp(lastReply.CreatedAt),
|
||||
LastSenderType = lastMessage?.SenderType,
|
||||
UnreadCount = unreadCount,
|
||||
Version = order.Version.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
private static TicketMessageDto ToMessageDto(MessageState message)
|
||||
{
|
||||
return new TicketMessageDto
|
||||
{
|
||||
MessageId = message.MessageId,
|
||||
SequenceNo = message.SequenceNo,
|
||||
SenderType = message.SenderType,
|
||||
SenderName = message.SenderName,
|
||||
Text = message.Recalled ? string.Empty : message.Text,
|
||||
Recalled = message.Recalled,
|
||||
CreatedAt = FormatTimestamp(message.CreatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static TicketRealtimeNotificationDto BuildNotification(
|
||||
string type,
|
||||
WorkOrderState order,
|
||||
MessageState? message,
|
||||
DateTimeOffset occurredAt)
|
||||
{
|
||||
return new TicketRealtimeNotificationDto
|
||||
{
|
||||
EventId = "evt-" + Guid.NewGuid().ToString("N"),
|
||||
Type = type,
|
||||
AppId = order.AppId,
|
||||
WechatOrderId = order.WechatOrderId,
|
||||
MessageId = message?.MessageId,
|
||||
Version = order.Version.ToString(CultureInfo.InvariantCulture),
|
||||
OccurredAt = FormatTimestamp(occurredAt)
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetStatusText(string status)
|
||||
{
|
||||
return status.ToLowerInvariant() switch
|
||||
{
|
||||
"waiting_reply" => "待客服回复",
|
||||
"replied" => "客服已回复",
|
||||
"completed" => "已完结",
|
||||
_ => "状态未知"
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(DateTimeOffset value) =>
|
||||
value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
private static int? ParseBefore(string? before)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(before))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!int.TryParse(before, NumberStyles.None, CultureInfo.InvariantCulture, out var value)
|
||||
|| value < 1)
|
||||
{
|
||||
throw ContractValidator.BadRequest(
|
||||
"before 必须为上一页返回的 nextBefore 值。",
|
||||
"invalid_before");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string EncodeCursor(WorkOrderState order)
|
||||
{
|
||||
var raw = order.UpdatedAt.UtcDateTime.Ticks.ToString(CultureInfo.InvariantCulture)
|
||||
+ "|"
|
||||
+ order.Key;
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static ListCursor? DecodeCursor(string? cursor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cursor))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var padded = cursor.Replace('-', '+').Replace('_', '/');
|
||||
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
|
||||
var raw = Encoding.UTF8.GetString(Convert.FromBase64String(padded));
|
||||
var separator = raw.IndexOf('|');
|
||||
if (separator <= 0 || separator == raw.Length - 1)
|
||||
{
|
||||
throw new FormatException();
|
||||
}
|
||||
|
||||
var ticks = long.Parse(
|
||||
raw[..separator],
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture);
|
||||
var key = raw[(separator + 1)..];
|
||||
return new ListCursor(
|
||||
new DateTimeOffset(ticks, TimeSpan.Zero),
|
||||
key);
|
||||
}
|
||||
catch (Exception exception) when (exception is FormatException
|
||||
or OverflowException
|
||||
or ArgumentException)
|
||||
{
|
||||
throw ContractValidator.BadRequest(
|
||||
"cursor 无效,请使用服务端返回的 nextCursor。",
|
||||
"invalid_cursor");
|
||||
}
|
||||
}
|
||||
|
||||
private static CreateWorkOrderResponse CloneCreateResponse(
|
||||
CreateWorkOrderResponse source,
|
||||
bool replayed)
|
||||
{
|
||||
return new CreateWorkOrderResponse
|
||||
{
|
||||
Success = source.Success,
|
||||
Operation = source.Operation,
|
||||
AppId = source.AppId,
|
||||
WechatOrderId = source.WechatOrderId,
|
||||
Status = source.Status,
|
||||
MessageId = source.MessageId,
|
||||
Version = source.Version,
|
||||
Replayed = replayed,
|
||||
IdentityVerified = source.IdentityVerified
|
||||
};
|
||||
}
|
||||
|
||||
private static SendMessageResponse CloneSendResponse(
|
||||
SendMessageResponse source,
|
||||
bool replayed)
|
||||
{
|
||||
return new SendMessageResponse
|
||||
{
|
||||
Success = source.Success,
|
||||
AppId = source.AppId,
|
||||
WechatOrderId = source.WechatOrderId,
|
||||
Message = source.Message == null
|
||||
? null
|
||||
: new TicketMessageDto
|
||||
{
|
||||
MessageId = source.Message.MessageId,
|
||||
SequenceNo = source.Message.SequenceNo,
|
||||
SenderType = source.Message.SenderType,
|
||||
SenderName = source.Message.SenderName,
|
||||
Text = source.Message.Text,
|
||||
Recalled = source.Message.Recalled,
|
||||
CreatedAt = source.Message.CreatedAt
|
||||
},
|
||||
Status = source.Status,
|
||||
Version = source.Version,
|
||||
Reopened = source.Reopened,
|
||||
Replayed = replayed,
|
||||
IdentityVerified = source.IdentityVerified
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WorkOrderState
|
||||
{
|
||||
public string PurchaseAccount { get; init; } = string.Empty;
|
||||
|
||||
public string AppId { get; init; } = string.Empty;
|
||||
|
||||
public string WechatOrderId { get; init; } = string.Empty;
|
||||
|
||||
public string Status { get; set; } = "waiting_reply";
|
||||
|
||||
public long Version { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
public int ReadThroughSequenceNo { get; set; }
|
||||
|
||||
public List<MessageState> Messages { get; } = [];
|
||||
|
||||
public string Key => CreateKey(PurchaseAccount, AppId, WechatOrderId);
|
||||
|
||||
public static string CreateKey(
|
||||
string purchaseAccount,
|
||||
string appId,
|
||||
string wechatOrderId) =>
|
||||
purchaseAccount + "|" + appId + "|" + wechatOrderId;
|
||||
}
|
||||
|
||||
private sealed class MessageState
|
||||
{
|
||||
public string MessageId { get; init; } = string.Empty;
|
||||
|
||||
public int SequenceNo { get; init; }
|
||||
|
||||
public string SenderType { get; init; } = string.Empty;
|
||||
|
||||
public string SenderName { get; init; } = string.Empty;
|
||||
|
||||
public string Text { get; init; } = string.Empty;
|
||||
|
||||
public bool Recalled { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
private sealed record ListCursor(DateTimeOffset UpdatedAt, string Key);
|
||||
}
|
||||
Reference in new issue
Block a user