增加 sse服务端测试

This commit is contained in:
chfei committed 2026-10-18 20:36:40 +08:00
1 parent fe87aece4e
commit d3488c98f5
10 files changed
+2009

No files matched your search

@@ -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<WorkOrderListResponse>()
.Produces<ApiErrorResponse>(StatusCodes.Status400BadRequest);
api.MapPost("/messages", (GetMessagesRequest request, InMemoryTicketStore store) =>
Results.Ok(store.GetMessages(request)))
.WithName("GetWorkOrderMessages")
.WithSummary("查询指定订单的公开留言")
.WithDescription("返回按 sequenceNo 升序排列的留言;before 使用上一次返回的 nextBefore。")
.Produces<WorkOrderMessagesResponse>()
.Produces<ApiErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ApiErrorResponse>(StatusCodes.Status404NotFound);
api.MapPost("", (CreateWorkOrderRequest request, InMemoryTicketStore store) =>
Results.Ok(store.Create(request)))
.WithName("CreateWorkOrder")
.WithSummary("创建工单或重新开启已完结工单")
.WithDescription("clientRequestId 用于幂等重放;同一订单已有未完结工单时返回 existing,不重复追加描述。")
.Produces<CreateWorkOrderResponse>()
.Produces<ApiErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ApiErrorResponse>(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<SendMessageResponse>()
.Produces<ApiErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ApiErrorResponse>(StatusCodes.Status404NotFound);
api.MapPost(
"/orders/{wechatOrderId}/read",
(string wechatOrderId, MarkReadRequest request, InMemoryTicketStore store) =>
Results.Ok(store.MarkRead(wechatOrderId, request)))
.WithName("MarkWorkOrderRead")
.WithSummary("标记公开留言已读")
.Produces<ApiSuccessResponse>()
.Produces<ApiErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ApiErrorResponse>(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<ApiErrorResponse>(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<TestSupportReplyResponse>()
.Produces<ApiErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ApiErrorResponse>(StatusCodes.Status404NotFound);
test.MapPost(
"/orders/complete",
(TestCompleteTicketRequest request, InMemoryTicketStore store) =>
Results.Ok(store.Complete(request)))
.WithName("TestCompleteWorkOrder")
.WithSummary("模拟完结工单并推送 SSE")
.Produces<WorkOrderSummaryDto>()
.Produces<ApiErrorResponse>(StatusCodes.Status400BadRequest)
.Produces<ApiErrorResponse>(StatusCodes.Status404NotFound);
test.MapPost(
"/reset",
(InMemoryTicketStore store) => Results.Ok(store.Reset()))
.WithName("TestResetData")
.WithSummary("重置为初始内存数据")
.Produces<ApiSuccessResponse>();
test.MapGet(
"/state",
(InMemoryTicketStore store) => Results.Ok(store.Snapshot()))
.WithName("TestGetState")
.WithSummary("查看当前全部内存数据")
.Produces<TestStateResponse>();
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。
}
}
}