Files
29WxOrder/OrderSupportTicket/Services/TicketApiService.cs
T
2026-10-20 17:03:23 +08:00

744 lines
21 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 Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using WxOrder.Framework.Commons;
namespace OrderSupportTicket.Services;
/// <summary>
/// 拍单软件工单 API v2 客户端。对应 CLIENT-TICKET-API-V2 正式订单协议。
/// </summary>
public sealed class TicketApiService
{
internal static string ServerUrl = "https://ai.e57.cn";
private const string ApiRoot = "/api/open/v2/customer-work-orders";
/// <summary>
/// 每条留言最多可携带的图片数量。
/// </summary>
public const int MaxImageCount = 3;
/// <summary>
/// 单张图片允许的最大字节数(5 MiB)。
/// </summary>
public const long MaxImageBytes = 5 * 1024 * 1024;
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);
private static readonly HashSet<string> SupportedImageMimeTypes =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg",
"image/png",
"image/webp"
};
private static readonly HttpClient ImageUploadClient = CreateImageUploadClient();
internal static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
static TicketApiService()
{
if (EnvironmentHelper.IsTest)
{
ServerUrl = "http://localhost:5188";
}
}
/// <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);
}
/// <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);
}
/// <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,
IList<MessageAttachmentReceipt> attachments = null)
{
ValidateOptions(options);
if (workOrder == null)
throw new ArgumentNullException(nameof(workOrder));
ValidateAppId(workOrder.AppId);
ValidateWechatOrderId(workOrder.WechatOrderId);
ValidateMessageContent(text, attachments);
ValidateOperationId(clientMessageId, "clientMessageId", required: true);
var hasAttachments = attachments != null && attachments.Count > 0;
var request = new SendMessageRequest
{
PurchaseAccount = options.UserName,
AppId = workOrder.AppId,
ClientMessageId = clientMessageId,
Text = string.IsNullOrWhiteSpace(text) ? null : text,
Attachments = hasAttachments ? attachments.ToList() : null
};
var path = ApiRoot
+ "/orders/"
+ Uri.EscapeDataString(workOrder.WechatOrderId)
+ "/messages";
return await PostAsync<SendMessageResponse>(path, request);
}
/// <summary>
/// 申请单张图片的短时直传凭证。每张图片单独申请。
/// </summary>
public async Task<ImageUploadTicketResponse> CreateImageUploadTicketAsync(
TicketClientOptions options,
string appId,
string wechatOrderId,
string fileName,
string mimeType,
long size,
string requestId = null)
{
ValidateOptions(options);
ValidateAppId(appId);
ValidateWechatOrderId(wechatOrderId);
var normalizedFileName = ValidateImageFileName(fileName);
var normalizedMimeType = ValidateImageMimeType(mimeType);
ValidateImageSize(size);
ValidateOperationId(requestId, "requestId", required: false);
var request = new CreateImageUploadTicketRequest
{
PurchaseAccount = options.UserName,
AppId = appId,
FileName = normalizedFileName,
MimeType = normalizedMimeType,
Size = size,
RequestId = string.IsNullOrWhiteSpace(requestId) ? null : requestId
};
var path = ApiRoot
+ "/orders/"
+ Uri.EscapeDataString(wechatOrderId)
+ "/message-attachments/upload-ticket";
var response = await PostAsync<ImageUploadTicketResponse>(path, request);
ValidateImageUploadTicket(response?.Upload);
return response;
}
/// <summary>
/// 使用服务端签发的凭证把图片二进制直接 PUT 到 COS。
/// 返回的 requestId/ticket/etag 需原样提交到留言接口。
/// </summary>
public async Task<MessageAttachmentReceipt> UploadImageAsync(
ImageUploadTicketDto upload,
string filePath)
{
ValidateImageUploadTicket(upload);
if (string.IsNullOrWhiteSpace(filePath))
throw new ArgumentException("图片文件路径不能为空。", nameof(filePath));
byte[] bytes;
try
{
bytes = await Task.Run(() => File.ReadAllBytes(filePath)).ConfigureAwait(false);
}
catch (Exception ex) when (
ex is IOException
|| ex is UnauthorizedAccessException
|| ex is ArgumentException)
{
throw new ArgumentException("读取图片文件失败:" + ex.Message, nameof(filePath));
}
if (bytes.Length < 1 || bytes.Length > MaxImageBytes)
throw new ArgumentException("单张图片必须为 1 字节~5 MiB。", nameof(filePath));
var contentType = GetUploadHeader(upload.Headers, "Content-Type")
?? "application/octet-stream";
using (var request = new HttpRequestMessage(
new HttpMethod(upload.Method.ToUpperInvariant()),
upload.UploadUrl))
using (var content = new ByteArrayContent(bytes))
{
content.Headers.TryAddWithoutValidation("Content-Type", contentType);
foreach (var header in upload.Headers ?? new Dictionary<string, string>())
{
if (string.IsNullOrWhiteSpace(header.Key))
continue;
if (string.Equals(header.Key, "Content-Type", StringComparison.OrdinalIgnoreCase)
|| string.Equals(header.Key, "Content-Length", StringComparison.OrdinalIgnoreCase))
continue;
if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value))
content.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
request.Content = content;
HttpResponseMessage response;
try
{
response = await ImageUploadClient
.SendAsync(request)
.ConfigureAwait(false);
}
catch (Exception ex) when (
ex is HttpRequestException
|| ex is TaskCanceledException)
{
throw new TicketApiException(0, "图片直传网络异常,请稍后重试。");
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
var statusCode = (int)response.StatusCode;
string body = null;
try
{
body = await response.Content
.ReadAsStringAsync()
.ConfigureAwait(false);
}
catch
{
// 存储端错误正文仅用于提取可读提示,失败时使用通用文案。
}
throw new TicketApiException(
statusCode,
ExtractUploadErrorMessage(body, statusCode));
}
var etag = response.Headers.TryGetValues("ETag", out var values)
? values.FirstOrDefault()
: null;
return new MessageAttachmentReceipt
{
RequestId = upload.RequestId,
Ticket = upload.Ticket,
Etag = string.IsNullOrWhiteSpace(etag) ? null : etag
};
}
}
}
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);
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);
return DeserializeResponse<T>(html);
}
private static async Task<string> PostBodyAsync(
string path,
object request)
{
var url = BuildUri(path);
// 请求 DTO 使用 JsonProperty 驼峰字段,先序列化后走 GetHtml 的字符串重载。
var json = JsonConvert.SerializeObject(request, JsonSettings);
var html = await GetHtml.PostJsonAsync(url, json);
if (string.IsNullOrWhiteSpace(html))
throw RequestFailed("工单接口未返回数据。");
return html;
}
private static void EnsureSuccessBody(string html)
{
if (string.IsNullOrWhiteSpace(html))
throw RequestFailed("工单接口未返回数据。");
ApiSuccessResponse response;
try
{
response = JsonConvert.DeserializeObject<ApiSuccessResponse>(html, JsonSettings);
}
catch (JsonException)
{
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 = JsonConvert.DeserializeObject<T>(html, JsonSettings);
if (result == null)
throw new JsonSerializationException("接口响应为空。");
return result;
}
catch (JsonException)
{
throw RequestFailed("工单接口返回了无法解析的数据。", html);
}
}
private static string ExtractErrorMessage(string body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
try
{
var error = JsonConvert.DeserializeObject<ApiErrorResponse>(body, JsonSettings);
return error == null
? null
: error.Message
?? error.Error
?? error.Code;
}
catch (JsonException)
{
return null;
}
}
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,
ExtractErrorCode(responseBody));
}
private static ApiErrorResponse ExtractErrorResponse(string responseBody)
{
if (string.IsNullOrWhiteSpace(responseBody))
return null;
try
{
return JsonConvert.DeserializeObject<ApiErrorResponse>(responseBody, JsonSettings);
}
catch (JsonException)
{
return null;
}
}
private static string ExtractErrorCode(string responseBody)
{
return ExtractErrorResponse(responseBody)?.Code;
}
private static int ExtractStatusCode(string responseBody)
{
var error = ExtractErrorResponse(responseBody);
if (error == null || string.IsNullOrWhiteSpace(error.Code))
return 0;
if (int.TryParse(error.Code, out var code))
return code;
switch (error.Code.Trim().ToLowerInvariant())
{
case "client_api_image_expired":
return 410;
case "client_api_image_already_used":
return 409;
case "client_api_image_invalid":
return 422;
case "client_api_image_upload_failed":
return 400;
case "client_api_images_unavailable":
return 503;
default:
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 > 5000)
// 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 ValidateMessageContent(
string text,
IList<MessageAttachmentReceipt> attachments)
{
var attachmentCount = attachments?.Count ?? 0;
if (attachmentCount > MaxImageCount)
{
throw new ArgumentException(
"每条留言最多提交 " + MaxImageCount + " 张图片。",
nameof(attachments));
}
if (attachmentCount > 0)
ValidateAttachmentReceipts(attachments);
if (!string.IsNullOrWhiteSpace(text))
{
if (text.Length > 4000)
throw new ArgumentException("text 不能超过 4000 字。", nameof(text));
}
else if (attachmentCount == 0)
{
throw new ArgumentException("text 与图片附件不能同时为空。", nameof(text));
}
}
private static void ValidateAttachmentReceipts(
IList<MessageAttachmentReceipt> attachments)
{
var requestIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var attachment in attachments)
{
if (attachment == null
|| string.IsNullOrWhiteSpace(attachment.RequestId)
|| !OperationIdRegex.IsMatch(attachment.RequestId)
|| string.IsNullOrWhiteSpace(attachment.Ticket))
{
throw new ArgumentException(
"图片上传回执不完整或 requestId 格式不正确。",
nameof(attachments));
}
if (!requestIds.Add(attachment.RequestId))
throw new ArgumentException("图片上传回执不能重复。", nameof(attachments));
}
}
private static string ValidateImageFileName(string fileName)
{
var normalized = (fileName ?? string.Empty).Trim();
if (normalized.Length < 1
|| normalized.Length > 180
|| normalized.Any(char.IsControl)
|| normalized.IndexOfAny(new[] { '/', '\\' }) >= 0)
{
throw new ArgumentException(
"fileName 必须为 1~180 字符且不能包含路径分隔符或控制字符。",
nameof(fileName));
}
return normalized;
}
private static string ValidateImageMimeType(string mimeType)
{
var normalized = (mimeType ?? string.Empty).Trim().ToLowerInvariant();
if (!SupportedImageMimeTypes.Contains(normalized))
{
throw new ArgumentException(
"mimeType 仅支持 image/jpeg、image/png 或 image/webp。",
nameof(mimeType));
}
return normalized;
}
private static void ValidateImageSize(long size)
{
if (size < 1 || size > MaxImageBytes)
{
throw new ArgumentException(
"单张图片必须为 1 字节~5 MiB。",
nameof(size));
}
}
private static void ValidateImageUploadTicket(ImageUploadTicketDto upload)
{
if (upload == null
|| string.IsNullOrWhiteSpace(upload.RequestId)
|| string.IsNullOrWhiteSpace(upload.Ticket)
|| string.IsNullOrWhiteSpace(upload.UploadUrl)
|| string.IsNullOrWhiteSpace(GetUploadHeader(upload.Headers, "Content-Type"))
|| !string.Equals(upload.Method, "PUT", StringComparison.OrdinalIgnoreCase))
{
throw new TicketApiException(0, "服务端返回的图片上传凭证不完整。");
}
if (!Uri.TryCreate(upload.UploadUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
{
throw new TicketApiException(0, "服务端返回的图片上传地址无效。");
}
}
private static string GetUploadHeader(
IDictionary<string, string> headers,
string name)
{
if (headers == null)
return null;
foreach (var header in headers)
{
if (string.Equals(header.Key, name, StringComparison.OrdinalIgnoreCase))
return header.Value;
}
return null;
}
private static string ExtractUploadErrorMessage(
string body,
int statusCode)
{
var error = ExtractErrorResponse(body);
if (!string.IsNullOrWhiteSpace(error?.Message))
return error.Message;
if (!string.IsNullOrWhiteSpace(error?.Error))
return error.Error;
if (statusCode == 403)
return "图片上传地址已失效或无权访问,请重新选择图片后发送。";
return "图片直传失败(HTTP " + statusCode + "),请重试。";
}
private static HttpClient CreateImageUploadClient()
{
try
{
ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls12;
}
catch
{
// 旧系统不支持显式 TLS 1.2 时保留运行时默认值。
}
return new HttpClient(new HttpClientHandler
{
UseProxy = false
})
{
Timeout = TimeSpan.FromSeconds(120)
};
}
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);
}
}
}