| | | 1 | | using System.Diagnostics; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using Microsoft.Extensions.Logging; |
| | | 5 | | |
| | | 6 | | namespace Services.ApiServices.InterpartsEngine; |
| | | 7 | | |
| | | 8 | | public interface IInterpartsEngineApiClient |
| | | 9 | | { |
| | | 10 | | Task<InterpartsEngineResult<EngineBrandsResponse>> GetBrandsAsync(string article, CancellationToken ct = default); |
| | | 11 | | Task<InterpartsEngineResult<EngineOffersResponse>> SearchOffersAsync(string article, string brand, CancellationToken |
| | | 12 | | |
| | | 13 | | /// <summary>Актуальные цена и наличие одного предложения (перепроверка перед покупкой).</summary> |
| | | 14 | | Task<InterpartsEngineResult<EngineOffer>> GetOfferAsync(string offerId, CancellationToken ct = default); |
| | | 15 | | } |
| | | 16 | | |
| | | 17 | | /// <summary> |
| | | 18 | | /// HTTP-клиент движка Interparts (контракт docs/engine-api.md, v0.3 + обязательный <c>X-Customer-Login</c>). |
| | | 19 | | /// Ретраев нет: каждый запрос без кэша движка опрашивает поставщиков. Все сбои возвращаются в |
| | | 20 | | /// <see cref="InterpartsEngineResult{T}"/>; исключения наружу не выходят, кроме отмены вызывающим. |
| | | 21 | | /// </summary> |
| | | 22 | | public sealed class InterpartsEngineApiClient : IInterpartsEngineApiClient |
| | | 23 | | { |
| | 1 | 24 | | private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; |
| | | 25 | | |
| | | 26 | | /// <summary>Обещание контракта; дольше — повод для предупреждения в логах.</summary> |
| | 1 | 27 | | private static readonly TimeSpan ContractSearchTime = TimeSpan.FromSeconds(30); |
| | | 28 | | |
| | | 29 | | private readonly IHttpClientFactory _factory; |
| | | 30 | | private readonly InterpartsEngineConfig _config; |
| | | 31 | | private readonly ILogger<InterpartsEngineApiClient> _logger; |
| | | 32 | | |
| | 14 | 33 | | public InterpartsEngineApiClient(IHttpClientFactory factory, InterpartsEngineConfig config, ILogger<InterpartsEngine |
| | | 34 | | { |
| | 14 | 35 | | _factory = factory; |
| | 14 | 36 | | _config = config; |
| | 14 | 37 | | _logger = logger; |
| | 14 | 38 | | } |
| | | 39 | | |
| | | 40 | | public Task<InterpartsEngineResult<EngineBrandsResponse>> GetBrandsAsync(string article, CancellationToken ct = defa |
| | 5 | 41 | | SendAsync<EngineBrandsResponse>($"search/brands?article={Uri.EscapeDataString(article)}", ct); |
| | | 42 | | |
| | | 43 | | public Task<InterpartsEngineResult<EngineOffersResponse>> SearchOffersAsync(string article, string brand, Cancellati |
| | 2 | 44 | | SendAsync<EngineOffersResponse>( |
| | 2 | 45 | | $"search/offers?article={Uri.EscapeDataString(article)}&brand={Uri.EscapeDataString(brand)}", ct); |
| | | 46 | | |
| | | 47 | | public Task<InterpartsEngineResult<EngineOffer>> GetOfferAsync(string offerId, CancellationToken ct = default) => |
| | 7 | 48 | | SendAsync<EngineOffer>($"offers/{Uri.EscapeDataString(offerId)}", ct); |
| | | 49 | | |
| | | 50 | | private async Task<InterpartsEngineResult<T>> SendAsync<T>(string path, CancellationToken ct) where T : class |
| | | 51 | | { |
| | 14 | 52 | | if (!_config.IsConfigured) |
| | 1 | 53 | | return InterpartsEngineResult<T>.Failure("NOT_CONFIGURED", |
| | 1 | 54 | | "Не настроен доступ к движку Interparts (InterpartsEngine:ApiKey).", false); |
| | | 55 | | |
| | 13 | 56 | | var stopwatch = Stopwatch.StartNew(); |
| | | 57 | | try |
| | | 58 | | { |
| | 13 | 59 | | var client = _factory.CreateClient(InterpartsEngineConfig.HttpClientName); |
| | 13 | 60 | | using var response = await client.GetAsync(path, ct); |
| | 10 | 61 | | var body = await response.Content.ReadAsStringAsync(ct); |
| | 10 | 62 | | LogDuration(path, response.StatusCode, stopwatch.Elapsed); |
| | | 63 | | |
| | 10 | 64 | | if (response.StatusCode == HttpStatusCode.OK) |
| | 3 | 65 | | return Parse<T>(body, path); |
| | | 66 | | |
| | 7 | 67 | | return MapError<T>(response.StatusCode, TryParseError(body), path); |
| | | 68 | | } |
| | 2 | 69 | | catch (OperationCanceledException) when (ct.IsCancellationRequested) |
| | | 70 | | { |
| | 1 | 71 | | throw; |
| | | 72 | | } |
| | 1 | 73 | | catch (OperationCanceledException) |
| | | 74 | | { |
| | | 75 | | // Движок досчитает ответ и закэширует его на ~7 минут: повтор того же запроса будет быстрым. |
| | 1 | 76 | | _logger.LogWarning("Interparts engine {Path}: нет ответа за {Seconds} с", path, (int)stopwatch.Elapsed.Total |
| | 1 | 77 | | return InterpartsEngineResult<T>.Failure("TIMEOUT", |
| | 1 | 78 | | "Движок не успел опросить поставщиков. Повторите запрос через минуту — ответ будет быстрее.", true); |
| | | 79 | | } |
| | 1 | 80 | | catch (HttpRequestException ex) |
| | | 81 | | { |
| | 1 | 82 | | _logger.LogWarning(ex, "Interparts engine {Path}: сервис недоступен", path); |
| | 1 | 83 | | return InterpartsEngineResult<T>.Failure("UNAVAILABLE", "Сервис подбора запчастей временно недоступен.", tru |
| | | 84 | | } |
| | 13 | 85 | | } |
| | | 86 | | |
| | | 87 | | private InterpartsEngineResult<T> Parse<T>(string body, string path) where T : class |
| | | 88 | | { |
| | | 89 | | try |
| | | 90 | | { |
| | 3 | 91 | | var value = JsonSerializer.Deserialize<T>(body, JsonOptions); |
| | 2 | 92 | | return value is null |
| | 2 | 93 | | ? InterpartsEngineResult<T>.Failure("BAD_RESPONSE", "Пустой ответ движка.", true) |
| | 2 | 94 | | : InterpartsEngineResult<T>.Ok(value); |
| | | 95 | | } |
| | 1 | 96 | | catch (JsonException ex) |
| | | 97 | | { |
| | 1 | 98 | | _logger.LogWarning(ex, "Interparts engine {Path}: не удалось разобрать ответ", path); |
| | 1 | 99 | | return InterpartsEngineResult<T>.Failure("BAD_RESPONSE", "Не удалось разобрать ответ движка.", true); |
| | | 100 | | } |
| | 3 | 101 | | } |
| | | 102 | | |
| | | 103 | | private InterpartsEngineResult<T> MapError<T>(HttpStatusCode status, EngineErrorBody? error, string path) where T : |
| | | 104 | | { |
| | 7 | 105 | | var code = error?.Code ?? $"HTTP_{(int)status}"; |
| | | 106 | | switch (status) |
| | | 107 | | { |
| | | 108 | | case HttpStatusCode.NotFound: |
| | 1 | 109 | | return InterpartsEngineResult<T>.NotFound(error?.Message ?? "Предложение не найдено."); |
| | | 110 | | case HttpStatusCode.Gone: |
| | 1 | 111 | | return InterpartsEngineResult<T>.Expired("Предложение устарело — повторите поиск."); |
| | | 112 | | case HttpStatusCode.Unauthorized: |
| | | 113 | | case HttpStatusCode.Forbidden: |
| | 2 | 114 | | _logger.LogError("Interparts engine {Path}: HTTP {Status} {Code} — проверьте InterpartsEngine:ApiKey и C |
| | 2 | 115 | | path, (int)status, code); |
| | 2 | 116 | | return InterpartsEngineResult<T>.Failure(code, "Движок отклонил доступ: проверьте ключ и учётку Interpar |
| | | 117 | | case HttpStatusCode.BadRequest: |
| | 1 | 118 | | _logger.LogWarning("Interparts engine {Path}: HTTP 400 {Code} {Message}", path, code, error?.Message); |
| | 1 | 119 | | return InterpartsEngineResult<T>.Failure(code, error?.Message ?? "Движок отклонил запрос.", false); |
| | | 120 | | default: |
| | 2 | 121 | | _logger.LogWarning("Interparts engine {Path}: HTTP {Status} {Code}", path, (int)status, code); |
| | 2 | 122 | | return InterpartsEngineResult<T>.Failure(code, "Сервис подбора запчастей временно недоступен.", true); |
| | | 123 | | } |
| | | 124 | | } |
| | | 125 | | |
| | | 126 | | private void LogDuration(string path, HttpStatusCode status, TimeSpan elapsed) |
| | | 127 | | { |
| | 10 | 128 | | if (elapsed > ContractSearchTime) |
| | 0 | 129 | | _logger.LogWarning("Interparts engine {Path}: HTTP {Status} за {Seconds} с — дольше контракта (30 с)", |
| | 0 | 130 | | path, (int)status, (int)elapsed.TotalSeconds); |
| | | 131 | | else |
| | 10 | 132 | | _logger.LogDebug("Interparts engine {Path}: HTTP {Status} за {Ms} мс", path, (int)status, (long)elapsed.Tota |
| | 10 | 133 | | } |
| | | 134 | | |
| | | 135 | | private static EngineErrorBody? TryParseError(string body) |
| | | 136 | | { |
| | 7 | 137 | | if (string.IsNullOrWhiteSpace(body)) return null; |
| | 7 | 138 | | try { return JsonSerializer.Deserialize<EngineErrorEnvelope>(body, JsonOptions)?.Error; } |
| | 0 | 139 | | catch (JsonException) { return null; } |
| | 7 | 140 | | } |
| | | 141 | | } |