Files
29WxOrder/OrderSupportTicket/Services/TicketApiService.cs
T
2026-10-17 18:18:05 +08:00

433 lines
12 KiB
C#
Raw Blame History

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