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); }