Files
2026-10-18 20:36:40 +08:00

219 lines
9.3 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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。
}
}
}