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