| | | 1 | | using Microsoft.AspNetCore.Authorization; |
| | | 2 | | using Microsoft.AspNetCore.Mvc; |
| | | 3 | | using Microsoft.Extensions.Caching.Memory; |
| | | 4 | | using Newtonsoft.Json.Linq; |
| | | 5 | | using OzonOrdersWeb.Filters; |
| | | 6 | | using Services.ApiServices.TradesoftApi.Models.Response; |
| | | 7 | | using Services.ApiServices.ZZapApi; |
| | | 8 | | using Services.ApiSupplierSettingsServices; |
| | | 9 | | |
| | | 10 | | namespace OzonOrdersWeb.Areas.Studio2.Controllers; |
| | | 11 | | |
| | | 12 | | /// <summary>Проценка ZZap Москва: /search/light только по запрошенному номеру, новые детали. |
| | | 13 | | /// Запросы к ZZap идут через общий троттлинг клиента (1 запрос в 3,5 с), поэтому при массовом |
| | | 14 | | /// прогреве строк этот источник отвечает позже остальных — это ограничение API, а не сбой.</summary> |
| | | 15 | | [Area("Studio2")] |
| | | 16 | | [Authorize(Roles = "User,Admin")] |
| | | 17 | | [ServiceFilter(typeof(SupplierQuotationHistoryFilter), Order = 0)] |
| | | 18 | | [ServiceFilter(typeof(SupplierDeliverySurchargeFilter), Order = 1)] |
| | | 19 | | [ServiceFilter(typeof(SupplierWarehouseRestrictionFilter), Order = 2)] |
| | | 20 | | public sealed class ZZapMoscowPriceController : Controller |
| | | 21 | | { |
| | | 22 | | private readonly ZZapDataManager _zzap; |
| | | 23 | | private readonly ApiSupplierSettingsService _settings; |
| | | 24 | | private readonly IMemoryCache _cache; |
| | | 25 | | |
| | | 26 | | // Ответ ZZap живёт недолго: цена меняется, но повторное раскрытие строки не должно занимать слот лимита API. |
| | 0 | 27 | | private static readonly object CreateLock = new(); |
| | 0 | 28 | | private static readonly TimeSpan ResponseCacheTtl = TimeSpan.FromMinutes(3); |
| | | 29 | | |
| | 0 | 30 | | public ZZapMoscowPriceController(ZZapDataManager zzap, ApiSupplierSettingsService settings, IMemoryCache cache) |
| | | 31 | | { |
| | 0 | 32 | | _zzap = zzap; |
| | 0 | 33 | | _settings = settings; |
| | 0 | 34 | | _cache = cache; |
| | 0 | 35 | | } |
| | | 36 | | |
| | | 37 | | [HttpGet] |
| | | 38 | | public async Task<IActionResult> Search(string code, string? brand, int? orderId = null, string? zasylWarehouseName |
| | | 39 | | { |
| | 0 | 40 | | if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(brand)) |
| | 0 | 41 | | return Ok(Array.Empty<PreOrderItem>()); |
| | | 42 | | |
| | | 43 | | try |
| | | 44 | | { |
| | 0 | 45 | | var settings = await _settings.GetZZapMoscowAsync(HttpContext.RequestAborted); |
| | 0 | 46 | | if (!settings.IsQuotationEnabled) |
| | 0 | 47 | | return Ok(Array.Empty<PreOrderItem>()); |
| | | 48 | | |
| | 0 | 49 | | var response = await GetSearchResponseAsync(code.Trim(), brand.Trim()); |
| | 0 | 50 | | var items = ZZapPriceMapper.MapOriginals(response, code.Trim(), brand.Trim(), settings, _zzap.DefaultCodeReg |
| | 0 | 51 | | return Ok(SupplierQuotationDeliveryDestination.Apply(items, settings.DeliveryAddress)); |
| | | 52 | | } |
| | 0 | 53 | | catch (ZZapApiException ex) |
| | | 54 | | { |
| | 0 | 55 | | return ex.StatusCode == 429 |
| | 0 | 56 | | ? StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = ex.Message }) |
| | 0 | 57 | | : Ok(new { items = Array.Empty<PreOrderItem>(), error = ex.Message }); |
| | | 58 | | } |
| | 0 | 59 | | catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested) |
| | | 60 | | { |
| | 0 | 61 | | return StatusCode(499, new { error = "request_canceled" }); |
| | | 62 | | } |
| | 0 | 63 | | catch (Exception ex) when (ex is HttpRequestException or TimeoutException or TaskCanceledException) |
| | | 64 | | { |
| | 0 | 65 | | return Ok(new { items = Array.Empty<PreOrderItem>(), error = "Сервис ZZap временно недоступен." }); |
| | | 66 | | } |
| | 0 | 67 | | } |
| | | 68 | | |
| | | 69 | | /// <summary> |
| | | 70 | | /// Кэш и объединение одинаковых запросов: параллельные вызовы с тем же артикулом и брендом ждут один |
| | | 71 | | /// ответ ZZap, а не стоят в очереди лимита по очереди. Отмена одного клиента не рвёт общий запрос, |
| | | 72 | | /// ошибки не кэшируются. |
| | | 73 | | /// </summary> |
| | | 74 | | private async Task<JObject> GetSearchResponseAsync(string code, string brand) |
| | | 75 | | { |
| | 0 | 76 | | var key = $"api-supplier:ZZapMoscow:search-light:{_zzap.DefaultCodeRegion}:{code.ToUpperInvariant()}:{brand.ToUp |
| | | 77 | | Task<JObject> task; |
| | | 78 | | // GetOrCreate не атомарен: без блокировки два одновременных запроса оба ушли бы в очередь лимита. |
| | 0 | 79 | | lock (CreateLock) |
| | | 80 | | { |
| | 0 | 81 | | task = _cache.GetOrCreate(key, entry => |
| | 0 | 82 | | { |
| | 0 | 83 | | entry.AbsoluteExpirationRelativeToNow = ResponseCacheTtl; |
| | 0 | 84 | | var started = _zzap.SearchLightAsync(code, brand, ZZapDataManager.SearchLightNewOnly, ct: CancellationTo |
| | 0 | 85 | | // Если все ждавшие клиенты отменились, ошибку всё равно нужно «наблюдать» (UnobservedTaskException). |
| | 0 | 86 | | _ = started.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted); |
| | 0 | 87 | | return started; |
| | 0 | 88 | | })!; |
| | 0 | 89 | | } |
| | | 90 | | |
| | | 91 | | try |
| | | 92 | | { |
| | 0 | 93 | | return await task.WaitAsync(HttpContext.RequestAborted); |
| | | 94 | | } |
| | 0 | 95 | | catch when (task.IsFaulted || task.IsCanceled) |
| | | 96 | | { |
| | 0 | 97 | | _cache.Remove(key); |
| | 0 | 98 | | throw; |
| | | 99 | | } |
| | 0 | 100 | | } |
| | | 101 | | } |