using AllBase; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using OrderSupportTicket.Models; using OrderSupportTicket.Models.Api; using System; using System.Collections.Generic; using System.Linq; 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 : ITicketApiService { internal static 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); 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 }; 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) { 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(path, request); } 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); } private static int ExtractStatusCode(string responseBody) { if (string.IsNullOrWhiteSpace(responseBody)) return 0; try { var error = JsonConvert.DeserializeObject(responseBody, JsonSettings); if (error != null && int.TryParse(error.Code, out var code)) return code; } catch (JsonException) { // GetHtml 在网络异常时会返回普通错误文本,此时没有 HTTP 状态码。 } 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 > 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); } } }