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。 } } }