219 lines
9.3 KiB
C#
219 lines
9.3 KiB
C#
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。
|
||
}
|
||
}
|
||
}
|
||
|