| | | 1 | | using System; |
| | | 2 | | using System.Collections.Generic; |
| | | 3 | | using System.Linq; |
| | | 4 | | using System.Net.Http; |
| | | 5 | | using System.Text; |
| | | 6 | | using System.Threading; |
| | | 7 | | using System.Threading.Tasks; |
| | | 8 | | using Microsoft.Extensions.Configuration; |
| | | 9 | | using Microsoft.Extensions.Logging; |
| | | 10 | | using Newtonsoft.Json.Linq; |
| | | 11 | | using OzonDomains; |
| | | 12 | | using OzonDomains.Models; |
| | | 13 | | using Services.DataServices; |
| | | 14 | | |
| | | 15 | | namespace 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"; |
| | 1 | 42 | | private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(20); |
| | | 43 | | |
| | | 44 | | /// <summary>Паузы перед повтором при 429 (лимит Ozon «request rate limit per second»).</summary> |
| | 1 | 45 | | 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>Хосты по умолчанию — только адреса этого приложения; остальное в кабинете принадлежит другим сервис |
| | 1 | 54 | | public static readonly string[] DefaultMonitoredHosts = ["studio2.mpsuper.ru"]; |
| | | 55 | | |
| | 0 | 56 | | public OzonWebhookAvailabilityService( |
| | 0 | 57 | | OzonClientServices clients, |
| | 0 | 58 | | IHttpClientFactory httpClientFactory, |
| | 0 | 59 | | OzonWebhookAvailabilityStore store, |
| | 0 | 60 | | ILogger<OzonWebhookAvailabilityService> logger, |
| | 0 | 61 | | IConfiguration configuration) |
| | | 62 | | { |
| | 0 | 63 | | var configured = configuration.GetSection("OzonWebhook:MonitoredHosts").Get<string[]>(); |
| | 0 | 64 | | _monitoredHosts = configured is { Length: > 0 } ? configured : DefaultMonitoredHosts; |
| | 0 | 65 | | _clients = clients; |
| | 0 | 66 | | _httpClientFactory = httpClientFactory; |
| | 0 | 67 | | _store = store; |
| | 0 | 68 | | _logger = logger; |
| | 0 | 69 | | } |
| | | 70 | | |
| | | 71 | | public async Task<OzonWebhookAvailabilitySnapshot> RefreshAsync(CancellationToken ct = default) |
| | | 72 | | { |
| | 0 | 73 | | var clients = (await _clients.GetOzonClients()) |
| | 0 | 74 | | .Where(HasCredentials) |
| | 0 | 75 | | .OrderBy(c => c.Name) |
| | 0 | 76 | | .ToList(); |
| | | 77 | | |
| | 0 | 78 | | var cabinets = new List<OzonWebhookCabinetState>(clients.Count); |
| | 0 | 79 | | foreach (var client in clients) |
| | 0 | 80 | | cabinets.Add(await LoadCabinetAsync(client, ct)); |
| | | 81 | | |
| | 0 | 82 | | var snapshot = new OzonWebhookAvailabilitySnapshot { CheckedAtUtc = DateTime.UtcNow, Cabinets = cabinets }; |
| | 0 | 83 | | _store.Set(snapshot); |
| | 0 | 84 | | return snapshot; |
| | 0 | 85 | | } |
| | | 86 | | |
| | | 87 | | public async Task RefreshIfStaleAsync(TimeSpan maxAge, TimeSpan wait) |
| | | 88 | | { |
| | 0 | 89 | | var current = _store.Current; |
| | 0 | 90 | | if (current.CheckedAtUtc.HasValue && DateTime.UtcNow - current.CheckedAtUtc.Value < maxAge) |
| | 0 | 91 | | return; |
| | | 92 | | |
| | | 93 | | try |
| | | 94 | | { |
| | | 95 | | // CancellationToken.None: обрыв HTTP-запроса страницы не должен рвать общий запрос к Ozon. |
| | 0 | 96 | | await _store.GetOrStartRefresh(() => RefreshAsync(CancellationToken.None)).WaitAsync(wait); |
| | 0 | 97 | | } |
| | 0 | 98 | | catch (TimeoutException) |
| | | 99 | | { |
| | | 100 | | // Не дождались — отдаём прежний снимок, свежий подхватит следующий опрос баннера. |
| | 0 | 101 | | } |
| | 0 | 102 | | catch (Exception ex) |
| | | 103 | | { |
| | 0 | 104 | | _logger.LogWarning(ex, "Не удалось обновить доступность пуш-адресов Ozon при загрузке страницы"); |
| | 0 | 105 | | } |
| | 0 | 106 | | } |
| | | 107 | | |
| | | 108 | | public async Task<string?> EnableAsync(int clientId, long urlId, CancellationToken ct = default) |
| | | 109 | | { |
| | 0 | 110 | | var client = (await _clients.GetOzonClients()).FirstOrDefault(c => c.Id == clientId && HasCredentials(c)); |
| | 0 | 111 | | if (client == null) |
| | 0 | 112 | | return "Кабинет Ozon не найден или без ключей."; |
| | | 113 | | |
| | | 114 | | try |
| | | 115 | | { |
| | 0 | 116 | | var body = new JObject { ["enabled"] = true, ["id"] = urlId }.ToString(Newtonsoft.Json.Formatting.None); |
| | 0 | 117 | | using var response = await PostAsync(client, EnableUrl, body, ct); |
| | 0 | 118 | | if (!response.IsSuccessStatusCode) |
| | | 119 | | { |
| | 0 | 120 | | var text = await response.Content.ReadAsStringAsync(ct); |
| | 0 | 121 | | _logger.LogWarning("Ozon notification/enable {Client}: HTTP {Status} {Body}", client.Name, (int)resp |
| | 0 | 122 | | return $"Ozon ответил {(int)response.StatusCode}: {Truncate(text, 300)}"; |
| | | 123 | | } |
| | 0 | 124 | | } |
| | 0 | 125 | | catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) |
| | | 126 | | { |
| | 0 | 127 | | _logger.LogWarning(ex, "Ozon notification/enable {Client}: сбой запроса", client.Name); |
| | 0 | 128 | | return "Не удалось связаться с Ozon."; |
| | | 129 | | } |
| | | 130 | | |
| | 0 | 131 | | await RefreshAsync(ct); |
| | 0 | 132 | | return null; |
| | 0 | 133 | | } |
| | | 134 | | |
| | | 135 | | private async Task<OzonWebhookCabinetState> LoadCabinetAsync(OzonClient client, CancellationToken ct) |
| | | 136 | | { |
| | | 137 | | try |
| | | 138 | | { |
| | 0 | 139 | | var body = new JObject { ["limit"] = 100, ["offset"] = 0, ["sort_dir"] = "ASC" }.ToString(Newtonsoft.Jso |
| | 0 | 140 | | using var response = await SendWithRateLimitRetryAsync( |
| | 0 | 141 | | () => PostAsync(client, ListUrl, body, ct), RateLimitRetryDelays, Task.Delay, ct); |
| | 0 | 142 | | var text = await response.Content.ReadAsStringAsync(ct); |
| | 0 | 143 | | if (!response.IsSuccessStatusCode) |
| | | 144 | | { |
| | 0 | 145 | | _logger.LogWarning("Ozon notification/list {Client}: HTTP {Status} {Body}", client.Name, (int)respon |
| | 0 | 146 | | return new OzonWebhookCabinetState |
| | 0 | 147 | | { |
| | 0 | 148 | | ClientId = client.Id, |
| | 0 | 149 | | ClientName = client.Name, |
| | 0 | 150 | | Error = $"Ozon ответил {(int)response.StatusCode}" |
| | 0 | 151 | | }; |
| | | 152 | | } |
| | | 153 | | |
| | 0 | 154 | | return new OzonWebhookCabinetState |
| | 0 | 155 | | { |
| | 0 | 156 | | ClientId = client.Id, |
| | 0 | 157 | | ClientName = client.Name, |
| | 0 | 158 | | Endpoints = ParseEndpoints(text, _monitoredHosts) |
| | 0 | 159 | | }; |
| | | 160 | | } |
| | 0 | 161 | | catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or Newtonsoft.Json.JsonExcept |
| | | 162 | | { |
| | 0 | 163 | | _logger.LogWarning(ex, "Ozon notification/list {Client}: сбой запроса", client.Name); |
| | 0 | 164 | | return new OzonWebhookCabinetState { ClientId = client.Id, ClientName = client.Name, Error = "Не удалось |
| | | 165 | | } |
| | 0 | 166 | | } |
| | | 167 | | |
| | | 168 | | /// <summary>Разбор ответа /v1/notification/list (вынесен для тестов).</summary> |
| | | 169 | | public static IReadOnlyList<OzonWebhookEndpointState> ParseEndpoints(string json, IReadOnlyCollection<string>? m |
| | | 170 | | { |
| | 8 | 171 | | var urls = JObject.Parse(json)["urls"] as JArray; |
| | 8 | 172 | | if (urls == null) |
| | 1 | 173 | | return Array.Empty<OzonWebhookEndpointState>(); |
| | | 174 | | |
| | 7 | 175 | | return urls.OfType<JObject>().Select(u => |
| | 7 | 176 | | { |
| | 16 | 177 | | var raw = (string?)u["availability_status"]; |
| | 16 | 178 | | return new OzonWebhookEndpointState |
| | 16 | 179 | | { |
| | 16 | 180 | | Id = (long?)u["id"] ?? 0, |
| | 16 | 181 | | Url = (string?)u["url"] ?? string.Empty, |
| | 16 | 182 | | Monitored = IsMonitoredUrl((string?)u["url"], monitoredHosts), |
| | 16 | 183 | | RawStatus = raw, |
| | 16 | 184 | | Availability = MapStatus(raw), |
| | 16 | 185 | | StatusDateUtc = (DateTime?)u["availability_status_date"], |
| | 16 | 186 | | Enabled = (bool?)u["enable"] ?? true, |
| | 16 | 187 | | DisableReason = (string?)u["disable_reason"], |
| | 16 | 188 | | ReasonDetails = (string?)u["reason_details"], |
| | 16 | 189 | | ProblematicType = (string?)u["problematic_type"], |
| | 4 | 190 | | Types = (u["types"] as JArray)?.Select(t => (string?)t["type"]).Where(t => !string.IsNullOrEmpty(t)) |
| | 16 | 191 | | ?? new List<string>() |
| | 16 | 192 | | }; |
| | 7 | 193 | | }).ToList(); |
| | | 194 | | } |
| | | 195 | | |
| | | 196 | | /// <summary>Адрес контролируем, если его хост в списке (null — контролируем всё).</summary> |
| | | 197 | | public static bool IsMonitoredUrl(string? url, IReadOnlyCollection<string>? monitoredHosts) |
| | | 198 | | { |
| | 16 | 199 | | if (monitoredHosts == null) |
| | 7 | 200 | | return true; |
| | 9 | 201 | | if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) |
| | 1 | 202 | | return false; |
| | 16 | 203 | | return monitoredHosts.Any(h => string.Equals(h?.Trim(), uri.Host, StringComparison.OrdinalIgnoreCase)); |
| | | 204 | | } |
| | | 205 | | |
| | 21 | 206 | | public static OzonWebhookAvailability MapStatus(string? raw) => raw?.Trim().ToUpperInvariant() switch |
| | 21 | 207 | | { |
| | 4 | 208 | | "GREEN" => OzonWebhookAvailability.Available, |
| | 1 | 209 | | "YELLOW" => OzonWebhookAvailability.Unstable, |
| | 10 | 210 | | "RED" => OzonWebhookAvailability.Unavailable, |
| | 6 | 211 | | _ => OzonWebhookAvailability.Unknown |
| | 21 | 212 | | }; |
| | | 213 | | |
| | | 214 | | private async Task<HttpResponseMessage> PostAsync(OzonClient client, string url, string json, CancellationToken |
| | | 215 | | { |
| | 0 | 216 | | var http = _httpClientFactory.CreateClient(HttpClientName); |
| | 0 | 217 | | http.Timeout = RequestTimeout; |
| | 0 | 218 | | using var request = new HttpRequestMessage(HttpMethod.Post, url) |
| | 0 | 219 | | { |
| | 0 | 220 | | Content = new StringContent(json, Encoding.UTF8, "application/json") |
| | 0 | 221 | | }; |
| | 0 | 222 | | request.Headers.Add("Client-Id", client.DecryptClientId); |
| | 0 | 223 | | request.Headers.Add("Api-Key", client.DecryptApiKey); |
| | 0 | 224 | | return await http.SendAsync(request, ct); |
| | 0 | 225 | | } |
| | | 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 | | { |
| | 8 | 237 | | for (var attempt = 0; ; attempt++) |
| | | 238 | | { |
| | 8 | 239 | | var response = await send(); |
| | 8 | 240 | | if ((int)response.StatusCode != 429 || attempt >= delays.Count) |
| | 3 | 241 | | return response; |
| | | 242 | | |
| | 5 | 243 | | response.Dispose(); |
| | 5 | 244 | | await delay(delays[attempt], ct); |
| | | 245 | | } |
| | 3 | 246 | | } |
| | | 247 | | |
| | | 248 | | private static bool HasCredentials(OzonClient c) => |
| | 0 | 249 | | c.ClientType == ClientType.OZON && !string.IsNullOrEmpty(c.DecryptClientId) && !string.IsNullOrEmpty(c.Decry |
| | | 250 | | |
| | 0 | 251 | | private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max]; |
| | | 252 | | } |
| | | 253 | | } |