using System.Globalization;
using System.Text;
using OrderSupportTicket.TestServer.Contracts;
using OrderSupportTicket.TestServer.Services;
namespace OrderSupportTicket.TestServer.Data;
///
/// 纯内存工单存储。所有账号、工单、留言和幂等回执仅在当前进程内有效。
///
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 _orders =
new(StringComparer.Ordinal);
private readonly Dictionary _createReceipts =
new(StringComparer.Ordinal);
private readonly Dictionary _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 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 NormalizeAppIds(IEnumerable? appIds)
{
var normalized = (appIds ?? Enumerable.Empty())
.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 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);
}