< Summary

Information
Class: Services.OzonWebhookServices.Monitoring.OzonWebhookAvailabilityService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/OzonWebhookServices/Monitoring/OzonWebhookAvailabilityService.cs
Line coverage
34%
Covered lines: 44
Uncovered lines: 85
Coverable lines: 129
Total lines: 253
Line coverage: 34.1%
Branch coverage
48%
Covered branches: 24
Total branches: 50
Branch coverage: 48%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)0%2040%
RefreshAsync()0%620%
RefreshIfStaleAsync()0%2040%
EnableAsync()0%2040%
LoadCabinetAsync()0%620%
ParseEndpoints(...)87.5%88100%
IsMonitoredUrl(...)83.33%66100%
MapStatus(...)100%88100%
PostAsync()100%210%
SendWithRateLimitRetryAsync()100%44100%
HasCredentials(...)0%2040%
Truncate(...)0%620%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/OzonWebhookServices/Monitoring/OzonWebhookAvailabilityService.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Net.Http;
 5using System.Text;
 6using System.Threading;
 7using System.Threading.Tasks;
 8using Microsoft.Extensions.Configuration;
 9using Microsoft.Extensions.Logging;
 10using Newtonsoft.Json.Linq;
 11using OzonDomains;
 12using OzonDomains.Models;
 13using Services.DataServices;
 14
 15namespace Services.OzonWebhookServices.Monitoring
 16{
 17    public interface IOzonWebhookAvailabilityService
 18    {
 19        /// <summary>Опрашивает /v1/notification/list по всем кабинетам Ozon и обновляет снимок в <see cref="OzonWebhook
 20        Task<OzonWebhookAvailabilitySnapshot> RefreshAsync(CancellationToken ct = default);
 21
 22        /// <summary>
 23        /// Обновляет снимок, только если он старше <paramref name="maxAge"/>; ждёт не дольше <paramref name="wait"/>
 24        /// (запрос к Ozon при этом не отменяется и дойдёт до конца). Для проверки при загрузке страницы.
 25        /// </summary>
 26        Task RefreshIfStaleAsync(TimeSpan maxAge, TimeSpan wait);
 27
 28        /// <summary>Включает отправку на адрес (/v1/notification/enable) и перечитывает состояние.</summary>
 29        Task<string?> EnableAsync(int clientId, long urlId, CancellationToken ct = default);
 30    }
 31
 32    /// <summary>
 33    /// Контроль доступности пуш-адресов по данным Ozon (с 28.08.2026 Ozon сам замеряет время ответа:
 34    /// до 1500 мс — доступен, 1500–2500 — нестабилен, свыше 2500 — недоступен; три дня «недоступен» —
 35    /// отправка отключается).
 36    /// </summary>
 37    public sealed class OzonWebhookAvailabilityService : IOzonWebhookAvailabilityService
 38    {
 39        public const string HttpClientName = "OzonApi";
 40        private const string ListUrl = "https://api-seller.ozon.ru/v1/notification/list";
 41        private const string EnableUrl = "https://api-seller.ozon.ru/v1/notification/enable";
 142        private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(20);
 43
 44        /// <summary>Паузы перед повтором при 429 (лимит Ozon «request rate limit per second»).</summary>
 145        public static readonly TimeSpan[] RateLimitRetryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2), Time
 46
 47        private readonly OzonClientServices _clients;
 48        private readonly IHttpClientFactory _httpClientFactory;
 49        private readonly OzonWebhookAvailabilityStore _store;
 50        private readonly ILogger<OzonWebhookAvailabilityService> _logger;
 51        private readonly string[] _monitoredHosts;
 52
 53        /// <summary>Хосты по умолчанию — только адреса этого приложения; остальное в кабинете принадлежит другим сервис
 154        public static readonly string[] DefaultMonitoredHosts = ["studio2.mpsuper.ru"];
 55
 056        public OzonWebhookAvailabilityService(
 057            OzonClientServices clients,
 058            IHttpClientFactory httpClientFactory,
 059            OzonWebhookAvailabilityStore store,
 060            ILogger<OzonWebhookAvailabilityService> logger,
 061            IConfiguration configuration)
 62        {
 063            var configured = configuration.GetSection("OzonWebhook:MonitoredHosts").Get<string[]>();
 064            _monitoredHosts = configured is { Length: > 0 } ? configured : DefaultMonitoredHosts;
 065            _clients = clients;
 066            _httpClientFactory = httpClientFactory;
 067            _store = store;
 068            _logger = logger;
 069        }
 70
 71        public async Task<OzonWebhookAvailabilitySnapshot> RefreshAsync(CancellationToken ct = default)
 72        {
 073            var clients = (await _clients.GetOzonClients())
 074                .Where(HasCredentials)
 075                .OrderBy(c => c.Name)
 076                .ToList();
 77
 078            var cabinets = new List<OzonWebhookCabinetState>(clients.Count);
 079            foreach (var client in clients)
 080                cabinets.Add(await LoadCabinetAsync(client, ct));
 81
 082            var snapshot = new OzonWebhookAvailabilitySnapshot { CheckedAtUtc = DateTime.UtcNow, Cabinets = cabinets };
 083            _store.Set(snapshot);
 084            return snapshot;
 085        }
 86
 87        public async Task RefreshIfStaleAsync(TimeSpan maxAge, TimeSpan wait)
 88        {
 089            var current = _store.Current;
 090            if (current.CheckedAtUtc.HasValue && DateTime.UtcNow - current.CheckedAtUtc.Value < maxAge)
 091                return;
 92
 93            try
 94            {
 95                // CancellationToken.None: обрыв HTTP-запроса страницы не должен рвать общий запрос к Ozon.
 096                await _store.GetOrStartRefresh(() => RefreshAsync(CancellationToken.None)).WaitAsync(wait);
 097            }
 098            catch (TimeoutException)
 99            {
 100                // Не дождались — отдаём прежний снимок, свежий подхватит следующий опрос баннера.
 0101            }
 0102            catch (Exception ex)
 103            {
 0104                _logger.LogWarning(ex, "Не удалось обновить доступность пуш-адресов Ozon при загрузке страницы");
 0105            }
 0106        }
 107
 108        public async Task<string?> EnableAsync(int clientId, long urlId, CancellationToken ct = default)
 109        {
 0110            var client = (await _clients.GetOzonClients()).FirstOrDefault(c => c.Id == clientId && HasCredentials(c));
 0111            if (client == null)
 0112                return "Кабинет Ozon не найден или без ключей.";
 113
 114            try
 115            {
 0116                var body = new JObject { ["enabled"] = true, ["id"] = urlId }.ToString(Newtonsoft.Json.Formatting.None);
 0117                using var response = await PostAsync(client, EnableUrl, body, ct);
 0118                if (!response.IsSuccessStatusCode)
 119                {
 0120                    var text = await response.Content.ReadAsStringAsync(ct);
 0121                    _logger.LogWarning("Ozon notification/enable {Client}: HTTP {Status} {Body}", client.Name, (int)resp
 0122                    return $"Ozon ответил {(int)response.StatusCode}: {Truncate(text, 300)}";
 123                }
 0124            }
 0125            catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
 126            {
 0127                _logger.LogWarning(ex, "Ozon notification/enable {Client}: сбой запроса", client.Name);
 0128                return "Не удалось связаться с Ozon.";
 129            }
 130
 0131            await RefreshAsync(ct);
 0132            return null;
 0133        }
 134
 135        private async Task<OzonWebhookCabinetState> LoadCabinetAsync(OzonClient client, CancellationToken ct)
 136        {
 137            try
 138            {
 0139                var body = new JObject { ["limit"] = 100, ["offset"] = 0, ["sort_dir"] = "ASC" }.ToString(Newtonsoft.Jso
 0140                using var response = await SendWithRateLimitRetryAsync(
 0141                    () => PostAsync(client, ListUrl, body, ct), RateLimitRetryDelays, Task.Delay, ct);
 0142                var text = await response.Content.ReadAsStringAsync(ct);
 0143                if (!response.IsSuccessStatusCode)
 144                {
 0145                    _logger.LogWarning("Ozon notification/list {Client}: HTTP {Status} {Body}", client.Name, (int)respon
 0146                    return new OzonWebhookCabinetState
 0147                    {
 0148                        ClientId = client.Id,
 0149                        ClientName = client.Name,
 0150                        Error = $"Ozon ответил {(int)response.StatusCode}"
 0151                    };
 152                }
 153
 0154                return new OzonWebhookCabinetState
 0155                {
 0156                    ClientId = client.Id,
 0157                    ClientName = client.Name,
 0158                    Endpoints = ParseEndpoints(text, _monitoredHosts)
 0159                };
 160            }
 0161            catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or Newtonsoft.Json.JsonExcept
 162            {
 0163                _logger.LogWarning(ex, "Ozon notification/list {Client}: сбой запроса", client.Name);
 0164                return new OzonWebhookCabinetState { ClientId = client.Id, ClientName = client.Name, Error = "Не удалось
 165            }
 0166        }
 167
 168        /// <summary>Разбор ответа /v1/notification/list (вынесен для тестов).</summary>
 169        public static IReadOnlyList<OzonWebhookEndpointState> ParseEndpoints(string json, IReadOnlyCollection<string>? m
 170        {
 8171            var urls = JObject.Parse(json)["urls"] as JArray;
 8172            if (urls == null)
 1173                return Array.Empty<OzonWebhookEndpointState>();
 174
 7175            return urls.OfType<JObject>().Select(u =>
 7176            {
 16177                var raw = (string?)u["availability_status"];
 16178                return new OzonWebhookEndpointState
 16179                {
 16180                    Id = (long?)u["id"] ?? 0,
 16181                    Url = (string?)u["url"] ?? string.Empty,
 16182                    Monitored = IsMonitoredUrl((string?)u["url"], monitoredHosts),
 16183                    RawStatus = raw,
 16184                    Availability = MapStatus(raw),
 16185                    StatusDateUtc = (DateTime?)u["availability_status_date"],
 16186                    Enabled = (bool?)u["enable"] ?? true,
 16187                    DisableReason = (string?)u["disable_reason"],
 16188                    ReasonDetails = (string?)u["reason_details"],
 16189                    ProblematicType = (string?)u["problematic_type"],
 4190                    Types = (u["types"] as JArray)?.Select(t => (string?)t["type"]).Where(t => !string.IsNullOrEmpty(t))
 16191                            ?? new List<string>()
 16192                };
 7193            }).ToList();
 194        }
 195
 196        /// <summary>Адрес контролируем, если его хост в списке (null — контролируем всё).</summary>
 197        public static bool IsMonitoredUrl(string? url, IReadOnlyCollection<string>? monitoredHosts)
 198        {
 16199            if (monitoredHosts == null)
 7200                return true;
 9201            if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
 1202                return false;
 16203            return monitoredHosts.Any(h => string.Equals(h?.Trim(), uri.Host, StringComparison.OrdinalIgnoreCase));
 204        }
 205
 21206        public static OzonWebhookAvailability MapStatus(string? raw) => raw?.Trim().ToUpperInvariant() switch
 21207        {
 4208            "GREEN" => OzonWebhookAvailability.Available,
 1209            "YELLOW" => OzonWebhookAvailability.Unstable,
 10210            "RED" => OzonWebhookAvailability.Unavailable,
 6211            _ => OzonWebhookAvailability.Unknown
 21212        };
 213
 214        private async Task<HttpResponseMessage> PostAsync(OzonClient client, string url, string json, CancellationToken 
 215        {
 0216            var http = _httpClientFactory.CreateClient(HttpClientName);
 0217            http.Timeout = RequestTimeout;
 0218            using var request = new HttpRequestMessage(HttpMethod.Post, url)
 0219            {
 0220                Content = new StringContent(json, Encoding.UTF8, "application/json")
 0221            };
 0222            request.Headers.Add("Client-Id", client.DecryptClientId);
 0223            request.Headers.Add("Api-Key", client.DecryptApiKey);
 0224            return await http.SendAsync(request, ct);
 0225        }
 226
 227        /// <summary>
 228        /// Повторяет запрос при HTTP 429: Ozon ограничивает частоту обращений в секунду, а кабинеты опрашиваются подряд
 229        /// поэтому одиночный 429 — штатная ситуация, а не сбой. Возвращает последний ответ, если попытки исчерпаны.
 230        /// </summary>
 231        public static async Task<HttpResponseMessage> SendWithRateLimitRetryAsync(
 232            Func<Task<HttpResponseMessage>> send,
 233            IReadOnlyList<TimeSpan> delays,
 234            Func<TimeSpan, CancellationToken, Task> delay,
 235            CancellationToken ct)
 236        {
 8237            for (var attempt = 0; ; attempt++)
 238            {
 8239                var response = await send();
 8240                if ((int)response.StatusCode != 429 || attempt >= delays.Count)
 3241                    return response;
 242
 5243                response.Dispose();
 5244                await delay(delays[attempt], ct);
 245            }
 3246        }
 247
 248        private static bool HasCredentials(OzonClient c) =>
 0249            c.ClientType == ClientType.OZON && !string.IsNullOrEmpty(c.DecryptClientId) && !string.IsNullOrEmpty(c.Decry
 250
 0251        private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max];
 252    }
 253}