< Summary

Information
Class: Services.OzonWebhookServices.OzonWebhookService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/OzonWebhookServices/OzonWebhookService.cs
Line coverage
15%
Covered lines: 29
Uncovered lines: 159
Coverable lines: 188
Total lines: 368
Line coverage: 15.4%
Branch coverage
6%
Covered branches: 5
Total branches: 74
Branch coverage: 6.7%
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(...)100%11100%
ProcessAsync()62.5%24836.66%
PersistWebhookInBackgroundAsync()0%156120%
ProcessNotificationBackgroundAsync()0%7280%
HandlePing()100%210%
HandleGeneral()100%210%
EnrichRecord(...)0%506220%
IsTransientException(...)0%600240%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/OzonWebhookServices/OzonWebhookService.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Net;
 5using System.Net.Http;
 6using System.Net.Sockets;
 7using System.Threading;
 8using System.Threading.Tasks;
 9using Hangfire;
 10using Microsoft.Extensions.Logging;
 11using OzonDomains.Models;
 12using OzonDomains.Models.OzonWebhook;
 13using Services.DataServices;
 14using Services.HangfireService;
 15using Services.OzonWebhookServices.Handlers;
 16using Services.OzonWebhookServices.Models;
 17
 18namespace Services.OzonWebhookServices
 19{
 20    /// <summary>
 21    /// Обработка push-уведомлений Ozon.
 22    /// HTTP-путь только подтверждает приём (&lt; 5 с для Ozon); запись в БД и бизнес-логика — в Hangfire.
 23    /// </summary>
 24    public sealed class OzonWebhookService : IOzonWebhookService
 25    {
 26        /// <summary>
 27        /// Высокочастотные типы без бизнес-обработчика — не пишем в БД (иначе забивается пул MySQL).
 28        /// </summary>
 129        private static readonly HashSet<OzonMessageType> AckOnlyMessageTypes = new()
 130        {
 131            OzonMessageType.TYPE_STOCKS_CHANGED
 132        };
 33
 34        private readonly IOzonWebhookNotificationFactory _factory;
 35        private readonly OzonPushNotificationDataService _dataService;
 36        private readonly IEnumerable<IWebhookNotificationHandler> _handlers;
 37        private readonly ILogger<OzonWebhookService> _logger;
 38
 439        public OzonWebhookService(
 440            IOzonWebhookNotificationFactory factory,
 441            OzonPushNotificationDataService dataService,
 442            IEnumerable<IWebhookNotificationHandler> handlers,
 443            ILogger<OzonWebhookService> logger)
 44        {
 445            _factory = factory;
 446            _dataService = dataService;
 447            _handlers = handlers;
 448            _logger = logger;
 449        }
 50
 51        /// <summary>
 52        /// Максимальное время на постановку задачи в очередь Hangfire в HTTP-пути.
 53        /// Ozon мониторит время ответа (норма до 1,5 с; свыше 2,5 с адрес «Недоступен» и отключается); не блокируем пот
 54        /// </summary>
 155        private static readonly TimeSpan EnqueueTimeout = TimeSpan.FromSeconds(1);
 56
 57        /// <inheritdoc/>
 58        public async Task<(object Response, int StatusCode)> ProcessAsync(
 59            string rawJson,
 60            string? sourceIp,
 61            CancellationToken ct = default)
 62        {
 463            if (string.IsNullOrWhiteSpace(rawJson))
 64            {
 365                _logger.LogInformation("Получен пустой payload от {Ip}", sourceIp);
 366                return (OzonErrorResponseFactory.ParameterMissed("Тело запроса пустое или отсутствует"), 400);
 67            }
 68
 169            var messageType = _factory.GetMessageType(rawJson);
 70
 171            if (messageType == OzonMessageType.TYPE_PING)
 072                return HandlePing();
 73
 74            // Без бизнес-обработчика запись в БД и постановка в очередь Hangfire бессмысленны
 75            // (запись лишь помечалась бы Skipped) и приводят к исчерпанию пула соединений
 76            // на высокочастотных типах (например TYPE_DELIVERY_DATE_CHANGED). Только ack.
 277            var hasHandler = _handlers.Any(h => h.CanHandle(messageType));
 78
 179            if (AckOnlyMessageTypes.Contains(messageType) || !hasHandler)
 80            {
 081                _logger.LogDebug(
 082                    "Ozon webhook {MessageType} от {Ip}: подтверждение без записи в БД (обработчик не требуется)",
 083                    messageType, sourceIp);
 084                return HandleGeneral();
 85            }
 86
 87            try
 88            {
 89                // Enqueue синхронно открывает соединение к БД Hangfire и при перегрузке пула
 90                // может висеть до ConnectionTimeout. Ограничиваем ожидание, чтобы не держать
 91                // HTTP-поток (иначе Ozon уходит в ретраи и нагрузка лавинообразно растёт).
 192                var enqueueTask = Task.Run(() =>
 193                    HangfireJobClientExtensions.EnqueueOzonWebhook<IOzonWebhookService>(
 194                        s => s.PersistWebhookInBackgroundAsync(rawJson, sourceIp)), ct);
 95
 196                await enqueueTask.WaitAsync(EnqueueTimeout, ct);
 97
 098                _logger.LogDebug(
 099                    "Ozon webhook {MessageType} от {Ip}: принят, сохранение в очереди Hangfire",
 0100                    messageType, sourceIp);
 101
 0102                return HandleGeneral();
 103            }
 0104            catch (TimeoutException)
 105            {
 0106                _logger.LogWarning(
 0107                    "Ozon webhook {MessageType} от {Ip}: постановка в очередь не завершилась за {Timeout}с (перегрузка х
 0108                    messageType, sourceIp, EnqueueTimeout.TotalSeconds);
 0109                return (OzonErrorResponseFactory.Unknown("Сервис временно перегружен, повторите позже"), 503);
 110            }
 1111            catch (OperationCanceledException) when (ct.IsCancellationRequested)
 112            {
 113                // Отмену HTTP-запроса должен обработать контроллер и вернуть 499, а не 500.
 1114                throw;
 115            }
 0116            catch (Exception ex)
 117            {
 0118                _logger.LogError(ex,
 0119                    "Не удалось поставить в очередь уведомление {MessageType} от {Ip}",
 0120                    messageType, sourceIp);
 0121                return (OzonErrorResponseFactory.Unknown("Не удалось принять уведомление"), 500);
 122            }
 3123        }
 124
 125        /// <summary>
 126        /// Сохраняет уведомление в БД и при необходимости выполняет обработчик (вне HTTP-запроса).
 127        /// </summary>
 128        [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 15, 60, 120 })]
 129        [Queue("ozon-webhook")]
 130        public async Task PersistWebhookInBackgroundAsync(string rawJson, string? sourceIp)
 131        {
 0132            var messageType = _factory.GetMessageType(rawJson);
 0133            var record = new OzonPushNotification
 0134            {
 0135                MessageType = messageType.ToString(),
 0136                RawPayload = rawJson,
 0137                SourceIpAddress = sourceIp,
 0138                ReceivedAt = DateTime.UtcNow,
 0139                ProcessingStatus = nameof(OzonNotificationStatus.Received)
 0140            };
 141
 142            try
 143            {
 0144                var notification = _factory.Parse(rawJson);
 0145                EnrichRecord(record, notification);
 146
 0147                var hasHandler = _handlers.Any(h => h.CanHandle(messageType));
 148
 0149                if (messageType == OzonMessageType.UNKNOWN || !hasHandler)
 150                {
 0151                    record.ProcessingStatus = nameof(OzonNotificationStatus.Skipped);
 0152                    record.ProcessedAt = DateTime.UtcNow;
 0153                    if (!hasHandler && messageType != OzonMessageType.UNKNOWN)
 0154                        record.ActionDetails = $"Обработчик для типа '{messageType}' не зарегистрирован.";
 155
 0156                    await _dataService.AddAsync(record, CancellationToken.None);
 0157                    return;
 158                }
 159
 0160                await _dataService.AddAsync(record, CancellationToken.None);
 0161                await ProcessNotificationBackgroundAsync(record.Id);
 0162            }
 0163            catch (Exception ex)
 164            {
 0165                if (IsTransientException(ex))
 166                {
 0167                    _logger.LogInformation(
 0168                        "PersistWebhookInBackgroundAsync: временная ошибка {MessageType} от {Ip}, повтор через Hangfire:
 0169                        messageType, sourceIp, ex.Message);
 170                }
 171                else
 172                {
 0173                    _logger.LogError(ex,
 0174                        "PersistWebhookInBackgroundAsync: ошибка {MessageType} от {Ip}",
 0175                        messageType, sourceIp);
 176                }
 177
 0178                record.ProcessingStatus = nameof(OzonNotificationStatus.Error);
 0179                record.ErrorMessage = ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message;
 0180                record.ProcessedAt = DateTime.UtcNow;
 181
 182                try
 183                {
 0184                    await _dataService.AddAsync(record, CancellationToken.None);
 0185                }
 0186                catch (Exception saveEx)
 187                {
 0188                    _logger.LogError(saveEx,
 0189                        "PersistWebhookInBackgroundAsync: не удалось сохранить запись об ошибке");
 0190                }
 191
 0192                throw;
 193            }
 0194        }
 195
 196        /// <inheritdoc/>
 197        [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
 198        [Queue("ozon-webhook")]
 199        public async Task ProcessNotificationBackgroundAsync(long notificationId)
 200        {
 0201            var record = await _dataService.GetByIdAsync(notificationId);
 0202            if (record == null)
 203            {
 0204                _logger.LogWarning(
 0205                    "ProcessNotificationBackgroundAsync: запись Id={Id} не найдена в БД", notificationId);
 0206                return;
 207            }
 208
 209            try
 210            {
 0211                if (!Enum.TryParse<OzonMessageType>(record.MessageType, out var messageType))
 0212                    messageType = OzonMessageType.UNKNOWN;
 213
 0214                var handler = _handlers.FirstOrDefault(h => h.CanHandle(messageType));
 0215                if (handler == null)
 216                {
 0217                    _logger.LogWarning(
 0218                        "ProcessNotificationBackgroundAsync: обработчик для {MessageType} не найден (Id={Id})",
 0219                        messageType, notificationId);
 0220                    record.ProcessingStatus = nameof(OzonNotificationStatus.Skipped);
 0221                    record.ActionDetails = $"Обработчик для типа '{messageType}' не зарегистрирован.";
 0222                    record.ProcessedAt = DateTime.UtcNow;
 0223                    await _dataService.UpdateAsync(record, CancellationToken.None);
 0224                    return;
 225                }
 226
 0227                var notification = _factory.Parse(record.RawPayload);
 0228                var handlerResult = await handler.HandleAsync(notification, CancellationToken.None);
 0229                var actionDetails = handlerResult.ActionDetails;
 230
 0231                record.ProcessingStatus = nameof(OzonNotificationStatus.Processed);
 0232                record.ActionDetails = actionDetails;
 0233                record.ProcessedAt = DateTime.UtcNow;
 0234                await _dataService.UpdateAsync(record, CancellationToken.None);
 235
 236                try
 237                {
 0238                    await Services.SignalRServices.NotificationService.NotifyAsync(
 0239                        "Ozon", "#D97706", actionDetails, "fa-bell");
 0240                }
 0241                catch (Exception notifyEx)
 242                {
 0243                    _logger.LogWarning(notifyEx,
 0244                        "ProcessNotificationBackgroundAsync: не удалось отправить уведомление в чат для Id={Id}",
 0245                        notificationId);
 0246                }
 247
 0248                _logger.LogInformation(
 0249                    "ProcessNotificationBackgroundAsync: Id={Id} обработан. {ActionDetails}",
 0250                    notificationId, actionDetails);
 0251            }
 0252            catch (Exception ex)
 253            {
 0254                if (IsTransientException(ex))
 255                {
 0256                    _logger.LogInformation(
 0257                        "ProcessNotificationBackgroundAsync: временная ошибка при обработке Id={Id}, повтор через Hangfi
 0258                        notificationId, ex.Message);
 259                }
 260                else
 261                {
 0262                    _logger.LogError(ex,
 0263                        "ProcessNotificationBackgroundAsync: ошибка обработки Id={Id}", notificationId);
 264                }
 265
 0266                record.ProcessingStatus = nameof(OzonNotificationStatus.Error);
 0267                record.ErrorMessage = ex.Message;
 0268                record.ProcessedAt = DateTime.UtcNow;
 269
 0270                try { await _dataService.UpdateAsync(record, CancellationToken.None); }
 0271                catch (Exception saveEx)
 272                {
 0273                    _logger.LogError(saveEx,
 0274                        "ProcessNotificationBackgroundAsync: не удалось сохранить статус ошибки для Id={Id}",
 0275                        notificationId);
 0276                }
 277
 0278                throw;
 279            }
 0280        }
 281
 282        private static (object Response, int StatusCode) HandlePing() =>
 0283            (new OzonPingSuccessResponse { Time = DateTime.UtcNow }, 200);
 284
 285        private static (object Response, int StatusCode) HandleGeneral() =>
 0286            (new OzonGeneralSuccessResponse(), 200);
 287
 288        private static void EnrichRecord(OzonPushNotification record, IOzonWebhookMessage notification)
 289        {
 290            switch (notification)
 291            {
 292                case OzonNewPostingNotification n:
 0293                    record.PostingNumber = n.PostingNumber;
 0294                    record.SellerId = n.SellerId;
 0295                    record.WarehouseId = n.WarehouseId;
 0296                    break;
 297
 298                case OzonPostingCancelledNotification n:
 0299                    record.PostingNumber = n.PostingNumber;
 0300                    record.SellerId = n.SellerId;
 0301                    record.WarehouseId = n.WarehouseId;
 0302                    break;
 303
 304                case OzonStateChangedNotification n:
 0305                    record.PostingNumber = n.PostingNumber;
 0306                    record.SellerId = n.SellerId;
 0307                    record.WarehouseId = n.WarehouseId;
 0308                    break;
 309
 310                case OzonCutoffDateChangedNotification n:
 0311                    record.PostingNumber = n.PostingNumber;
 0312                    record.SellerId = n.SellerId;
 0313                    record.WarehouseId = n.WarehouseId;
 0314                    break;
 315
 316                case OzonDeliveryDateChangedNotification n:
 0317                    record.PostingNumber = n.PostingNumber;
 0318                    record.SellerId = n.SellerId;
 0319                    record.WarehouseId = n.WarehouseId;
 0320                    break;
 321
 322                case OzonItemNotification n:
 0323                    record.SellerId = n.SellerId;
 0324                    break;
 325
 326                case OzonStocksChangedNotification n:
 0327                    record.SellerId = n.SellerId;
 0328                    break;
 329
 330                case OzonNewMessageNotification n:
 0331                    record.SellerId = n.SellerId;
 0332                    break;
 333
 334                case OzonUpdateMessageNotification n:
 0335                    record.SellerId = n.SellerId;
 0336                    break;
 337
 338                case OzonMessageReadNotification n:
 0339                    record.SellerId = n.SellerId;
 0340                    break;
 341
 342                case OzonChatClosedNotification n:
 0343                    record.SellerId = n.SellerId;
 344                    break;
 345            }
 0346        }
 347
 348        private static bool IsTransientException(Exception ex)
 349        {
 0350            if (ex is OperationCanceledException or TimeoutException or HttpRequestException or SocketException)
 0351                return true;
 352
 0353            if (ex is InvalidOperationException && ex.Message.Contains("Временная ошибка", StringComparison.OrdinalIgnor
 0354                return true;
 355
 0356            if (ex is InvalidOperationException && ex.Message.Contains("повтор", StringComparison.OrdinalIgnoreCase))
 0357                return true;
 358
 0359            if (ex.InnerException != null && IsTransientException(ex.InnerException))
 0360                return true;
 361
 0362            if (ex is AggregateException agg)
 0363                return agg.InnerExceptions.Any(IsTransientException);
 364
 0365            return false;
 366        }
 367    }
 368}