增加 sse服务端测试
This commit is contained in:
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。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user