744 lines
21 KiB
C#
744 lines
21 KiB
C#
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);
|
||
}
|
||
}
|
||
|
||
}
|