增加工单模块

This commit is contained in:
chfei committed 2026-10-17 18:18:05 +08:00
1 parent dc136e449e
commit f9dcae4610
17 files changed
+4908

No files matched your search

@@ -0,0 +1,206 @@
using LaunchDarkly.EventSource;
using OrderSupportTicket.Models;
using System;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Text;
using System.Threading.Tasks;
namespace OrderSupportTicket.Services;
/// <summary>
/// 基于 LaunchDarkly.EventSource 的 SSE 客户端。
/// net462 可使用;断线自动退避重连,并携带服务端事件 ID 进行补偿。
/// </summary>
public sealed class EventSourceTicketRealtimeService : ITicketRealtimeService
{
private const string EventsPath = "/api/open/v2/customer-work-orders/events";
private readonly object _sync = new object();
private EventSource _eventSource;
private bool _running;
private bool _disposed;
public event EventHandler<TicketRealtimeNotificationEventArgs> NotificationReceived;
public event EventHandler<TicketRealtimeStateChangedEventArgs> StateChanged;
/// <summary>
/// 当前 SSE 连接状态。
/// </summary>
public TicketRealtimeState State { get; private set; } = TicketRealtimeState.Disconnected;
public void Start(TicketClientOptions options)
{
if (_disposed)
throw new ObjectDisposedException(nameof(EventSourceTicketRealtimeService));
if (options == null)
throw new ArgumentNullException(nameof(options));
TicketApiService.ValidateOptions(options);
StopCore();
var uri = BuildEventsUri(options);
var source = new EventSource(uri);
source.Opened += OnOpened;
source.Closed += OnClosed;
source.MessageReceived += OnMessageReceived;
source.Error += OnError;
lock (_sync)
{
_running = true;
_eventSource = source;
}
SetState(TicketRealtimeState.Connecting, "连接中…");
_ = RunAsync(source);
}
public Task StopAsync()
{
StopCore();
SetState(TicketRealtimeState.Disconnected, "已断开");
return Task.FromResult(0);
}
public void Dispose()
{
if (_disposed)
return;
StopCore();
_disposed = true;
}
private async Task RunAsync(EventSource source)
{
try
{
await source.StartAsync().ConfigureAwait(false);
lock (_sync)
{
if (!_running || !ReferenceEquals(_eventSource, source))
return;
}
SetState(TicketRealtimeState.Reconnecting, "连接已关闭,等待重连…");
}
catch (Exception ex)
{
lock (_sync)
{
if (!_running || !ReferenceEquals(_eventSource, source))
return;
}
SetState(TicketRealtimeState.Reconnecting, "连接失败:" + ex.Message);
}
}
private void OnOpened(object sender, StateChangedEventArgs args)
{
SetState(TicketRealtimeState.Connected, "已连接");
}
private void OnClosed(object sender, StateChangedEventArgs args)
{
lock (_sync)
{
if (!_running || !ReferenceEquals(_eventSource, sender))
return;
}
SetState(TicketRealtimeState.Reconnecting, "连接断开,重连中…");
}
private void OnError(object sender, ExceptionEventArgs args)
{
lock (_sync)
{
if (!_running || !ReferenceEquals(_eventSource, sender))
return;
}
SetState(
TicketRealtimeState.Reconnecting,
"连接异常,重连中:" + (args.Exception?.Message ?? "未知错误"));
}
private void OnMessageReceived(object sender, MessageReceivedEventArgs args)
{
if (args == null)
return;
var data = args.Message.Data;
if (string.IsNullOrWhiteSpace(data))
return;
TicketRealtimeNotification notification;
try
{
var serializer = new DataContractJsonSerializer(
typeof(TicketRealtimeNotification));
using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(data)))
{
notification = (TicketRealtimeNotification)serializer.ReadObject(stream);
}
}
catch (SerializationException)
{
return;
}
if (notification == null)
return;
if (string.IsNullOrWhiteSpace(notification.EventId))
notification.EventId = args.Message.LastEventId;
if (string.IsNullOrWhiteSpace(notification.Type))
notification.Type = args.EventName;
NotificationReceived?.Invoke(
this,
new TicketRealtimeNotificationEventArgs(notification));
}
private void StopCore()
{
EventSource source;
lock (_sync)
{
_running = false;
source = _eventSource;
_eventSource = null;
}
if (source == null)
return;
source.Opened -= OnOpened;
source.Closed -= OnClosed;
source.MessageReceived -= OnMessageReceived;
source.Error -= OnError;
source.Close();
}
private void SetState(TicketRealtimeState state, string message)
{
State = state;
StateChanged?.Invoke(
this,
new TicketRealtimeStateChangedEventArgs(state, message));
}
private static Uri BuildEventsUri(TicketClientOptions options)
{
var appIds = options.AppIds
.Where(a => !string.IsNullOrWhiteSpace(a))
.Distinct(StringComparer.Ordinal)
.Select(a => "appId=" + Uri.EscapeDataString(a.Trim()));
var query = new[]
{
"purchaseAccount=" + Uri.EscapeDataString(options.UserName)
}.Concat(appIds);
return new Uri(
TicketApiService.ServerUrl
+ EventsPath
+ "?"
+ string.Join("&", query),
UriKind.Absolute);
}
}
@@ -0,0 +1,87 @@
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
using System.Threading.Tasks;
namespace OrderSupportTicket.Services;
/// <summary>
/// 客户工单 v2 接口。
/// </summary>
public interface ITicketApiService
{
/// <summary>
/// 查询工单摘要列表。
/// </summary>
Task<WorkOrderListResponse> GetWorkOrdersAsync(
TicketClientOptions options,
string wechatOrderId = null,
string status = null,
int limit = 20,
string cursor = null);
/// <summary>
/// 查询指定订单的公开留言板。
/// </summary>
Task<WorkOrderMessagesResponse> GetMessagesAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
string before = null,
int limit = 50);
/// <summary>
/// 提交问题或创建工单。
/// </summary>
Task<CreateWorkOrderResponse> CreateWorkOrderAsync(
TicketClientOptions options,
TicketOrderOption order,
string description,
string clientRequestId = null);
/// <summary>
/// 提交客户留言。
/// </summary>
Task<SendMessageResponse> SendMessageAsync(
TicketClientOptions options,
WorkOrderSummaryDto workOrder,
string text,
string clientMessageId);
/// <summary>
/// 标记当前订单留言已读。
/// </summary>
Task MarkReadAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
int throughSequenceNo);
}
/// <summary>
/// 工单接口调用失败时返回的异常。
/// </summary>
public sealed class TicketApiException : System.Exception
{
/// <summary>
/// 创建工单接口异常。
/// </summary>
public TicketApiException(
int statusCode,
string message,
string responseBody = null)
: base(message)
{
StatusCode = statusCode;
ResponseBody = responseBody;
}
/// <summary>
/// 服务端返回的状态码;无法从 GetHtml 响应中识别时为 0。
/// </summary>
public int StatusCode { get; }
/// <summary>
/// 服务端返回的原始响应正文。
/// </summary>
public string ResponseBody { get; }
}
@@ -0,0 +1,36 @@
using OrderSupportTicket.Models;
using System;
using System.Threading.Tasks;
namespace OrderSupportTicket.Services;
/// <summary>
/// 工单 SSE 实时通知服务。
/// </summary>
public interface ITicketRealtimeService : IDisposable
{
/// <summary>
/// 收到工单变更摘要通知时触发。
/// </summary>
event EventHandler<TicketRealtimeNotificationEventArgs> NotificationReceived;
/// <summary>
/// SSE 连接状态变化时触发。
/// </summary>
event EventHandler<TicketRealtimeStateChangedEventArgs> StateChanged;
/// <summary>
/// 当前 SSE 连接状态。
/// </summary>
TicketRealtimeState State { get; }
/// <summary>
/// 使用指定工单参数建立 SSE 连接。
/// </summary>
void Start(TicketClientOptions options);
/// <summary>
/// 停止 SSE 连接。
/// </summary>
Task StopAsync();
}
@@ -0,0 +1,432 @@
using AllBase;
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace OrderSupportTicket.Services;
/// <summary>
/// 拍单软件工单 API v2 客户端。对应 CLIENT-TICKET-API-V2 正式订单协议。
/// </summary>
public sealed class TicketApiService : ITicketApiService
{
internal const string ServerUrl = "https://ai.e57.cn";
private const string ApiRoot = "/api/open/v2/customer-work-orders";
private static readonly Regex AppIdRegex =
new Regex(@"^wx[0-9a-f]{16}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex OrderIdRegex =
new Regex(@"^[0-9]{10,64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex PurchaseAccountRegex =
new Regex(@"^[\p{L}\p{N}_.@-]{2,120}$", RegexOptions.Compiled);
private static readonly Regex OperationIdRegex =
new Regex(@"^[A-Za-z0-9_.:-]{1,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// 获取工单列表
/// </summary>
/// <param name="options"></param>
/// <param name="wechatOrderId"></param>
/// <param name="status"></param>
/// <param name="limit"></param>
/// <param name="cursor"></param>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public async Task<WorkOrderListResponse> GetWorkOrdersAsync(
TicketClientOptions options,
string wechatOrderId = null,
string status = null,
int limit = 20,
string cursor = null)
{
ValidateOptions(options);
if (!string.IsNullOrWhiteSpace(wechatOrderId))
ValidateWechatOrderId(wechatOrderId);
if (limit < 1 || limit > 100)
throw new ArgumentOutOfRangeException(nameof(limit), "limit 必须为 1~100。");
var request = new ListWorkOrdersRequest
{
PurchaseAccount = options.UserName,
AppIds = NormalizeAppIds(options.AppIds),
WechatOrderId = string.IsNullOrWhiteSpace(wechatOrderId) ? null : wechatOrderId,
Status = string.IsNullOrWhiteSpace(status) ? null : status,
Limit = limit,
Cursor = string.IsNullOrWhiteSpace(cursor) ? null : cursor
};
return await PostAsync<WorkOrderListResponse>(
ApiRoot + "/list",
request).ConfigureAwait(false);
}
/// <summary>
/// 获取工单消息
/// </summary>
/// <param name="options"></param>
/// <param name="appId"></param>
/// <param name="wechatOrderId"></param>
/// <param name="before"></param>
/// <param name="limit"></param>
/// <returns></returns>
/// <exception cref="ArgumentOutOfRangeException"></exception>
public async Task<WorkOrderMessagesResponse> GetMessagesAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
string before = null,
int limit = 50)
{
ValidateOptions(options);
ValidateAppId(appId);
ValidateWechatOrderId(wechatOrderId);
if (limit < 1 || limit > 100)
throw new ArgumentOutOfRangeException(nameof(limit), "limit 必须为 1~100。");
var request = new GetMessagesRequest
{
PurchaseAccount = options.UserName,
AppId = appId,
WechatOrderId = wechatOrderId,
Limit = limit,
Before = string.IsNullOrWhiteSpace(before) ? null : before
};
var path = ApiRoot
+ "/messages";
return await PostAsync<WorkOrderMessagesResponse>(path, request)
.ConfigureAwait(false);
}
/// <summary>
/// 创建工单
/// </summary>
/// <param name="options"></param>
/// <param name="order"></param>
/// <param name="description"></param>
/// <param name="clientRequestId"></param>
/// <returns></returns>
public async Task<CreateWorkOrderResponse> CreateWorkOrderAsync(
TicketClientOptions options,
TicketOrderOption order,
string description,
string clientRequestId = null)
{
ValidateOptions(options);
ValidateOrder(order);
ValidateDescription(description);
ValidateOperationId(clientRequestId, "clientRequestId", required: false);
var request = new CreateWorkOrderRequest
{
PurchaseAccount = options.UserName,
AppId = order.AppId,
WechatOrderId = order.OrderId,
Description = description,
ClientRequestId = string.IsNullOrWhiteSpace(clientRequestId)
? null
: clientRequestId
};
return await PostAsync<CreateWorkOrderResponse>(
ApiRoot,
request);
}
/// <summary>
/// 发送工单消息
/// </summary>
/// <param name="options"></param>
/// <param name="workOrder"></param>
/// <param name="text"></param>
/// <param name="clientMessageId"></param>
/// <returns></returns>
/// <exception cref="ArgumentNullException"></exception>
public async Task<SendMessageResponse> SendMessageAsync(
TicketClientOptions options,
WorkOrderSummaryDto workOrder,
string text,
string clientMessageId)
{
ValidateOptions(options);
if (workOrder == null)
throw new ArgumentNullException(nameof(workOrder));
ValidateAppId(workOrder.AppId);
ValidateWechatOrderId(workOrder.WechatOrderId);
ValidateMessageText(text);
ValidateOperationId(clientMessageId, "clientMessageId", required: true);
var request = new SendMessageRequest
{
PurchaseAccount = options.UserName,
AppId = workOrder.AppId,
ClientMessageId = clientMessageId,
Text = text
};
var path = ApiRoot
+ "/orders/"
+ Uri.EscapeDataString(workOrder.WechatOrderId)
+ "/messages";
return await PostAsync<SendMessageResponse>(path, request)
.ConfigureAwait(false);
}
public async Task MarkReadAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
int throughSequenceNo)
{
ValidateOptions(options);
ValidateAppId(appId);
ValidateWechatOrderId(wechatOrderId);
if (throughSequenceNo < 1)
return;
var request = new MarkReadRequest
{
PurchaseAccount = options.UserName,
AppId = appId,
ThroughSequenceNo = throughSequenceNo
};
var path = ApiRoot
+ "/orders/"
+ Uri.EscapeDataString(wechatOrderId)
+ "/read";
var html = await PostBodyAsync(path, request).ConfigureAwait(false);
EnsureSuccessBody(html);
}
public static void ValidateOptions(TicketClientOptions options)
{
if (options == null)
throw new ArgumentNullException(nameof(options));
if (string.IsNullOrWhiteSpace(options.UserName)
|| !PurchaseAccountRegex.IsMatch(options.UserName))
{
throw new ArgumentException(
"purchaseAccount 必填,且只能为 2~120 位中英文、数字或 _.@-。",
nameof(options));
}
NormalizeAppIds(options.AppIds);
}
public static void ValidateAppId(string appId)
{
if (string.IsNullOrWhiteSpace(appId) || !AppIdRegex.IsMatch(appId))
throw new ArgumentException(
"appId 必须为 wx 加 16 位小写十六进制字符。",
nameof(appId));
}
public static void ValidateWechatOrderId(string wechatOrderId)
{
if (string.IsNullOrWhiteSpace(wechatOrderId)
|| !OrderIdRegex.IsMatch(wechatOrderId))
{
throw new ArgumentException(
"wechatOrderId 必须保持为 10~64 位数字字符串。",
nameof(wechatOrderId));
}
}
public static void ValidateOrder(TicketOrderOption order)
{
if (order == null)
throw new ArgumentNullException(nameof(order));
ValidateAppId(order.AppId);
ValidateWechatOrderId(order.OrderId);
}
private static async Task<T> PostAsync<T>(
string path,
object request)
{
var html = await PostBodyAsync(path, request).ConfigureAwait(false);
return DeserializeResponse<T>(html);
}
private static async Task<string> PostBodyAsync(
string path,
object request)
{
var url = BuildUri(path);
// 请求 DTO 使用 DataMember 驼峰字段,先序列化后走 GetHtml 的字符串重载。
var json = Serialize(request);
var html = await GetHtml.PostJsonAsync(url, json).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(html))
throw RequestFailed("工单接口未返回数据。");
return html;
}
private static void EnsureSuccessBody(string html)
{
if (string.IsNullOrWhiteSpace(html))
throw RequestFailed("工单接口未返回数据。");
ApiSuccessResponse response;
try
{
response = Deserialize<ApiSuccessResponse>(html);
}
catch (SerializationException)
{
throw RequestFailed(html);
}
if (response != null && !response.Success)
{
throw RequestFailed(
ExtractErrorMessage(html)
?? "工单接口请求失败。",
html);
}
}
private static T DeserializeResponse<T>(string html)
{
EnsureSuccessBody(html);
try
{
var result = Deserialize<T>(html);
if (result == null)
throw new SerializationException("接口响应为空。");
return result;
}
catch (SerializationException)
{
throw RequestFailed("工单接口返回了无法解析的数据。", html);
}
}
private static string ExtractErrorMessage(string body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
try
{
var error = Deserialize<ApiErrorResponse>(body);
return error == null
? null
: error.Message
?? error.Error
?? error.Code;
}
catch (SerializationException)
{
return null;
}
}
private static string Serialize(object value)
{
var serializer = new DataContractJsonSerializer(value.GetType());
using (var stream = new MemoryStream())
{
serializer.WriteObject(stream, value);
return Encoding.UTF8.GetString(stream.ToArray());
}
}
private static T Deserialize<T>(string json)
{
var serializer = new DataContractJsonSerializer(typeof(T));
using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(json ?? string.Empty)))
{
return (T)serializer.ReadObject(stream);
}
}
private static string BuildUri(string path)
{
return ServerUrl + path;
}
private static TicketApiException RequestFailed(
string message,
string responseBody = null)
{
return new TicketApiException(
ExtractStatusCode(responseBody),
message,
responseBody);
}
private static int ExtractStatusCode(string responseBody)
{
if (string.IsNullOrWhiteSpace(responseBody))
return 0;
try
{
var error = Deserialize<ApiErrorResponse>(responseBody);
if (error != null && int.TryParse(error.Code, out var code))
return code;
}
catch (SerializationException)
{
// GetHtml 在网络异常时会返回普通错误文本,此时没有 HTTP 状态码。
}
return 0;
}
private static List<string> NormalizeAppIds(IEnumerable<string> appIds)
{
var normalized = (appIds ?? Enumerable.Empty<string>())
.Where(a => !string.IsNullOrWhiteSpace(a))
.Select(a => a.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
if (normalized.Count == 0)
throw new ArgumentException("appIds 至少需要一个 APPID。", nameof(appIds));
if (normalized.Count > 50)
throw new ArgumentException("appIds 最多支持 50 个 APPID。", nameof(appIds));
foreach (var appId in normalized)
ValidateAppId(appId);
return normalized;
}
private static void ValidateDescription(string description)
{
if (string.IsNullOrWhiteSpace(description)
|| description.Length < 1
|| description.Length > 2000)
{
throw new ArgumentException("description 必须为 1~2000 字。", nameof(description));
}
}
private static void ValidateMessageText(string text)
{
if (string.IsNullOrWhiteSpace(text)
|| text.Length < 1
|| text.Length > 4000)
{
throw new ArgumentException("text 必须为 1~4000 字。", nameof(text));
}
}
private static void ValidateOperationId(
string value,
string parameterName,
bool required)
{
if (string.IsNullOrWhiteSpace(value))
{
if (required)
throw new ArgumentException(parameterName + " 必填。", parameterName);
return;
}
if (!OperationIdRegex.IsMatch(value))
{
throw new ArgumentException(
parameterName + " 必须为 1~128 位字母、数字或 _.:-。",
parameterName);
}
}
}