780 lines
27 KiB
C#
780 lines
27 KiB
C#
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);
|
|
}
|