From d3488c98f50d790ed822e63b26e6e17c8b46ae36 Mon Sep 17 00:00:00 2001 From: Chfei Date: Sun, 18 Oct 2026 20:36:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20sse=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApiException.cs | 18 + .../ContractValidator.cs | 156 ++++ .../Contracts/ApiContracts.cs | 406 +++++++++ .../Data/InMemoryTicketStore.cs | 779 ++++++++++++++++++ .../Endpoints/TicketApiEndpoints.cs | 218 +++++ .../OrderSupportTicket.TestServer.csproj | 16 + test/OrderSupportTicket.TestServer/Program.cs | 104 +++ .../Properties/launchSettings.json | 15 + .../Services/TicketEventHub.cs | 92 +++ .../wwwroot/index.html | 205 +++++ 10 files changed, 2009 insertions(+) create mode 100644 test/OrderSupportTicket.TestServer/ApiException.cs create mode 100644 test/OrderSupportTicket.TestServer/ContractValidator.cs create mode 100644 test/OrderSupportTicket.TestServer/Contracts/ApiContracts.cs create mode 100644 test/OrderSupportTicket.TestServer/Data/InMemoryTicketStore.cs create mode 100644 test/OrderSupportTicket.TestServer/Endpoints/TicketApiEndpoints.cs create mode 100644 test/OrderSupportTicket.TestServer/OrderSupportTicket.TestServer.csproj create mode 100644 test/OrderSupportTicket.TestServer/Program.cs create mode 100644 test/OrderSupportTicket.TestServer/Properties/launchSettings.json create mode 100644 test/OrderSupportTicket.TestServer/Services/TicketEventHub.cs create mode 100644 test/OrderSupportTicket.TestServer/wwwroot/index.html diff --git a/test/OrderSupportTicket.TestServer/ApiException.cs b/test/OrderSupportTicket.TestServer/ApiException.cs new file mode 100644 index 0000000..6ee5e69 --- /dev/null +++ b/test/OrderSupportTicket.TestServer/ApiException.cs @@ -0,0 +1,18 @@ +namespace OrderSupportTicket.TestServer; + +/// +/// 测试服务端主动返回的业务错误。中间件会转换为客户端可解析的 JSON。 +/// +public sealed class ApiException : Exception +{ + public ApiException(int statusCode, string message, string code) + : base(message) + { + StatusCode = statusCode; + Code = code; + } + + public int StatusCode { get; } + + public string Code { get; } +} diff --git a/test/OrderSupportTicket.TestServer/ContractValidator.cs b/test/OrderSupportTicket.TestServer/ContractValidator.cs new file mode 100644 index 0000000..4f61718 --- /dev/null +++ b/test/OrderSupportTicket.TestServer/ContractValidator.cs @@ -0,0 +1,156 @@ +using System.Text.RegularExpressions; + +namespace OrderSupportTicket.TestServer; + +/// +/// 与客户端 OrderSupportTicket.Services.TicketApiService 保持一致的参数规则。 +/// +public static class ContractValidator +{ + private static readonly Regex AppIdRegex = + new(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex OrderIdRegex = + new(@"^[0-9]{10,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex PurchaseAccountRegex = + new(@"^[\p{L}\p{N}_.@-]{2,120}$", RegexOptions.Compiled); + + private static readonly Regex OperationIdRegex = + new(@"^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly HashSet Statuses = + new(StringComparer.OrdinalIgnoreCase) + { + "waiting_reply", + "replied", + "completed" + }; + + public static string PurchaseAccount(string? value) + { + var normalized = value?.Trim(); + if (string.IsNullOrWhiteSpace(normalized) || !PurchaseAccountRegex.IsMatch(normalized)) + { + throw BadRequest( + "purchaseAccount 必填,且只能为 2~120 位中英文、数字或 _.@-。", + "invalid_purchase_account"); + } + + return normalized; + } + + public static string AppId(string? value) + { + var normalized = value?.Trim(); + if (string.IsNullOrWhiteSpace(normalized) || !AppIdRegex.IsMatch(normalized)) + { + throw BadRequest( + "appId 必须为 wx 加 16 位小写十六进制字符。", + "invalid_app_id"); + } + + return normalized; + } + + public static string WechatOrderId(string? value) + { + var normalized = value?.Trim(); + if (string.IsNullOrWhiteSpace(normalized) || !OrderIdRegex.IsMatch(normalized)) + { + throw BadRequest( + "wechatOrderId 必须保持为 10~64 位数字字符串。", + "invalid_wechat_order_id"); + } + + return normalized; + } + + public static string Description(string? value) + { + var normalized = value?.Trim(); + if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > 2000) + { + throw BadRequest( + "description 必须为 1~2000 字。", + "invalid_description"); + } + + return normalized; + } + + public static string MessageText(string? value) + { + var normalized = value?.Trim(); + if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > 4000) + { + throw BadRequest( + "text 必须为 1~4000 字。", + "invalid_message_text"); + } + + return normalized; + } + + public static string? OptionalOperationId(string? value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return OperationId(value, parameterName); + } + + public static string OperationId(string? value, string parameterName) + { + var normalized = value?.Trim(); + if (string.IsNullOrWhiteSpace(normalized) || !OperationIdRegex.IsMatch(normalized)) + { + throw BadRequest( + parameterName + " 必须为 1~128 位字母、数字或 _.:-。", + "invalid_operation_id"); + } + + return normalized; + } + + public static string? Status(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var normalized = value.Trim().ToLowerInvariant(); + if (!Statuses.Contains(normalized)) + { + throw BadRequest( + "status 只能为 waiting_reply、replied 或 completed。", + "invalid_status"); + } + + return normalized; + } + + public static int Limit(int value, int fallback) + { + if (value == 0) + { + value = fallback; + } + + if (value is < 1 or > 100) + { + throw BadRequest("limit 必须为 1~100。", "invalid_limit"); + } + + return value; + } + + public static ApiException BadRequest(string message, string code) => + new(StatusCodes.Status400BadRequest, message, code); + + public static ApiException NotFound(string message, string code = "work_order_not_found") => + new(StatusCodes.Status404NotFound, message, code); +} diff --git a/test/OrderSupportTicket.TestServer/Contracts/ApiContracts.cs b/test/OrderSupportTicket.TestServer/Contracts/ApiContracts.cs new file mode 100644 index 0000000..370dd3c --- /dev/null +++ b/test/OrderSupportTicket.TestServer/Contracts/ApiContracts.cs @@ -0,0 +1,406 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace OrderSupportTicket.TestServer.Contracts; + +public sealed class CreateWorkOrderRequest +{ + [Required] + [JsonPropertyName("purchaseAccount")] + public string? PurchaseAccount { get; set; } + + [Required] + [JsonPropertyName("appId")] + public string? AppId { get; set; } + + [Required] + [JsonPropertyName("wechatOrderId")] + public string? WechatOrderId { get; set; } + + [Required] + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("clientRequestId")] + public string? ClientRequestId { get; set; } +} + +public sealed class ListWorkOrdersRequest +{ + [Required] + [JsonPropertyName("purchaseAccount")] + public string? PurchaseAccount { get; set; } + + [Required] + [JsonPropertyName("appIds")] + public List? AppIds { get; set; } + + [JsonPropertyName("wechatOrderId")] + public string? WechatOrderId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [Range(1, 100)] + [JsonPropertyName("limit")] + public int Limit { get; set; } = 20; + + [JsonPropertyName("cursor")] + public string? Cursor { get; set; } +} + +public sealed class GetMessagesRequest +{ + [Required] + [JsonPropertyName("purchaseAccount")] + public string? PurchaseAccount { get; set; } + + [Required] + [JsonPropertyName("appId")] + public string? AppId { get; set; } + + [Required] + [JsonPropertyName("wechatOrderId")] + public string? WechatOrderId { get; set; } + + [Range(1, 100)] + [JsonPropertyName("limit")] + public int Limit { get; set; } = 50; + + [JsonPropertyName("before")] + public string? Before { get; set; } +} + +public sealed class SendMessageRequest +{ + [Required] + [JsonPropertyName("purchaseAccount")] + public string? PurchaseAccount { get; set; } + + [Required] + [JsonPropertyName("appId")] + public string? AppId { get; set; } + + [Required] + [JsonPropertyName("clientMessageId")] + public string? ClientMessageId { get; set; } + + [Required] + [JsonPropertyName("text")] + public string? Text { get; set; } +} + +public sealed class MarkReadRequest +{ + [Required] + [JsonPropertyName("purchaseAccount")] + public string? PurchaseAccount { get; set; } + + [Required] + [JsonPropertyName("appId")] + public string? AppId { get; set; } + + [Range(1, int.MaxValue)] + [JsonPropertyName("throughSequenceNo")] + public int ThroughSequenceNo { get; set; } +} + +public sealed class WorkOrderListResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } = true; + + [JsonPropertyName("items")] + public List Items { get; set; } = []; + + [JsonPropertyName("hasMore")] + public bool HasMore { get; set; } + + [JsonPropertyName("nextCursor")] + public string? NextCursor { get; set; } + + [JsonPropertyName("identityVerified")] + public bool IdentityVerified { get; set; } +} + +public sealed class WorkOrderSummaryDto +{ + [JsonPropertyName("appId")] + public string AppId { get; set; } = string.Empty; + + [JsonPropertyName("wechatOrderId")] + public string WechatOrderId { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("statusText")] + public string StatusText { get; set; } = string.Empty; + + [JsonPropertyName("createdAt")] + public string CreatedAt { get; set; } = string.Empty; + + [JsonPropertyName("updatedAt")] + public string UpdatedAt { get; set; } = string.Empty; + + [JsonPropertyName("lastMessageAt")] + public string LastMessageAt { get; set; } = string.Empty; + + [JsonPropertyName("lastReplyAt")] + public string? LastReplyAt { get; set; } + + [JsonPropertyName("lastSenderType")] + public string? LastSenderType { get; set; } + + [JsonPropertyName("unreadCount")] + public int UnreadCount { get; set; } + + [JsonPropertyName("version")] + public string Version { get; set; } = "1"; +} + +public sealed class WorkOrderMessagesResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } = true; + + [JsonPropertyName("appId")] + public string AppId { get; set; } = string.Empty; + + [JsonPropertyName("wechatOrderId")] + public string WechatOrderId { get; set; } = string.Empty; + + [JsonPropertyName("items")] + public List Items { get; set; } = []; + + [JsonPropertyName("hasMore")] + public bool HasMore { get; set; } + + [JsonPropertyName("nextBefore")] + public string? NextBefore { get; set; } + + [JsonPropertyName("identityVerified")] + public bool IdentityVerified { get; set; } +} + +public sealed class TicketMessageDto +{ + [JsonPropertyName("messageId")] + public string MessageId { get; set; } = string.Empty; + + [JsonPropertyName("sequenceNo")] + public int SequenceNo { get; set; } + + [JsonPropertyName("senderType")] + public string SenderType { get; set; } = string.Empty; + + [JsonPropertyName("senderName")] + public string SenderName { get; set; } = string.Empty; + + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; + + [JsonPropertyName("recalled")] + public bool Recalled { get; set; } + + [JsonPropertyName("createdAt")] + public string CreatedAt { get; set; } = string.Empty; +} + +public sealed class CreateWorkOrderResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } = true; + + [JsonPropertyName("operation")] + public string Operation { get; set; } = string.Empty; + + [JsonPropertyName("appId")] + public string AppId { get; set; } = string.Empty; + + [JsonPropertyName("wechatOrderId")] + public string WechatOrderId { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("replayed")] + public bool Replayed { get; set; } + + [JsonPropertyName("identityVerified")] + public bool IdentityVerified { get; set; } +} + +public sealed class SendMessageResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } = true; + + [JsonPropertyName("appId")] + public string AppId { get; set; } = string.Empty; + + [JsonPropertyName("wechatOrderId")] + public string WechatOrderId { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public TicketMessageDto? Message { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("reopened")] + public bool Reopened { get; set; } + + [JsonPropertyName("replayed")] + public bool Replayed { get; set; } + + [JsonPropertyName("identityVerified")] + public bool IdentityVerified { get; set; } +} + +public sealed class ApiSuccessResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } = true; +} + +public sealed class ApiErrorResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("error")] + public string Error { get; set; } = string.Empty; + + [JsonPropertyName("code")] + public string Code { get; set; } = string.Empty; +} + +public sealed class TestSupportReplyRequest +{ + [Required] + [JsonPropertyName("purchaseAccount")] + public string? PurchaseAccount { get; set; } + + [Required] + [JsonPropertyName("appId")] + public string? AppId { get; set; } + + [Required] + [JsonPropertyName("wechatOrderId")] + public string? WechatOrderId { get; set; } + + [Required] + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("senderName")] + public string? SenderName { get; set; } + public TestSupportReplyRequest() + { + PurchaseAccount = "18990991234"; + SenderName = "丽丽"; + } +} + +public sealed class TestCompleteTicketRequest +{ + [Required] + [JsonPropertyName("purchaseAccount")] + public string? PurchaseAccount { get; set; } + + [Required] + [JsonPropertyName("appId")] + public string? AppId { get; set; } + + [Required] + [JsonPropertyName("wechatOrderId")] + public string? WechatOrderId { get; set; } +} + +public sealed class TestSupportReplyResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } = true; + + [JsonPropertyName("message")] + public TicketMessageDto? Message { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; +} + +public sealed class TestStateResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } = true; + + [JsonPropertyName("orders")] + public List Orders { get; set; } = []; + + [JsonPropertyName("sseSubscribers")] + public int SseSubscribers { get; set; } +} + +public sealed class TestWorkOrderStateDto +{ + [JsonPropertyName("purchaseAccount")] + public string PurchaseAccount { get; set; } = string.Empty; + + [JsonPropertyName("appId")] + public string AppId { get; set; } = string.Empty; + + [JsonPropertyName("wechatOrderId")] + public string WechatOrderId { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("readThroughSequenceNo")] + public int ReadThroughSequenceNo { get; set; } + + [JsonPropertyName("messages")] + public List Messages { get; set; } = []; +} + +public sealed class TicketRealtimeNotificationDto +{ + [JsonPropertyName("eventId")] + public string EventId { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("appId")] + public string AppId { get; set; } = string.Empty; + + [JsonPropertyName("wechatOrderId")] + public string WechatOrderId { get; set; } = string.Empty; + + [JsonPropertyName("messageId")] + public string? MessageId { get; set; } + + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("occurredAt")] + public string OccurredAt { get; set; } = string.Empty; +} diff --git a/test/OrderSupportTicket.TestServer/Data/InMemoryTicketStore.cs b/test/OrderSupportTicket.TestServer/Data/InMemoryTicketStore.cs new file mode 100644 index 0000000..43b9973 --- /dev/null +++ b/test/OrderSupportTicket.TestServer/Data/InMemoryTicketStore.cs @@ -0,0 +1,779 @@ +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); +} diff --git a/test/OrderSupportTicket.TestServer/Endpoints/TicketApiEndpoints.cs b/test/OrderSupportTicket.TestServer/Endpoints/TicketApiEndpoints.cs new file mode 100644 index 0000000..7f2b6eb --- /dev/null +++ b/test/OrderSupportTicket.TestServer/Endpoints/TicketApiEndpoints.cs @@ -0,0 +1,218 @@ +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using OrderSupportTicket.TestServer.Contracts; +using OrderSupportTicket.TestServer.Data; +using OrderSupportTicket.TestServer.Services; + +namespace OrderSupportTicket.TestServer.Endpoints; + +public static class TicketApiEndpoints +{ + private const string Tag = "客户工单 API v2"; + private const string TestTag = "测试辅助接口"; + + private static readonly JsonSerializerOptions EventJsonOptions = + new(JsonSerializerDefaults.Web); + + public static IEndpointRouteBuilder MapTicketApiEndpoints( + this IEndpointRouteBuilder endpoints) + { + var api = endpoints.MapGroup("/api/open/v2/customer-work-orders") + .WithTags(Tag); + + api.MapPost("/list", (ListWorkOrdersRequest request, InMemoryTicketStore store) => + Results.Ok(store.List(request))) + .WithName("ListWorkOrders") + .WithSummary("查询工单摘要列表") + .WithDescription("按拍单账号、APPID、订单号和状态筛选,支持游标分页。") + .Produces() + .Produces(StatusCodes.Status400BadRequest); + + api.MapPost("/messages", (GetMessagesRequest request, InMemoryTicketStore store) => + Results.Ok(store.GetMessages(request))) + .WithName("GetWorkOrderMessages") + .WithSummary("查询指定订单的公开留言") + .WithDescription("返回按 sequenceNo 升序排列的留言;before 使用上一次返回的 nextBefore。") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + api.MapPost("", (CreateWorkOrderRequest request, InMemoryTicketStore store) => + Results.Ok(store.Create(request))) + .WithName("CreateWorkOrder") + .WithSummary("创建工单或重新开启已完结工单") + .WithDescription("clientRequestId 用于幂等重放;同一订单已有未完结工单时返回 existing,不重复追加描述。") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + api.MapPost( + "/orders/{wechatOrderId}/messages", + (string wechatOrderId, SendMessageRequest request, InMemoryTicketStore store) => + Results.Ok(store.Send(wechatOrderId, request))) + .WithName("SendWorkOrderMessage") + .WithSummary("提交客户留言") + .WithDescription("clientMessageId 用于幂等重放;已完结工单收到留言后会重新开启。") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + api.MapPost( + "/orders/{wechatOrderId}/read", + (string wechatOrderId, MarkReadRequest request, InMemoryTicketStore store) => + Results.Ok(store.MarkRead(wechatOrderId, request))) + .WithName("MarkWorkOrderRead") + .WithSummary("标记公开留言已读") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + api.MapGet( + "/events", + (string purchaseAccount, + [FromQuery(Name = "appId")] string[] appId, + TicketEventHub eventHub, + HttpContext context) => + StreamEventsAsync( + context, + purchaseAccount, + appId, + eventHub)) + .WithName("SubscribeWorkOrderEvents") + .WithSummary("SSE 实时通知") + .WithDescription( + "浏览器 EventSource 连接地址。重复传入 appId,例如:" + + "?purchaseAccount=18990991234&appId=wx316b1291351558aa") + .Produces(StatusCodes.Status200OK, contentType: "text/event-stream") + .Produces(StatusCodes.Status400BadRequest); + + var test = endpoints.MapGroup("/api/test") + .WithTags(TestTag); + + test.MapPost( + "/support-replies", + (TestSupportReplyRequest request, InMemoryTicketStore store) => + Results.Ok(store.SupportReply(request))) + .WithName("TestAddSupportReply") + .WithSummary("模拟客服回复并推送 SSE") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + test.MapPost( + "/orders/complete", + (TestCompleteTicketRequest request, InMemoryTicketStore store) => + Results.Ok(store.Complete(request))) + .WithName("TestCompleteWorkOrder") + .WithSummary("模拟完结工单并推送 SSE") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + test.MapPost( + "/reset", + (InMemoryTicketStore store) => Results.Ok(store.Reset())) + .WithName("TestResetData") + .WithSummary("重置为初始内存数据") + .Produces(); + + test.MapGet( + "/state", + (InMemoryTicketStore store) => Results.Ok(store.Snapshot())) + .WithName("TestGetState") + .WithSummary("查看当前全部内存数据") + .Produces(); + + return endpoints; + } + + private static async Task StreamEventsAsync( + HttpContext context, + string purchaseAccount, + string[] appIds, + TicketEventHub eventHub) + { + purchaseAccount = ContractValidator.PurchaseAccount(purchaseAccount); + if (appIds == null || appIds.Length == 0) + { + throw ContractValidator.BadRequest( + "appId 至少需要一个。", + "invalid_app_ids"); + } + + var normalizedAppIds = appIds + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(ContractValidator.AppId) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (normalizedAppIds.Count == 0 || normalizedAppIds.Count > 50) + { + throw ContractValidator.BadRequest( + "appId 必须为 1~50 个有效 APPID。", + "invalid_app_ids"); + } + + using var subscription = eventHub.Subscribe(purchaseAccount, normalizedAppIds); + var cancellationToken = context.RequestAborted; + var response = context.Response; + response.StatusCode = StatusCodes.Status200OK; + response.ContentType = "text/event-stream; charset=utf-8"; + response.Headers.CacheControl = "no-cache, no-transform"; + response.Headers.Connection = "keep-alive"; + response.Headers["X-Accel-Buffering"] = "no"; + + await response.WriteAsync(": connected\n\n", cancellationToken); + await response.Body.FlushAsync(cancellationToken); + + try + { + while (!cancellationToken.IsCancellationRequested) + { + var waitTask = subscription.Reader.WaitToReadAsync(cancellationToken).AsTask(); + var heartbeatTask = Task.Delay( + TimeSpan.FromSeconds(15), + cancellationToken); + var completed = await Task.WhenAny(waitTask, heartbeatTask); + + if (completed == waitTask) + { + if (!await waitTask) + { + break; + } + + while (subscription.Reader.TryRead(out var notification)) + { + var json = JsonSerializer.Serialize( + notification, + EventJsonOptions); + var frame = new StringBuilder() + .Append("id: ").Append(notification.EventId).Append('\n') + .Append("event: ").Append(notification.Type).Append('\n') + .Append("data: ").Append(json).Append('\n') + .Append('\n') + .ToString(); + await response.WriteAsync(frame, cancellationToken); + } + + await response.Body.FlushAsync(cancellationToken); + } + else + { + await response.WriteAsync(": keep-alive\n\n", cancellationToken); + await response.Body.FlushAsync(cancellationToken); + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // 客户端主动断开,不需要记录。 + } + catch (IOException) + { + // 客户端断开连接时 Kestrel 可能抛出 IOException。 + } + } +} + diff --git a/test/OrderSupportTicket.TestServer/OrderSupportTicket.TestServer.csproj b/test/OrderSupportTicket.TestServer/OrderSupportTicket.TestServer.csproj new file mode 100644 index 0000000..7ab8e49 --- /dev/null +++ b/test/OrderSupportTicket.TestServer/OrderSupportTicket.TestServer.csproj @@ -0,0 +1,16 @@ + + + + net8.0 + enable + enable + 12.0 + OrderSupportTicket.TestServer + OrderSupportTicket.TestServer + + + + + + + diff --git a/test/OrderSupportTicket.TestServer/Program.cs b/test/OrderSupportTicket.TestServer/Program.cs new file mode 100644 index 0000000..d330525 --- /dev/null +++ b/test/OrderSupportTicket.TestServer/Program.cs @@ -0,0 +1,104 @@ +using System.Text.Json.Serialization; +using Microsoft.OpenApi.Models; +using OrderSupportTicket.TestServer; +using OrderSupportTicket.TestServer.Contracts; +using OrderSupportTicket.TestServer.Data; +using OrderSupportTicket.TestServer.Endpoints; +using OrderSupportTicket.TestServer.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => policy + .AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod()); +}); +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.Never; +}); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "OrderSupportTicket 测试服务端", + Version = "v1", + Description = "纯内存实现客户工单 API v2;重启服务后数据恢复为初始种子数据。" + }); +}); + +var app = builder.Build(); + +app.UseCors(); + +app.Use(async (context, next) => +{ + try + { + await next(); + } + catch (ApiException exception) + { + if (context.Response.HasStarted) + { + throw; + } + + context.Response.Clear(); + context.Response.StatusCode = exception.StatusCode; + context.Response.ContentType = "application/json; charset=utf-8"; + await context.Response.WriteAsJsonAsync(new ApiErrorResponse + { + Success = false, + Message = exception.Message, + Error = exception.Message, + Code = exception.Code + }); + } + catch (BadHttpRequestException exception) + { + if (context.Response.HasStarted) + { + throw; + } + + context.Response.Clear(); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + context.Response.ContentType = "application/json; charset=utf-8"; + await context.Response.WriteAsJsonAsync(new ApiErrorResponse + { + Success = false, + Message = exception.Message, + Error = exception.Message, + Code = "bad_request" + }); + } +}); + +app.UseDefaultFiles(); +app.UseStaticFiles(); +app.UseSwagger(); +app.UseSwaggerUI(options => +{ + options.SwaggerEndpoint("/swagger/v1/swagger.json", "OrderSupportTicket Test API v1"); + options.DocumentTitle = "OrderSupportTicket 测试 API"; +}); + +app.MapGet("/health", () => Results.Ok(new +{ + success = true, + service = "OrderSupportTicket.TestServer", + storage = "in-memory", + utcNow = DateTimeOffset.UtcNow.ToString("O") +})); + +app.MapTicketApiEndpoints(); + +app.Run(); + +public partial class Program { } diff --git a/test/OrderSupportTicket.TestServer/Properties/launchSettings.json b/test/OrderSupportTicket.TestServer/Properties/launchSettings.json new file mode 100644 index 0000000..ac88ec7 --- /dev/null +++ b/test/OrderSupportTicket.TestServer/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "OrderSupportTicket.TestServer": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5188", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/test/OrderSupportTicket.TestServer/Services/TicketEventHub.cs b/test/OrderSupportTicket.TestServer/Services/TicketEventHub.cs new file mode 100644 index 0000000..48a6f5d --- /dev/null +++ b/test/OrderSupportTicket.TestServer/Services/TicketEventHub.cs @@ -0,0 +1,92 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using OrderSupportTicket.TestServer.Contracts; + +namespace OrderSupportTicket.TestServer.Services; + +/// +/// 纯内存 SSE 订阅中心。进程退出后事件和订阅全部丢失。 +/// +public sealed class TicketEventHub +{ + private readonly ConcurrentDictionary _subscriptions = new(); + + public int SubscriberCount => _subscriptions.Count; + + public TicketEventSubscription Subscribe( + string purchaseAccount, + IReadOnlyCollection appIds) + { + var id = Guid.NewGuid(); + var channel = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + var subscription = new Subscription( + purchaseAccount, + new HashSet(appIds, StringComparer.OrdinalIgnoreCase), + channel); + _subscriptions[id] = subscription; + return new TicketEventSubscription( + channel.Reader, + () => _subscriptions.TryRemove(id, out _)); + } + + public void Publish( + string purchaseAccount, + TicketRealtimeNotificationDto notification) + { + foreach (var subscription in _subscriptions.Values) + { + if (!string.Equals( + subscription.PurchaseAccount, + purchaseAccount, + StringComparison.Ordinal)) + { + continue; + } + + if (!subscription.AppIds.Contains(notification.AppId)) + { + continue; + } + + subscription.Channel.Writer.TryWrite(notification); + } + } + + private sealed record Subscription( + string PurchaseAccount, + HashSet AppIds, + Channel Channel); +} + +public sealed class TicketEventSubscription : IDisposable +{ + private readonly Action _unsubscribe; + private int _disposed; + + internal TicketEventSubscription( + ChannelReader reader, + Action unsubscribe) + { + Reader = reader; + _unsubscribe = unsubscribe; + } + + public ChannelReader Reader { get; } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _unsubscribe(); + } +} + diff --git a/test/OrderSupportTicket.TestServer/wwwroot/index.html b/test/OrderSupportTicket.TestServer/wwwroot/index.html new file mode 100644 index 0000000..84f0763 --- /dev/null +++ b/test/OrderSupportTicket.TestServer/wwwroot/index.html @@ -0,0 +1,205 @@ + + + + + +OrderSupportTicket 测试控制台 + + + +
+

OrderSupportTicket 测试控制台

纯内存数据 · 重启后恢复种子数据 · 打开 Swagger

+ +
+
+
+

请求参数

+
+ + + + + + + + + +
+

种子数据:账号 18990991234 下有两个已有工单;订单 9000000000000000001 已有一条客服回复,适合测试未读与 SSE。

+
+ + + + + + + + + +
+
+
+

接口响应

+
等待操作…
+
+
+

SSE 实时通知 未连接

+
+ + + +
+
暂无事件。连接后可点击“模拟客服回复”或“模拟完结”观察推送。
+
+
+ + +