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); } } }