增加 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,18 @@
namespace OrderSupportTicket.TestServer;
/// <summary>
/// 测试服务端主动返回的业务错误。中间件会转换为客户端可解析的 JSON。
/// </summary>
public sealed class ApiException : Exception
{
public ApiException(int statusCode, string message, string code)
: base(message)
{
StatusCode = statusCode;
Code = code;
}
public int StatusCode { get; }
public string Code { get; }
}
@@ -0,0 +1,156 @@
using System.Text.RegularExpressions;
namespace OrderSupportTicket.TestServer;
/// <summary>
/// 与客户端 OrderSupportTicket.Services.TicketApiService 保持一致的参数规则。
/// </summary>
public static class ContractValidator
{
private static readonly Regex AppIdRegex =
new(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex OrderIdRegex =
new(@"^[0-9]{10,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex PurchaseAccountRegex =
new(@"^[\p{L}\p{N}_.@-]{2,120}$", RegexOptions.Compiled);
private static readonly Regex OperationIdRegex =
new(@"^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly HashSet<string> Statuses =
new(StringComparer.OrdinalIgnoreCase)
{
"waiting_reply",
"replied",
"completed"
};
public static string PurchaseAccount(string? value)
{
var normalized = value?.Trim();
if (string.IsNullOrWhiteSpace(normalized) || !PurchaseAccountRegex.IsMatch(normalized))
{
throw BadRequest(
"purchaseAccount 必填,且只能为 2~120 位中英文、数字或 _.@-。",
"invalid_purchase_account");
}
return normalized;
}
public static string AppId(string? value)
{
var normalized = value?.Trim();
if (string.IsNullOrWhiteSpace(normalized) || !AppIdRegex.IsMatch(normalized))
{
throw BadRequest(
"appId 必须为 wx 加 16 位小写十六进制字符。",
"invalid_app_id");
}
return normalized;
}
public static string WechatOrderId(string? value)
{
var normalized = value?.Trim();
if (string.IsNullOrWhiteSpace(normalized) || !OrderIdRegex.IsMatch(normalized))
{
throw BadRequest(
"wechatOrderId 必须保持为 10~64 位数字字符串。",
"invalid_wechat_order_id");
}
return normalized;
}
public static string Description(string? value)
{
var normalized = value?.Trim();
if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > 2000)
{
throw BadRequest(
"description 必须为 1~2000 字。",
"invalid_description");
}
return normalized;
}
public static string MessageText(string? value)
{
var normalized = value?.Trim();
if (string.IsNullOrWhiteSpace(normalized) || normalized.Length > 4000)
{
throw BadRequest(
"text 必须为 1~4000 字。",
"invalid_message_text");
}
return normalized;
}
public static string? OptionalOperationId(string? value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return OperationId(value, parameterName);
}
public static string OperationId(string? value, string parameterName)
{
var normalized = value?.Trim();
if (string.IsNullOrWhiteSpace(normalized) || !OperationIdRegex.IsMatch(normalized))
{
throw BadRequest(
parameterName + " 必须为 1~128 位字母、数字或 _.:-。",
"invalid_operation_id");
}
return normalized;
}
public static string? Status(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var normalized = value.Trim().ToLowerInvariant();
if (!Statuses.Contains(normalized))
{
throw BadRequest(
"status 只能为 waiting_reply、replied 或 completed。",
"invalid_status");
}
return normalized;
}
public static int Limit(int value, int fallback)
{
if (value == 0)
{
value = fallback;
}
if (value is < 1 or > 100)
{
throw BadRequest("limit 必须为 1~100。", "invalid_limit");
}
return value;
}
public static ApiException BadRequest(string message, string code) =>
new(StatusCodes.Status400BadRequest, message, code);
public static ApiException NotFound(string message, string code = "work_order_not_found") =>
new(StatusCodes.Status404NotFound, message, code);
}
@@ -0,0 +1,406 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace OrderSupportTicket.TestServer.Contracts;
public sealed class CreateWorkOrderRequest
{
[Required]
[JsonPropertyName("purchaseAccount")]
public string? PurchaseAccount { get; set; }
[Required]
[JsonPropertyName("appId")]
public string? AppId { get; set; }
[Required]
[JsonPropertyName("wechatOrderId")]
public string? WechatOrderId { get; set; }
[Required]
[JsonPropertyName("description")]
public string? Description { get; set; }
[JsonPropertyName("clientRequestId")]
public string? ClientRequestId { get; set; }
}
public sealed class ListWorkOrdersRequest
{
[Required]
[JsonPropertyName("purchaseAccount")]
public string? PurchaseAccount { get; set; }
[Required]
[JsonPropertyName("appIds")]
public List<string>? AppIds { get; set; }
[JsonPropertyName("wechatOrderId")]
public string? WechatOrderId { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; }
[Range(1, 100)]
[JsonPropertyName("limit")]
public int Limit { get; set; } = 20;
[JsonPropertyName("cursor")]
public string? Cursor { get; set; }
}
public sealed class GetMessagesRequest
{
[Required]
[JsonPropertyName("purchaseAccount")]
public string? PurchaseAccount { get; set; }
[Required]
[JsonPropertyName("appId")]
public string? AppId { get; set; }
[Required]
[JsonPropertyName("wechatOrderId")]
public string? WechatOrderId { get; set; }
[Range(1, 100)]
[JsonPropertyName("limit")]
public int Limit { get; set; } = 50;
[JsonPropertyName("before")]
public string? Before { get; set; }
}
public sealed class SendMessageRequest
{
[Required]
[JsonPropertyName("purchaseAccount")]
public string? PurchaseAccount { get; set; }
[Required]
[JsonPropertyName("appId")]
public string? AppId { get; set; }
[Required]
[JsonPropertyName("clientMessageId")]
public string? ClientMessageId { get; set; }
[Required]
[JsonPropertyName("text")]
public string? Text { get; set; }
}
public sealed class MarkReadRequest
{
[Required]
[JsonPropertyName("purchaseAccount")]
public string? PurchaseAccount { get; set; }
[Required]
[JsonPropertyName("appId")]
public string? AppId { get; set; }
[Range(1, int.MaxValue)]
[JsonPropertyName("throughSequenceNo")]
public int ThroughSequenceNo { get; set; }
}
public sealed class WorkOrderListResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; } = true;
[JsonPropertyName("items")]
public List<WorkOrderSummaryDto> Items { get; set; } = [];
[JsonPropertyName("hasMore")]
public bool HasMore { get; set; }
[JsonPropertyName("nextCursor")]
public string? NextCursor { get; set; }
[JsonPropertyName("identityVerified")]
public bool IdentityVerified { get; set; }
}
public sealed class WorkOrderSummaryDto
{
[JsonPropertyName("appId")]
public string AppId { get; set; } = string.Empty;
[JsonPropertyName("wechatOrderId")]
public string WechatOrderId { get; set; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("statusText")]
public string StatusText { get; set; } = string.Empty;
[JsonPropertyName("createdAt")]
public string CreatedAt { get; set; } = string.Empty;
[JsonPropertyName("updatedAt")]
public string UpdatedAt { get; set; } = string.Empty;
[JsonPropertyName("lastMessageAt")]
public string LastMessageAt { get; set; } = string.Empty;
[JsonPropertyName("lastReplyAt")]
public string? LastReplyAt { get; set; }
[JsonPropertyName("lastSenderType")]
public string? LastSenderType { get; set; }
[JsonPropertyName("unreadCount")]
public int UnreadCount { get; set; }
[JsonPropertyName("version")]
public string Version { get; set; } = "1";
}
public sealed class WorkOrderMessagesResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; } = true;
[JsonPropertyName("appId")]
public string AppId { get; set; } = string.Empty;
[JsonPropertyName("wechatOrderId")]
public string WechatOrderId { get; set; } = string.Empty;
[JsonPropertyName("items")]
public List<TicketMessageDto> Items { get; set; } = [];
[JsonPropertyName("hasMore")]
public bool HasMore { get; set; }
[JsonPropertyName("nextBefore")]
public string? NextBefore { get; set; }
[JsonPropertyName("identityVerified")]
public bool IdentityVerified { get; set; }
}
public sealed class TicketMessageDto
{
[JsonPropertyName("messageId")]
public string MessageId { get; set; } = string.Empty;
[JsonPropertyName("sequenceNo")]
public int SequenceNo { get; set; }
[JsonPropertyName("senderType")]
public string SenderType { get; set; } = string.Empty;
[JsonPropertyName("senderName")]
public string SenderName { get; set; } = string.Empty;
[JsonPropertyName("text")]
public string Text { get; set; } = string.Empty;
[JsonPropertyName("recalled")]
public bool Recalled { get; set; }
[JsonPropertyName("createdAt")]
public string CreatedAt { get; set; } = string.Empty;
}
public sealed class CreateWorkOrderResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; } = true;
[JsonPropertyName("operation")]
public string Operation { get; set; } = string.Empty;
[JsonPropertyName("appId")]
public string AppId { get; set; } = string.Empty;
[JsonPropertyName("wechatOrderId")]
public string WechatOrderId { get; set; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("messageId")]
public string? MessageId { get; set; }
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("replayed")]
public bool Replayed { get; set; }
[JsonPropertyName("identityVerified")]
public bool IdentityVerified { get; set; }
}
public sealed class SendMessageResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; } = true;
[JsonPropertyName("appId")]
public string AppId { get; set; } = string.Empty;
[JsonPropertyName("wechatOrderId")]
public string WechatOrderId { get; set; } = string.Empty;
[JsonPropertyName("message")]
public TicketMessageDto? Message { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("reopened")]
public bool Reopened { get; set; }
[JsonPropertyName("replayed")]
public bool Replayed { get; set; }
[JsonPropertyName("identityVerified")]
public bool IdentityVerified { get; set; }
}
public sealed class ApiSuccessResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; } = true;
}
public sealed class ApiErrorResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
[JsonPropertyName("error")]
public string Error { get; set; } = string.Empty;
[JsonPropertyName("code")]
public string Code { get; set; } = string.Empty;
}
public sealed class TestSupportReplyRequest
{
[Required]
[JsonPropertyName("purchaseAccount")]
public string? PurchaseAccount { get; set; }
[Required]
[JsonPropertyName("appId")]
public string? AppId { get; set; }
[Required]
[JsonPropertyName("wechatOrderId")]
public string? WechatOrderId { get; set; }
[Required]
[JsonPropertyName("text")]
public string? Text { get; set; }
[JsonPropertyName("senderName")]
public string? SenderName { get; set; }
public TestSupportReplyRequest()
{
PurchaseAccount = "18990991234";
SenderName = "丽丽";
}
}
public sealed class TestCompleteTicketRequest
{
[Required]
[JsonPropertyName("purchaseAccount")]
public string? PurchaseAccount { get; set; }
[Required]
[JsonPropertyName("appId")]
public string? AppId { get; set; }
[Required]
[JsonPropertyName("wechatOrderId")]
public string? WechatOrderId { get; set; }
}
public sealed class TestSupportReplyResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; } = true;
[JsonPropertyName("message")]
public TicketMessageDto? Message { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
}
public sealed class TestStateResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; } = true;
[JsonPropertyName("orders")]
public List<TestWorkOrderStateDto> Orders { get; set; } = [];
[JsonPropertyName("sseSubscribers")]
public int SseSubscribers { get; set; }
}
public sealed class TestWorkOrderStateDto
{
[JsonPropertyName("purchaseAccount")]
public string PurchaseAccount { get; set; } = string.Empty;
[JsonPropertyName("appId")]
public string AppId { get; set; } = string.Empty;
[JsonPropertyName("wechatOrderId")]
public string WechatOrderId { get; set; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; set; } = string.Empty;
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("readThroughSequenceNo")]
public int ReadThroughSequenceNo { get; set; }
[JsonPropertyName("messages")]
public List<TicketMessageDto> Messages { get; set; } = [];
}
public sealed class TicketRealtimeNotificationDto
{
[JsonPropertyName("eventId")]
public string EventId { get; set; } = string.Empty;
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("appId")]
public string AppId { get; set; } = string.Empty;
[JsonPropertyName("wechatOrderId")]
public string WechatOrderId { get; set; } = string.Empty;
[JsonPropertyName("messageId")]
public string? MessageId { get; set; }
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
[JsonPropertyName("occurredAt")]
public string OccurredAt { get; set; } = string.Empty;
}
@@ -0,0 +1,779 @@
using System.Globalization;
using System.Text;
using OrderSupportTicket.TestServer.Contracts;
using OrderSupportTicket.TestServer.Services;
namespace OrderSupportTicket.TestServer.Data;
/// <summary>
/// 纯内存工单存储。所有账号、工单、留言和幂等回执仅在当前进程内有效。
/// </summary>
public sealed class InMemoryTicketStore
{
private const string SeedAccount = "18990991234";
private const string SeedAppId1 = "wx316b1291351558aa";
private const string SeedAppId2 = "wx7999743c3d64c05c";
private const string SeedOrderId1 = "9000000000000000001";
private const string SeedOrderId2 = "9000000000000000002";
private readonly object _gate = new();
private readonly TicketEventHub _events;
private readonly Dictionary<string, WorkOrderState> _orders =
new(StringComparer.Ordinal);
private readonly Dictionary<string, CreateWorkOrderResponse> _createReceipts =
new(StringComparer.Ordinal);
private readonly Dictionary<string, SendMessageResponse> _sendReceipts =
new(StringComparer.Ordinal);
public InMemoryTicketStore(TicketEventHub events)
{
_events = events;
Reset();
}
public ApiSuccessResponse Reset()
{
lock (_gate)
{
_orders.Clear();
_createReceipts.Clear();
_sendReceipts.Clear();
Seed();
}
return new ApiSuccessResponse();
}
public WorkOrderListResponse List(ListWorkOrdersRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
var limit = ContractValidator.Limit(request.Limit, 20);
var status = ContractValidator.Status(request.Status);
var appIds = NormalizeAppIds(request.AppIds);
var wechatOrderId = string.IsNullOrWhiteSpace(request.WechatOrderId)
? null
: ContractValidator.WechatOrderId(request.WechatOrderId);
var cursor = DecodeCursor(request.Cursor);
List<WorkOrderState> ordered;
lock (_gate)
{
ordered = _orders.Values
.Where(item => string.Equals(
item.PurchaseAccount,
purchaseAccount,
StringComparison.Ordinal))
.Where(item => appIds.Contains(item.AppId))
.Where(item => wechatOrderId == null
|| string.Equals(
item.WechatOrderId,
wechatOrderId,
StringComparison.Ordinal))
.Where(item => status == null
|| string.Equals(item.Status, status, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.UpdatedAt)
.ThenBy(item => item.Key, StringComparer.Ordinal)
.ToList();
}
var remaining = cursor == null
? ordered
: ordered
.Where(item => item.UpdatedAt < cursor.UpdatedAt
|| (item.UpdatedAt == cursor.UpdatedAt
&& string.CompareOrdinal(item.Key, cursor.Key) > 0))
.ToList();
var page = remaining.Take(limit + 1).ToList();
var hasMore = page.Count > limit;
if (hasMore)
{
page.RemoveAt(page.Count - 1);
}
return new WorkOrderListResponse
{
Items = page.Select(ToSummaryDto).ToList(),
HasMore = hasMore,
NextCursor = hasMore && page.Count > 0
? EncodeCursor(page[^1])
: null
};
}
public WorkOrderMessagesResponse GetMessages(GetMessagesRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
var appId = ContractValidator.AppId(request.AppId);
var wechatOrderId = ContractValidator.WechatOrderId(request.WechatOrderId);
var limit = ContractValidator.Limit(request.Limit, 50);
var before = ParseBefore(request.Before);
lock (_gate)
{
var order = FindOrderOrThrow(purchaseAccount, appId, wechatOrderId);
var messages = order.Messages
.Where(message => before == null || message.SequenceNo < before.Value)
.OrderBy(message => message.SequenceNo)
.ToList();
var hasMore = messages.Count > limit;
var page = hasMore
? messages.Skip(messages.Count - limit).ToList()
: messages;
return new WorkOrderMessagesResponse
{
AppId = appId,
WechatOrderId = wechatOrderId,
Items = page.Select(ToMessageDto).ToList(),
HasMore = hasMore,
NextBefore = hasMore && page.Count > 0
? page[0].SequenceNo.ToString(CultureInfo.InvariantCulture)
: null
};
}
}
public CreateWorkOrderResponse Create(CreateWorkOrderRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
var appId = ContractValidator.AppId(request.AppId);
var wechatOrderId = ContractValidator.WechatOrderId(request.WechatOrderId);
var description = ContractValidator.Description(request.Description);
var clientRequestId = ContractValidator.OptionalOperationId(
request.ClientRequestId,
nameof(request.ClientRequestId));
var receiptKey = clientRequestId == null
? null
: purchaseAccount + "|" + clientRequestId;
TicketRealtimeNotificationDto? notification = null;
CreateWorkOrderResponse response;
lock (_gate)
{
if (receiptKey != null
&& _createReceipts.TryGetValue(receiptKey, out var existingReceipt))
{
return CloneCreateResponse(existingReceipt, replayed: true);
}
var now = DateTimeOffset.UtcNow;
var order = FindOrder(purchaseAccount, appId, wechatOrderId);
if (order == null)
{
order = new WorkOrderState
{
PurchaseAccount = purchaseAccount,
AppId = appId,
WechatOrderId = wechatOrderId,
Status = "waiting_reply",
Version = 1,
CreatedAt = now,
UpdatedAt = now
};
_orders[order.Key] = order;
var message = AddMessage(order, "customer", purchaseAccount, description, now);
response = new CreateWorkOrderResponse
{
Operation = "created",
AppId = appId,
WechatOrderId = wechatOrderId,
Status = order.Status,
MessageId = message.MessageId,
Version = order.Version.ToString(CultureInfo.InvariantCulture)
};
notification = BuildNotification(
"relay.ticket.message_added",
order,
message,
now);
}
else if (string.Equals(order.Status, "completed", StringComparison.OrdinalIgnoreCase))
{
order.Status = "waiting_reply";
var message = AddMessage(order, "customer", purchaseAccount, description, now);
order.Version++;
order.UpdatedAt = now;
response = new CreateWorkOrderResponse
{
Operation = "reopened",
AppId = appId,
WechatOrderId = wechatOrderId,
Status = order.Status,
MessageId = message.MessageId,
Version = order.Version.ToString(CultureInfo.InvariantCulture)
};
notification = BuildNotification(
"relay.ticket.message_added",
order,
message,
now);
}
else
{
response = new CreateWorkOrderResponse
{
Operation = "existing",
AppId = appId,
WechatOrderId = wechatOrderId,
Status = order.Status,
MessageId = null,
Version = order.Version.ToString(CultureInfo.InvariantCulture)
};
}
if (receiptKey != null)
{
_createReceipts[receiptKey] = CloneCreateResponse(response, replayed: false);
}
}
if (notification != null)
{
_events.Publish(purchaseAccount, notification);
}
return response;
}
public SendMessageResponse Send(string wechatOrderId, SendMessageRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
var appId = ContractValidator.AppId(request.AppId);
var orderId = ContractValidator.WechatOrderId(wechatOrderId);
var clientMessageId = ContractValidator.OperationId(
request.ClientMessageId,
nameof(request.ClientMessageId));
var text = ContractValidator.MessageText(request.Text);
var receiptKey = purchaseAccount + "|" + clientMessageId;
TicketRealtimeNotificationDto? notification = null;
SendMessageResponse response;
lock (_gate)
{
if (_sendReceipts.TryGetValue(receiptKey, out var existingReceipt))
{
return CloneSendResponse(existingReceipt, replayed: true);
}
var order = FindOrderOrThrow(purchaseAccount, appId, orderId);
var now = DateTimeOffset.UtcNow;
var reopened = string.Equals(
order.Status,
"completed",
StringComparison.OrdinalIgnoreCase);
order.Status = "waiting_reply";
var message = AddMessage(order, "customer", purchaseAccount, text, now);
order.Version++;
order.UpdatedAt = now;
response = new SendMessageResponse
{
AppId = appId,
WechatOrderId = orderId,
Message = ToMessageDto(message),
Status = order.Status,
Version = order.Version.ToString(CultureInfo.InvariantCulture),
Reopened = reopened
};
_sendReceipts[receiptKey] = CloneSendResponse(response, replayed: false);
notification = BuildNotification(
"relay.ticket.message_added",
order,
message,
now);
}
_events.Publish(purchaseAccount, notification);
return response;
}
public ApiSuccessResponse MarkRead(string wechatOrderId, MarkReadRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
var appId = ContractValidator.AppId(request.AppId);
var orderId = ContractValidator.WechatOrderId(wechatOrderId);
if (request.ThroughSequenceNo < 1)
{
throw ContractValidator.BadRequest(
"throughSequenceNo 必须大于 0。",
"invalid_sequence_no");
}
lock (_gate)
{
var order = FindOrderOrThrow(purchaseAccount, appId, orderId);
order.ReadThroughSequenceNo = Math.Max(
order.ReadThroughSequenceNo,
request.ThroughSequenceNo);
}
return new ApiSuccessResponse();
}
public TestSupportReplyResponse SupportReply(TestSupportReplyRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
var appId = ContractValidator.AppId(request.AppId);
var wechatOrderId = ContractValidator.WechatOrderId(request.WechatOrderId);
var text = ContractValidator.MessageText(request.Text);
var senderName = string.IsNullOrWhiteSpace(request.SenderName)
? "测试客服"
: request.SenderName.Trim();
TicketRealtimeNotificationDto? notification = null;
TestSupportReplyResponse response;
lock (_gate)
{
var order = FindOrderOrThrow(purchaseAccount, appId, wechatOrderId);
var now = DateTimeOffset.UtcNow;
order.Status = "replied";
var message = AddMessage(order, "support", senderName, text, now);
order.Version++;
order.UpdatedAt = now;
response = new TestSupportReplyResponse
{
Message = ToMessageDto(message),
Status = order.Status,
Version = order.Version.ToString(CultureInfo.InvariantCulture)
};
notification = BuildNotification(
"relay.ticket.message_added",
order,
message,
now);
}
_events.Publish(purchaseAccount, notification);
return response;
}
public WorkOrderSummaryDto Complete(TestCompleteTicketRequest request)
{
ArgumentNullException.ThrowIfNull(request);
var purchaseAccount = ContractValidator.PurchaseAccount(request.PurchaseAccount);
var appId = ContractValidator.AppId(request.AppId);
var wechatOrderId = ContractValidator.WechatOrderId(request.WechatOrderId);
TicketRealtimeNotificationDto notification;
WorkOrderSummaryDto response;
lock (_gate)
{
var order = FindOrderOrThrow(purchaseAccount, appId, wechatOrderId);
var now = DateTimeOffset.UtcNow;
order.Status = "completed";
order.Version++;
order.UpdatedAt = now;
response = ToSummaryDto(order);
notification = BuildNotification(
"relay.ticket.status_changed",
order,
null,
now);
}
_events.Publish(purchaseAccount, notification);
return response;
}
public TestStateResponse Snapshot()
{
lock (_gate)
{
return new TestStateResponse
{
Orders = _orders.Values
.OrderByDescending(item => item.UpdatedAt)
.Select(item => new TestWorkOrderStateDto
{
PurchaseAccount = item.PurchaseAccount,
AppId = item.AppId,
WechatOrderId = item.WechatOrderId,
Status = item.Status,
Version = item.Version.ToString(CultureInfo.InvariantCulture),
ReadThroughSequenceNo = item.ReadThroughSequenceNo,
Messages = item.Messages
.OrderBy(message => message.SequenceNo)
.Select(ToMessageDto)
.ToList()
})
.ToList(),
SseSubscribers = _events.SubscriberCount
};
}
}
private void Seed()
{
var now = DateTimeOffset.UtcNow;
var order1 = new WorkOrderState
{
PurchaseAccount = SeedAccount,
AppId = SeedAppId1,
WechatOrderId = SeedOrderId1,
Status = "replied",
Version = 2,
CreatedAt = now.AddHours(-4),
UpdatedAt = now.AddHours(-1),
ReadThroughSequenceNo = 0
};
AddMessage(
order1,
"customer",
SeedAccount,
"你好,请问这个订单什么时候发货?",
now.AddHours(-4));
AddMessage(
order1,
"support",
"测试客服",
"您好,订单已为您加急处理,预计今天发出。",
now.AddHours(-1));
_orders[order1.Key] = order1;
var order2 = new WorkOrderState
{
PurchaseAccount = SeedAccount,
AppId = SeedAppId2,
WechatOrderId = SeedOrderId2,
Status = "waiting_reply",
Version = 1,
CreatedAt = now.AddHours(-2),
UpdatedAt = now.AddHours(-2),
ReadThroughSequenceNo = 0
};
AddMessage(
order2,
"customer",
SeedAccount,
"抽纸包装破损了,可以补发吗?",
now.AddHours(-2));
_orders[order2.Key] = order2;
}
private static HashSet<string> NormalizeAppIds(IEnumerable<string>? appIds)
{
var normalized = (appIds ?? Enumerable.Empty<string>())
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(ContractValidator.AppId)
.Distinct(StringComparer.Ordinal)
.ToList();
if (normalized.Count == 0)
{
throw ContractValidator.BadRequest(
"appIds 至少需要一个 APPID。",
"invalid_app_ids");
}
if (normalized.Count > 50)
{
throw ContractValidator.BadRequest(
"appIds 最多支持 50 个 APPID。",
"invalid_app_ids");
}
return normalized.ToHashSet(StringComparer.Ordinal);
}
private WorkOrderState? FindOrder(
string purchaseAccount,
string appId,
string wechatOrderId)
{
var key = WorkOrderState.CreateKey(purchaseAccount, appId, wechatOrderId);
return _orders.TryGetValue(key, out var order) ? order : null;
}
private WorkOrderState FindOrderOrThrow(
string purchaseAccount,
string appId,
string wechatOrderId)
{
return FindOrder(purchaseAccount, appId, wechatOrderId)
?? throw ContractValidator.NotFound(
"该账号、APPID 和微信订单下尚无工单。");
}
private static MessageState AddMessage(
WorkOrderState order,
string senderType,
string senderName,
string text,
DateTimeOffset createdAt)
{
var sequenceNo = order.Messages.Count == 0
? 1
: order.Messages.Max(message => message.SequenceNo) + 1;
var message = new MessageState
{
MessageId = "msg-" + Guid.NewGuid().ToString("N"),
SequenceNo = sequenceNo,
SenderType = senderType,
SenderName = senderName,
Text = text,
CreatedAt = createdAt
};
order.Messages.Add(message);
return message;
}
private static WorkOrderSummaryDto ToSummaryDto(WorkOrderState order)
{
var lastMessage = order.Messages
.OrderByDescending(message => message.SequenceNo)
.FirstOrDefault();
var lastReply = order.Messages
.Where(message => !string.Equals(
message.SenderType,
"customer",
StringComparison.OrdinalIgnoreCase))
.Where(message => !message.Recalled)
.OrderByDescending(message => message.SequenceNo)
.FirstOrDefault();
var unreadCount = order.Messages.Count(message =>
!string.Equals(
message.SenderType,
"customer",
StringComparison.OrdinalIgnoreCase)
&& !message.Recalled
&& message.SequenceNo > order.ReadThroughSequenceNo);
return new WorkOrderSummaryDto
{
AppId = order.AppId,
WechatOrderId = order.WechatOrderId,
Status = order.Status,
StatusText = GetStatusText(order.Status),
CreatedAt = FormatTimestamp(order.CreatedAt),
UpdatedAt = FormatTimestamp(order.UpdatedAt),
LastMessageAt = FormatTimestamp(
lastMessage?.CreatedAt ?? order.CreatedAt),
LastReplyAt = lastReply == null
? null
: FormatTimestamp(lastReply.CreatedAt),
LastSenderType = lastMessage?.SenderType,
UnreadCount = unreadCount,
Version = order.Version.ToString(CultureInfo.InvariantCulture)
};
}
private static TicketMessageDto ToMessageDto(MessageState message)
{
return new TicketMessageDto
{
MessageId = message.MessageId,
SequenceNo = message.SequenceNo,
SenderType = message.SenderType,
SenderName = message.SenderName,
Text = message.Recalled ? string.Empty : message.Text,
Recalled = message.Recalled,
CreatedAt = FormatTimestamp(message.CreatedAt)
};
}
private static TicketRealtimeNotificationDto BuildNotification(
string type,
WorkOrderState order,
MessageState? message,
DateTimeOffset occurredAt)
{
return new TicketRealtimeNotificationDto
{
EventId = "evt-" + Guid.NewGuid().ToString("N"),
Type = type,
AppId = order.AppId,
WechatOrderId = order.WechatOrderId,
MessageId = message?.MessageId,
Version = order.Version.ToString(CultureInfo.InvariantCulture),
OccurredAt = FormatTimestamp(occurredAt)
};
}
private static string GetStatusText(string status)
{
return status.ToLowerInvariant() switch
{
"waiting_reply" => "待客服回复",
"replied" => "客服已回复",
"completed" => "已完结",
_ => "状态未知"
};
}
private static string FormatTimestamp(DateTimeOffset value) =>
value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
private static int? ParseBefore(string? before)
{
if (string.IsNullOrWhiteSpace(before))
{
return null;
}
if (!int.TryParse(before, NumberStyles.None, CultureInfo.InvariantCulture, out var value)
|| value < 1)
{
throw ContractValidator.BadRequest(
"before 必须为上一页返回的 nextBefore 值。",
"invalid_before");
}
return value;
}
private static string EncodeCursor(WorkOrderState order)
{
var raw = order.UpdatedAt.UtcDateTime.Ticks.ToString(CultureInfo.InvariantCulture)
+ "|"
+ order.Key;
return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static ListCursor? DecodeCursor(string? cursor)
{
if (string.IsNullOrWhiteSpace(cursor))
{
return null;
}
try
{
var padded = cursor.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
var raw = Encoding.UTF8.GetString(Convert.FromBase64String(padded));
var separator = raw.IndexOf('|');
if (separator <= 0 || separator == raw.Length - 1)
{
throw new FormatException();
}
var ticks = long.Parse(
raw[..separator],
NumberStyles.None,
CultureInfo.InvariantCulture);
var key = raw[(separator + 1)..];
return new ListCursor(
new DateTimeOffset(ticks, TimeSpan.Zero),
key);
}
catch (Exception exception) when (exception is FormatException
or OverflowException
or ArgumentException)
{
throw ContractValidator.BadRequest(
"cursor 无效,请使用服务端返回的 nextCursor。",
"invalid_cursor");
}
}
private static CreateWorkOrderResponse CloneCreateResponse(
CreateWorkOrderResponse source,
bool replayed)
{
return new CreateWorkOrderResponse
{
Success = source.Success,
Operation = source.Operation,
AppId = source.AppId,
WechatOrderId = source.WechatOrderId,
Status = source.Status,
MessageId = source.MessageId,
Version = source.Version,
Replayed = replayed,
IdentityVerified = source.IdentityVerified
};
}
private static SendMessageResponse CloneSendResponse(
SendMessageResponse source,
bool replayed)
{
return new SendMessageResponse
{
Success = source.Success,
AppId = source.AppId,
WechatOrderId = source.WechatOrderId,
Message = source.Message == null
? null
: new TicketMessageDto
{
MessageId = source.Message.MessageId,
SequenceNo = source.Message.SequenceNo,
SenderType = source.Message.SenderType,
SenderName = source.Message.SenderName,
Text = source.Message.Text,
Recalled = source.Message.Recalled,
CreatedAt = source.Message.CreatedAt
},
Status = source.Status,
Version = source.Version,
Reopened = source.Reopened,
Replayed = replayed,
IdentityVerified = source.IdentityVerified
};
}
private sealed class WorkOrderState
{
public string PurchaseAccount { get; init; } = string.Empty;
public string AppId { get; init; } = string.Empty;
public string WechatOrderId { get; init; } = string.Empty;
public string Status { get; set; } = "waiting_reply";
public long Version { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; }
public int ReadThroughSequenceNo { get; set; }
public List<MessageState> Messages { get; } = [];
public string Key => CreateKey(PurchaseAccount, AppId, WechatOrderId);
public static string CreateKey(
string purchaseAccount,
string appId,
string wechatOrderId) =>
purchaseAccount + "|" + appId + "|" + wechatOrderId;
}
private sealed class MessageState
{
public string MessageId { get; init; } = string.Empty;
public int SequenceNo { get; init; }
public string SenderType { get; init; } = string.Empty;
public string SenderName { get; init; } = string.Empty;
public string Text { get; init; } = string.Empty;
public bool Recalled { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
private sealed record ListCursor(DateTimeOffset UpdatedAt, string Key);
}
@@ -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。
}
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>12.0</LangVersion>
<AssemblyName>OrderSupportTicket.TestServer</AssemblyName>
<RootNamespace>OrderSupportTicket.TestServer</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,104 @@
using System.Text.Json.Serialization;
using Microsoft.OpenApi.Models;
using OrderSupportTicket.TestServer;
using OrderSupportTicket.TestServer.Contracts;
using OrderSupportTicket.TestServer.Data;
using OrderSupportTicket.TestServer.Endpoints;
using OrderSupportTicket.TestServer.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<TicketEventHub>();
builder.Services.AddSingleton<InMemoryTicketStore>();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy => policy
.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod());
});
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.Never;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "OrderSupportTicket 测试服务端",
Version = "v1",
Description = "纯内存实现客户工单 API v2;重启服务后数据恢复为初始种子数据。"
});
});
var app = builder.Build();
app.UseCors();
app.Use(async (context, next) =>
{
try
{
await next();
}
catch (ApiException exception)
{
if (context.Response.HasStarted)
{
throw;
}
context.Response.Clear();
context.Response.StatusCode = exception.StatusCode;
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsJsonAsync(new ApiErrorResponse
{
Success = false,
Message = exception.Message,
Error = exception.Message,
Code = exception.Code
});
}
catch (BadHttpRequestException exception)
{
if (context.Response.HasStarted)
{
throw;
}
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsJsonAsync(new ApiErrorResponse
{
Success = false,
Message = exception.Message,
Error = exception.Message,
Code = "bad_request"
});
}
});
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/v1/swagger.json", "OrderSupportTicket Test API v1");
options.DocumentTitle = "OrderSupportTicket 测试 API";
});
app.MapGet("/health", () => Results.Ok(new
{
success = true,
service = "OrderSupportTicket.TestServer",
storage = "in-memory",
utcNow = DateTimeOffset.UtcNow.ToString("O")
}));
app.MapTicketApiEndpoints();
app.Run();
public partial class Program { }
@@ -0,0 +1,15 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"OrderSupportTicket.TestServer": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5188",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,92 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
using OrderSupportTicket.TestServer.Contracts;
namespace OrderSupportTicket.TestServer.Services;
/// <summary>
/// 纯内存 SSE 订阅中心。进程退出后事件和订阅全部丢失。
/// </summary>
public sealed class TicketEventHub
{
private readonly ConcurrentDictionary<Guid, Subscription> _subscriptions = new();
public int SubscriberCount => _subscriptions.Count;
public TicketEventSubscription Subscribe(
string purchaseAccount,
IReadOnlyCollection<string> appIds)
{
var id = Guid.NewGuid();
var channel = Channel.CreateUnbounded<TicketRealtimeNotificationDto>(
new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false
});
var subscription = new Subscription(
purchaseAccount,
new HashSet<string>(appIds, StringComparer.OrdinalIgnoreCase),
channel);
_subscriptions[id] = subscription;
return new TicketEventSubscription(
channel.Reader,
() => _subscriptions.TryRemove(id, out _));
}
public void Publish(
string purchaseAccount,
TicketRealtimeNotificationDto notification)
{
foreach (var subscription in _subscriptions.Values)
{
if (!string.Equals(
subscription.PurchaseAccount,
purchaseAccount,
StringComparison.Ordinal))
{
continue;
}
if (!subscription.AppIds.Contains(notification.AppId))
{
continue;
}
subscription.Channel.Writer.TryWrite(notification);
}
}
private sealed record Subscription(
string PurchaseAccount,
HashSet<string> AppIds,
Channel<TicketRealtimeNotificationDto> Channel);
}
public sealed class TicketEventSubscription : IDisposable
{
private readonly Action _unsubscribe;
private int _disposed;
internal TicketEventSubscription(
ChannelReader<TicketRealtimeNotificationDto> reader,
Action unsubscribe)
{
Reader = reader;
_unsubscribe = unsubscribe;
}
public ChannelReader<TicketRealtimeNotificationDto> Reader { get; }
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_unsubscribe();
}
}
@@ -0,0 +1,205 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>OrderSupportTicket 测试控制台</title>
<style>
:root{--bg:#f4f6f8;--panel:#fff;--line:#d9e0e7;--text:#202a33;--muted:#667685;--brand:#98638f;--brand2:#774d70;--ok:#237a4b;--err:#b3261e}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 "Microsoft YaHei UI","PingFang SC",Segoe UI,sans-serif}
header{padding:18px 24px;background:linear-gradient(120deg,#5b3a57,#98638f);color:#fff;display:flex;justify-content:space-between;gap:16px;align-items:center}
header h1{margin:0;font-size:21px}header p{margin:3px 0 0;opacity:.85;font-size:13px}header a{color:#fff}
main{max-width:1320px;margin:0 auto;padding:18px;display:grid;grid-template-columns:minmax(420px,1fr) minmax(380px,1fr);gap:16px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:16px}.card h2{margin:0 0 12px;font-size:16px}
.grid{display:grid;grid-template-columns:130px 1fr;gap:9px 12px;align-items:center}.grid label{color:var(--muted);font-size:13px}
input,textarea,button{font:inherit}input,textarea{width:100%;border:1px solid var(--line);border-radius:5px;padding:7px 9px;background:#fff;color:var(--text)}
textarea{min-height:68px;resize:vertical}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
button{border:1px solid var(--brand);border-radius:5px;background:var(--brand);color:#fff;padding:7px 12px;cursor:pointer}button:hover{background:var(--brand2)}button.ghost{background:#fff;color:var(--brand)}button.danger{border-color:var(--err);background:var(--err)}
button:disabled{opacity:.45;cursor:not-allowed}.hint{color:var(--muted);font-size:12px;margin:8px 0 0}
pre{min-height:260px;max-height:560px;overflow:auto;margin:0;padding:12px;border-radius:6px;background:#111820;color:#d8e2eb;white-space:pre-wrap;word-break:break-word;font:12px/1.55 Consolas,monospace}
#sseLog{max-height:280px}.logline{padding:4px 0;border-bottom:1px dashed var(--line);word-break:break-all}.logline:last-child{border-bottom:0}
.state{display:inline-flex;align-items:center;gap:6px;font-size:12px;margin-left:8px;color:var(--muted)}.dot{width:8px;height:8px;border-radius:50%;background:#8896a2}.dot.on{background:var(--ok)}.dot.err{background:var(--err)}
@media(max-width:900px){main{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column}}
</style>
</head>
<body>
<header>
<div><h1>OrderSupportTicket 测试控制台</h1><p>纯内存数据 · 重启后恢复种子数据 · <a href="/swagger" target="_blank">打开 Swagger</a></p></div>
<div><a href="/health" target="_blank">健康检查</a>&nbsp;&nbsp;<a href="/api/test/state" target="_blank">当前内存状态</a></div>
</header>
<main>
<section class="card">
<h2>请求参数</h2>
<div class="grid">
<label>purchaseAccount</label><input id="account" value="18990991234">
<label>appIds</label><input id="appIds" value="wx316b1291351558aa,wx7999743c3d64c05c">
<label>appId</label><input id="appId" value="wx316b1291351558aa">
<label>wechatOrderId</label><input id="orderId" value="9000000000000000001">
<label>创建描述</label><textarea id="description">测试新建工单:商品什么时候发货?</textarea>
<label>客户留言</label><textarea id="messageText">补充说明:请尽快处理,谢谢。</textarea>
<label>clientRequestId</label><input id="clientRequestId" placeholder="留空自动生成">
<label>clientMessageId</label><input id="clientMessageId" placeholder="留空自动生成">
<label>已读 sequenceNo</label><input id="sequenceNo" type="number" min="1" value="1">
</div>
<p class="hint">种子数据:账号 18990991234 下有两个已有工单;订单 9000000000000000001 已有一条客服回复,适合测试未读与 SSE。</p>
<div class="actions">
<button onclick="listOrders()">查询工单列表</button>
<button onclick="getMessages()">查询留言</button>
<button onclick="createTicket()">创建工单</button>
<button onclick="sendMessage()">发送客户留言</button>
<button onclick="markRead()">标记已读</button>
<button class="ghost" onclick="supportReply()">模拟客服回复</button>
<button class="ghost" onclick="completeTicket()">模拟完结</button>
<button class="ghost" onclick="loadState()">查看内存状态</button>
<button class="danger" onclick="resetData()">重置数据</button>
</div>
</section>
<section class="card">
<h2>接口响应</h2>
<pre id="output">等待操作…</pre>
</section>
<section class="card" style="grid-column:1/-1">
<h2>SSE 实时通知 <span class="state"><span id="sseDot" class="dot"></span><span id="sseText">未连接</span></span></h2>
<div class="actions" style="margin-top:0;margin-bottom:12px">
<button onclick="connectSse()">连接 SSE</button>
<button class="ghost" onclick="disconnectSse()">断开</button>
<button class="ghost" onclick="clearSseLog()">清空日志</button>
</div>
<div id="sseLog" class="logline" style="max-height:280px;overflow:auto">暂无事件。连接后可点击“模拟客服回复”或“模拟完结”观察推送。</div>
</section>
</main>
<script>
const $ = id => document.getElementById(id);
const base = '/api/open/v2/customer-work-orders';
let source = null;
function newId(prefix) {
const random = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random())
.replaceAll('-', '');
return prefix + '-' + random;
}
function splitAppIds() {
return $('appIds').value.split(/[,,;\s]+/).map(x => x.trim()).filter(Boolean);
}
function commonBody() {
return {
purchaseAccount: $('account').value.trim(),
appId: $('appId').value.trim()
};
}
async function call(method, path, body) {
$('output').textContent = '请求中…\n' + method + ' ' + path;
try {
const response = await fetch(path, {
method,
headers: body === undefined ? undefined : {'Content-Type': 'application/json'},
body: body === undefined ? undefined : JSON.stringify(body)
});
const text = await response.text();
let parsed;
try { parsed = text ? JSON.stringify(JSON.parse(text), null, 2) : '(空响应)'; }
catch { parsed = text; }
$('output').textContent = 'HTTP ' + response.status + ' ' + response.statusText + '\n' + method + ' ' + path + '\n\n' + parsed;
return {response, text};
} catch (error) {
$('output').textContent = '请求失败:' + error;
throw error;
}
}
function listOrders() {
const body = {...commonBody(), appIds: splitAppIds(), limit: 100, cursor: null};
return call('POST', base + '/list', body);
}
function getMessages() {
return call('POST', base + '/messages', {
...commonBody(),
wechatOrderId: $('orderId').value.trim(),
limit: 100,
before: null
});
}
async function createTicket() {
const id = $('clientRequestId').value.trim() || newId('create');
$('clientRequestId').value = id;
await call('POST', base, {
...commonBody(),
wechatOrderId: $('orderId').value.trim(),
description: $('description').value.trim(),
clientRequestId: id
});
}
function sendMessage() {
const id = $('clientMessageId').value.trim() || newId('send');
$('clientMessageId').value = id;
return call('POST', base + '/orders/' + encodeURIComponent($('orderId').value.trim()) + '/messages', {
...commonBody(),
clientMessageId: id,
text: $('messageText').value.trim()
});
}
function markRead() {
return call('POST', base + '/orders/' + encodeURIComponent($('orderId').value.trim()) + '/read', {
...commonBody(),
throughSequenceNo: Number($('sequenceNo').value)
});
}
function supportReply() {
return call('POST', '/api/test/support-replies', {
...commonBody(),
wechatOrderId: $('orderId').value.trim(),
text: $('messageText').value.trim() || '这是测试客服回复。',
senderName: '测试客服'
});
}
function completeTicket() {
return call('POST', '/api/test/orders/complete', {
...commonBody(),
wechatOrderId: $('orderId').value.trim()
});
}
function resetData() {
if (!confirm('确定重置全部内存数据?')) return;
return call('POST', '/api/test/reset');
}
function loadState() { return call('GET', '/api/test/state'); }
function appendSse(text, isError) {
const log = $('sseLog');
if (log.dataset.initial !== 'false') { log.textContent = ''; log.dataset.initial = 'false'; }
const line = document.createElement('div');
line.className = 'logline';
line.style.color = isError ? 'var(--err)' : '';
line.textContent = new Date().toLocaleTimeString() + ' ' + text;
log.appendChild(line);
log.scrollTop = log.scrollHeight;
}
function setSse(state, text) {
$('sseDot').className = 'dot' + (state === 'on' ? ' on' : state === 'err' ? ' err' : '');
$('sseText').textContent = text;
}
function connectSse() {
disconnectSse();
const query = new URLSearchParams();
query.set('purchaseAccount', $('account').value.trim());
splitAppIds().forEach(id => query.append('appId', id));
source = new EventSource(base + '/events?' + query.toString());
setSse('', '连接中…');
source.onopen = () => { setSse('on', '已连接'); appendSse('SSE 已连接'); };
source.onerror = () => { setSse('err', '连接异常/重连中'); appendSse('SSE 连接异常或正在重连', true); };
source.onmessage = event => appendSse(event.data);
for (const type of ['relay.ticket.message_added', 'relay.ticket.status_changed', 'resync_required']) {
source.addEventListener(type, event => appendSse(event.type + '\n' + event.data));
}
}
function disconnectSse() {
if (source) { source.close(); source = null; setSse('', '已断开'); }
}
function clearSseLog() { $('sseLog').textContent = '暂无事件。'; $('sseLog').dataset.initial = 'false'; }
$('appId').addEventListener('change', () => {
const id = $('appId').value.trim();
if (id.endsWith('1558aa')) $('orderId').value = '9000000000000000001';
if (id.endsWith('64c05c')) $('orderId').value = '9000000000000000002';
});
</script>
</body>
</html>