using AllBase;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using OrderSupportTicket.Models;
using OrderSupportTicket.Models.Api;
using System;
using System.Collections.Generic;
using System.Diagnostics;
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;
///
/// 拍单软件工单 API v2 客户端。对应 CLIENT-TICKET-API-V2 正式订单协议。
///
public sealed class TicketApiService
{
internal static string ServerUrl = "https://ai.e57.cn";
private const string ApiRoot = "/api/open/v2/customer-work-orders";
///
/// 每条留言最多可携带的图片数量。
///
public const int MaxImageCount = 3;
///
/// 单张图片允许的最大字节数(5 MiB)。
///
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 SupportedImageMimeTypes =
new HashSet(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";
}
}
///
/// 获取工单列表
///
///
///
///
///
///
///
///
public async Task 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(
ApiRoot + "/list",
request);
}
///
/// 获取工单消息
///
///
///
///
///
///
///
///
public async Task 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
};
Debug.WriteLine($"{wechatOrderId}加载所有对话");
var path = ApiRoot
+ "/messages";
return await PostAsync(path, request);
}
///
/// 创建工单
///
///
///
///
///
///
public async Task 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(
ApiRoot,
request);
}
///
/// 发送工单消息
///
///
///
///
///
///
///
public async Task SendMessageAsync(
TicketClientOptions options,
WorkOrderSummaryDto workOrder,
string text,
string clientMessageId,
IList 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(path, request);
}
///
/// 申请单张图片的短时直传凭证。每张图片单独申请。
///
public async Task 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(path, request);
ValidateImageUploadTicket(response?.Upload);
return response;
}
///
/// 使用服务端签发的凭证把图片二进制直接 PUT 到 COS。
/// 返回的 requestId/ticket/etag 需原样提交到留言接口。
///
public async Task 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())
{
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 PostAsync(
string path,
object request)
{
var html = await PostBodyAsync(path, request);
return DeserializeResponse(html);
}
private static async Task 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(html, JsonSettings);
}
catch (JsonException)
{
throw RequestFailed(html);
}
if (response != null && !response.Success)
{
throw RequestFailed(
ExtractErrorMessage(html)
?? "工单接口请求失败。",
html);
}
}
private static T DeserializeResponse(string html)
{
EnsureSuccessBody(html);
try
{
var result = JsonConvert.DeserializeObject(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(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(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 NormalizeAppIds(IEnumerable appIds)
{
var normalized = (appIds ?? Enumerable.Empty())
.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 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 attachments)
{
var requestIds = new HashSet(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 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);
}
}
}