| | | 1 | | using System.Net.Http.Headers; |
| | | 2 | | using System.Text; |
| | | 3 | | using Newtonsoft.Json; |
| | | 4 | | using Newtonsoft.Json.Linq; |
| | | 5 | | using Services.ApiServices.ArmtekApi.Models.Envelope; |
| | | 6 | | using Services.ApiServices.ApiSupplierRequestLog; |
| | | 7 | | |
| | | 8 | | namespace Services.ApiServices.ArmtekApi; |
| | | 9 | | |
| | | 10 | | /// <summary> |
| | | 11 | | /// Низкоуровневый HTTP-клиент Armtek Web-Services (https://ws.armtek.ru). Basic Auth, конверт |
| | | 12 | | /// ответа STATUS/MESSAGES/RESP (см. docs/api/armtek.md). Ретраи на транспортном уровне — |
| | | 13 | | /// через Polly (BaseApiClient.GetRetryPolicy() на именованном HttpClient("ArmtekApi") в DI), |
| | | 14 | | /// здесь ручного retry-цикла нет. |
| | | 15 | | /// </summary> |
| | | 16 | | public class ArmtekHttpClient |
| | | 17 | | { |
| | | 18 | | private readonly IHttpClientFactory _httpClientFactory; |
| | | 19 | | private readonly string _baseUrl; |
| | | 20 | | private readonly string _base64Auth; |
| | | 21 | | private readonly ApiSupplierRequestLogStore? _logs; |
| | | 22 | | private readonly string _httpClientName; |
| | | 23 | | private readonly string _providerKey; |
| | | 24 | | private readonly string _quotaKey; |
| | | 25 | | private readonly ArmtekDailyQuotaGuard _dailyQuotaGuard; |
| | | 26 | | |
| | | 27 | | // Армтек документирует таймаут getOrder2?STATUS=1 в 90 секунд — клиентский таймаут больше, |
| | | 28 | | // иначе .NET оборвёт соединение раньше, чем сервис успеет вернуть даже частичный ответ. |
| | 1 | 29 | | private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(100); |
| | | 30 | | |
| | | 31 | | // getStoreList отдаёт весь справочник складов: ~100 тыс. записей, 13–16 МБ без сжатия (замер 09.10.2026, API отвеча |
| | | 32 | | // На канале прод-сервера 100 секунд на такой объём не хватает, поэтому ему даётся отдельный, больший таймаут. |
| | 1 | 33 | | private static readonly TimeSpan StoreListTimeout = TimeSpan.FromMinutes(5); |
| | | 34 | | |
| | | 35 | | private static TimeSpan TimeoutFor(string path) => |
| | 85 | 36 | | string.Equals(path, "ws_user/getStoreList", StringComparison.OrdinalIgnoreCase) ? StoreListTimeout : DefaultTime |
| | | 37 | | |
| | 157 | 38 | | public ArmtekHttpClient(IHttpClientFactory httpClientFactory, ArmtekConfig config, ApiSupplierRequestLogStore? logs |
| | 157 | 39 | | string httpClientName = "ArmtekApi", string providerKey = "Armtek", ArmtekDailyQuotaGuard? dailyQuotaGuard = nul |
| | | 40 | | { |
| | 157 | 41 | | _httpClientFactory = httpClientFactory; |
| | 157 | 42 | | _baseUrl = config.BaseUrl.TrimEnd('/'); |
| | 157 | 43 | | var authString = $"{config.User}:{config.Password}"; |
| | 157 | 44 | | _base64Auth = Convert.ToBase64String(Encoding.UTF8.GetBytes(authString)); |
| | 157 | 45 | | _logs = logs; |
| | 157 | 46 | | _httpClientName = httpClientName; |
| | 157 | 47 | | _providerKey = providerKey; |
| | 157 | 48 | | _quotaKey = $"{providerKey}:{config.User}"; |
| | 157 | 49 | | _dailyQuotaGuard = dailyQuotaGuard ?? new ArmtekDailyQuotaGuard(); |
| | 157 | 50 | | } |
| | | 51 | | |
| | | 52 | | private HttpClient CreateClient(TimeSpan? timeout = null) |
| | | 53 | | { |
| | 131 | 54 | | var client = _httpClientFactory.CreateClient(_httpClientName); |
| | 131 | 55 | | client.Timeout = timeout ?? DefaultTimeout; |
| | | 56 | | // ping формально не требует авторизации, но лишний заголовок его не ломает — |
| | | 57 | | // проще слать Basic Auth на все запросы одним и тем же CreateClient(). |
| | 131 | 58 | | client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", _base64Auth); |
| | 131 | 59 | | return client; |
| | | 60 | | } |
| | | 61 | | |
| | | 62 | | /// <summary>GET-запрос. path — без ведущего /api, например "ws_ping/index".</summary> |
| | | 63 | | public async Task<ArmtekResponse<T>> GetAsync<T>(string path, Dictionary<string, string>? query = null, Cancellation |
| | | 64 | | { |
| | 46 | 65 | | if (_dailyQuotaGuard.IsBlocked(_quotaKey)) |
| | 0 | 66 | | return DailyQuotaExceededResponse<T>(); |
| | | 67 | | |
| | 46 | 68 | | var url = BuildUrl(_baseUrl, path, query); |
| | 46 | 69 | | var client = CreateClient(); |
| | 46 | 70 | | var timer = System.Diagnostics.Stopwatch.StartNew(); |
| | 91 | 71 | | try { var response = await client.GetAsync(url, ct); return await ParseEnvelopeAsync<T>(response, url, "GET", nu |
| | 15 | 72 | | catch (Exception ex) { _logs?.Add(_providerKey, "GET", url, null, timer, null, ex.Message); throw; } |
| | 41 | 73 | | } |
| | | 74 | | |
| | | 75 | | /// <summary>POST application/x-www-form-urlencoded. path — без ведущего /api.</summary> |
| | | 76 | | public async Task<ArmtekResponse<T>> PostFormAsync<T>(string path, Dictionary<string, string> form, CancellationToke |
| | | 77 | | { |
| | 85 | 78 | | if (_dailyQuotaGuard.IsBlocked(_quotaKey)) |
| | 2 | 79 | | return DailyQuotaExceededResponse<T>(); |
| | | 80 | | |
| | 83 | 81 | | var url = BuildUrl(_baseUrl, path, null); |
| | 83 | 82 | | var formWithFormat = new Dictionary<string, string>(form) { ["format"] = "json" }; |
| | 83 | 83 | | var timer = System.Diagnostics.Stopwatch.StartNew(); |
| | 668 | 84 | | var requestForLog = string.Join("&", formWithFormat.Select(x => $"{x.Key}={x.Value}")); |
| | 83 | 85 | | var attempts = IsSafeToRetry(path) ? 2 : 1; |
| | | 86 | | |
| | 170 | 87 | | for (var attempt = 1; attempt <= attempts; attempt++) |
| | | 88 | | { |
| | 85 | 89 | | using var content = new FormUrlEncodedContent(formWithFormat); |
| | | 90 | | try |
| | | 91 | | { |
| | 85 | 92 | | using var response = await CreateClient(TimeoutFor(path)).PostAsync(url, content, ct); |
| | 74 | 93 | | return await ParseEnvelopeAsync<T>(response, url, "POST", await content.ReadAsStringAsync(ct), timer); |
| | | 94 | | } |
| | 15 | 95 | | catch (Exception ex) when (attempt < attempts && IsTransientTransportFailure(ex, ct)) |
| | | 96 | | { |
| | 2 | 97 | | await Task.Delay(TimeSpan.FromMilliseconds(250 * attempt), ct); |
| | | 98 | | } |
| | 13 | 99 | | catch (Exception ex) |
| | | 100 | | { |
| | 13 | 101 | | _logs?.Add(_providerKey, "POST", url, null, timer, requestForLog, ex.Message); |
| | 13 | 102 | | throw; |
| | | 103 | | } |
| | 2 | 104 | | } |
| | | 105 | | |
| | 0 | 106 | | throw new InvalidOperationException("Не удалось выполнить запрос Armtek."); |
| | 72 | 107 | | } |
| | | 108 | | |
| | | 109 | | // getStoreList отвечает десятки секунд или не отвечает вовсе: второй 100-секундный заход только удваивает время и ч |
| | | 110 | | private static bool IsSafeToRetry(string path) => |
| | 83 | 111 | | path.StartsWith("ws_search/", StringComparison.OrdinalIgnoreCase) || |
| | 83 | 112 | | (path.StartsWith("ws_user/", StringComparison.OrdinalIgnoreCase) && |
| | 83 | 113 | | !string.Equals(path, "ws_user/getStoreList", StringComparison.OrdinalIgnoreCase)) || |
| | 83 | 114 | | string.Equals(path, "ws_order/createTestOrder", StringComparison.OrdinalIgnoreCase); |
| | | 115 | | |
| | | 116 | | private static bool IsTransientTransportFailure(Exception exception, CancellationToken ct) => |
| | 6 | 117 | | !ct.IsCancellationRequested && exception is HttpRequestException or IOException or TaskCanceledException; |
| | | 118 | | |
| | | 119 | | private static string BuildUrl(string baseUrl, string path, Dictionary<string, string>? query) |
| | | 120 | | { |
| | 129 | 121 | | var parts = new List<string> { "format=json" }; |
| | 129 | 122 | | if (query != null) |
| | | 123 | | { |
| | 254 | 124 | | foreach (var kv in query) |
| | 97 | 125 | | parts.Add($"{kv.Key}={Uri.EscapeDataString(kv.Value)}"); |
| | | 126 | | } |
| | | 127 | | |
| | 129 | 128 | | return $"{baseUrl}/api/{path.TrimStart('/')}?{string.Join("&", parts)}"; |
| | | 129 | | } |
| | | 130 | | |
| | | 131 | | private async Task<ArmtekResponse<T>> ParseEnvelopeAsync<T>(HttpResponseMessage response, string requestUri, string |
| | | 132 | | { |
| | 119 | 133 | | var body = await response.Content.ReadAsStringAsync(); |
| | 119 | 134 | | _logs?.Add(_providerKey, method, requestUri, (int)response.StatusCode, timer, request, body); |
| | | 135 | | |
| | 119 | 136 | | if (!response.IsSuccessStatusCode) |
| | | 137 | | { |
| | 9 | 138 | | if (ArmtekDailyQuotaGuard.IsExceeded(body)) |
| | | 139 | | { |
| | 4 | 140 | | _dailyQuotaGuard.Block(_quotaKey); |
| | 4 | 141 | | return DailyQuotaExceededResponse<T>(); |
| | | 142 | | } |
| | | 143 | | |
| | 5 | 144 | | throw new ArmtekApiException( |
| | 5 | 145 | | $"Armtek HTTP {(int)response.StatusCode} {response.ReasonPhrase}: {TruncateError(body)}", |
| | 5 | 146 | | requestUri: requestUri); |
| | | 147 | | } |
| | | 148 | | |
| | | 149 | | ArmtekResponse<T>? envelope; |
| | | 150 | | try |
| | | 151 | | { |
| | 110 | 152 | | envelope = JsonConvert.DeserializeObject<ArmtekResponse<T>>(body); |
| | 108 | 153 | | } |
| | 2 | 154 | | catch (JsonException ex) |
| | | 155 | | { |
| | 2 | 156 | | var fallback = TryParseEnvelopeFallback<T>(body); |
| | 2 | 157 | | if (fallback != null) |
| | 0 | 158 | | return fallback; |
| | | 159 | | |
| | 2 | 160 | | throw new ArmtekApiException( |
| | 2 | 161 | | $"Armtek вернул тело, не являющееся валидным конвертом STATUS/MESSAGES/RESP: {TruncateError(body)}", |
| | 2 | 162 | | requestUri: requestUri, inner: ex); |
| | | 163 | | } |
| | | 164 | | |
| | 108 | 165 | | if (envelope == null) |
| | | 166 | | { |
| | 1 | 167 | | throw new ArmtekApiException( |
| | 1 | 168 | | $"Armtek вернул пустой или нераспознаваемый ответ: {TruncateError(body)}", |
| | 1 | 169 | | requestUri: requestUri); |
| | | 170 | | } |
| | | 171 | | |
| | 107 | 172 | | return envelope; |
| | 111 | 173 | | } |
| | | 174 | | |
| | 6 | 175 | | private static ArmtekResponse<T> DailyQuotaExceededResponse<T>() => new() |
| | 6 | 176 | | { |
| | 6 | 177 | | Status = 401, |
| | 6 | 178 | | Messages = |
| | 6 | 179 | | [ |
| | 6 | 180 | | new ArmtekMessage |
| | 6 | 181 | | { |
| | 6 | 182 | | Type = "A", |
| | 6 | 183 | | Text = "Превышено суточное количество запросов к API Armtek для данного логина.", |
| | 6 | 184 | | Date = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss") |
| | 6 | 185 | | } |
| | 6 | 186 | | ] |
| | 6 | 187 | | }; |
| | | 188 | | |
| | | 189 | | private static string TruncateError(string? text, int maxLen = 500) |
| | | 190 | | { |
| | 9 | 191 | | if (string.IsNullOrEmpty(text)) return "(пустое тело)"; |
| | 7 | 192 | | return text.Length <= maxLen ? text : text[..maxLen] + "…"; |
| | | 193 | | } |
| | | 194 | | |
| | | 195 | | /// <summary> |
| | | 196 | | /// Armtek в некоторых сценариях поиска (например "ничего не найдено") возвращает RESP как объект: |
| | | 197 | | /// {"MSG":"..."} вместо ожидаемого массива. Для T=List<> нормализуем RESP в пустой список и |
| | | 198 | | /// переносим MSG в MESSAGES[I], чтобы не падать исключением на штатном бизнес-ответе. |
| | | 199 | | /// </summary> |
| | | 200 | | private static ArmtekResponse<T>? TryParseEnvelopeFallback<T>(string body) |
| | | 201 | | { |
| | | 202 | | try |
| | | 203 | | { |
| | 2 | 204 | | var root = JObject.Parse(body); |
| | 1 | 205 | | if (root["STATUS"] == null || root["MESSAGES"] == null || root["RESP"] == null) |
| | 0 | 206 | | return null; |
| | | 207 | | |
| | 1 | 208 | | var respToken = root["RESP"]; |
| | 1 | 209 | | if (respToken?.Type != JTokenType.Object) |
| | 0 | 210 | | return null; |
| | | 211 | | |
| | 1 | 212 | | var targetType = typeof(T); |
| | 1 | 213 | | var isGenericList = targetType.IsGenericType && |
| | 1 | 214 | | targetType.GetGenericTypeDefinition() == typeof(List<>); |
| | 1 | 215 | | if (!isGenericList) |
| | 1 | 216 | | return null; |
| | | 217 | | |
| | 0 | 218 | | var envelope = new ArmtekResponse<T> |
| | 0 | 219 | | { |
| | 0 | 220 | | Status = root["STATUS"]?.Value<int>() ?? 0, |
| | 0 | 221 | | Messages = ParseMessages(root["MESSAGES"]), |
| | 0 | 222 | | Resp = (T)Activator.CreateInstance(targetType)! |
| | 0 | 223 | | }; |
| | | 224 | | |
| | 0 | 225 | | var msg = respToken["MSG"]?.ToString(); |
| | 0 | 226 | | if (!string.IsNullOrWhiteSpace(msg)) |
| | | 227 | | { |
| | 0 | 228 | | envelope.Messages ??= new List<ArmtekMessage>(); |
| | 0 | 229 | | envelope.Messages.Add(new ArmtekMessage |
| | 0 | 230 | | { |
| | 0 | 231 | | Type = "I", |
| | 0 | 232 | | Text = msg, |
| | 0 | 233 | | Date = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") |
| | 0 | 234 | | }); |
| | | 235 | | } |
| | | 236 | | |
| | 0 | 237 | | envelope.Resp = (T)Activator.CreateInstance(targetType)!; |
| | 0 | 238 | | return envelope; |
| | | 239 | | } |
| | 1 | 240 | | catch |
| | | 241 | | { |
| | 1 | 242 | | return null; |
| | | 243 | | } |
| | 2 | 244 | | } |
| | | 245 | | |
| | | 246 | | private static List<ArmtekMessage> ParseMessages(JToken? token) |
| | | 247 | | { |
| | 0 | 248 | | var result = new List<ArmtekMessage>(); |
| | 0 | 249 | | if (token is not JArray arr) |
| | 0 | 250 | | return result; |
| | | 251 | | |
| | 0 | 252 | | foreach (var item in arr) |
| | | 253 | | { |
| | 0 | 254 | | if (item is not JObject obj) |
| | | 255 | | continue; |
| | | 256 | | |
| | 0 | 257 | | result.Add(new ArmtekMessage |
| | 0 | 258 | | { |
| | 0 | 259 | | Type = obj["TYPE"]?.ToString() ?? string.Empty, |
| | 0 | 260 | | Text = obj["TEXT"]?.ToString() ?? string.Empty, |
| | 0 | 261 | | Date = obj["DATE"]?.ToString() ?? string.Empty |
| | 0 | 262 | | }); |
| | | 263 | | } |
| | | 264 | | |
| | 0 | 265 | | return result; |
| | | 266 | | } |
| | | 267 | | } |