433 lines
12 KiB
C#
433 lines
12 KiB
C#
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);
|
||
}
|
||
}
|
||
|
||
}
|