| | | 1 | | using System.IO; |
| | | 2 | | using System.Text; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using System.Text.Json.Serialization; |
| | | 5 | | using System.Threading; |
| | | 6 | | using System.Threading.Tasks; |
| | | 7 | | using Microsoft.AspNetCore.Http; |
| | | 8 | | using Microsoft.AspNetCore.Mvc; |
| | | 9 | | using Microsoft.Extensions.Logging; |
| | | 10 | | using OzonOrdersWeb.Middleware; |
| | | 11 | | using Services.OzonWebhookServices; |
| | | 12 | | |
| | | 13 | | namespace OzonOrdersWeb.Areas.Studio2.Controllers |
| | | 14 | | { |
| | | 15 | | /// <summary> |
| | | 16 | | /// Контроллер для приёма push-уведомлений от Ozon Seller API. |
| | | 17 | | /// |
| | | 18 | | /// Endpoint: POST /Studio2/OzonWebhook/Receive |
| | | 19 | | /// |
| | | 20 | | /// Особенности: |
| | | 21 | | /// — НЕТ атрибута [Authorize]: endpoint публичный, авторизация через IP-фильтр. |
| | | 22 | | /// — [ServiceFilter(typeof(OzonIpWhitelistFilter))]: разрешает запросы только |
| | | 23 | | /// с IP-диапазонов Ozon (195.34.21.0/24, 185.73.192.0/22, 91.223.93.0/24) |
| | | 24 | | /// и localhost (для локальной разработки). |
| | | 25 | | /// — EndpointAccessMiddleware пропускает этот путь (добавлен в ignorePatterns). |
| | | 26 | | /// — Content-Type ответа: application/json. |
| | | 27 | | /// |
| | | 28 | | /// Документация Ozon: https://docs.ozon.ru/api/seller/#tag/push-notifications |
| | | 29 | | /// </summary> |
| | | 30 | | [Area("Studio2")] |
| | | 31 | | [Route("[area]/[controller]/[action]")] |
| | | 32 | | [ServiceFilter(typeof(OzonIpWhitelistFilter))] |
| | | 33 | | public class OzonWebhookController : Controller |
| | | 34 | | { |
| | 1 | 35 | | private static readonly JsonSerializerOptions ResponseSerializerOptions = new() |
| | 1 | 36 | | { |
| | 1 | 37 | | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, |
| | 1 | 38 | | PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, |
| | 1 | 39 | | WriteIndented = false |
| | 1 | 40 | | }; |
| | | 41 | | |
| | | 42 | | private readonly IOzonWebhookService _webhookService; |
| | | 43 | | private readonly ILogger<OzonWebhookController> _logger; |
| | | 44 | | |
| | 5 | 45 | | public OzonWebhookController( |
| | 5 | 46 | | IOzonWebhookService webhookService, |
| | 5 | 47 | | ILogger<OzonWebhookController> logger) |
| | | 48 | | { |
| | 5 | 49 | | _webhookService = webhookService; |
| | 5 | 50 | | _logger = logger; |
| | 5 | 51 | | } |
| | | 52 | | |
| | | 53 | | /// <summary> |
| | | 54 | | /// Принимает push-уведомление от Ozon. |
| | | 55 | | /// |
| | | 56 | | /// POST /Studio2/OzonWebhook/Receive |
| | | 57 | | /// |
| | | 58 | | /// Ozon ожидает: |
| | | 59 | | /// — HTTP 200 + JSON-тело при успешной обработке. |
| | | 60 | | /// — HTTP 4xx или 5xx + JSON-тело с полем "error" при ошибке. |
| | | 61 | | /// |
| | | 62 | | /// Если ответ не придёт за 5 секунд или придёт несоответствующий формат, |
| | | 63 | | /// Ozon может приостановить доставку уведомлений. |
| | | 64 | | /// </summary> |
| | | 65 | | [HttpPost] |
| | | 66 | | public async Task<IActionResult> Receive(CancellationToken ct) |
| | | 67 | | { |
| | | 68 | | try |
| | | 69 | | { |
| | | 70 | | // Читаем тело запроса как строку (Ozon отправляет application/json) |
| | | 71 | | string rawJson; |
| | 5 | 72 | | using (var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: false)) |
| | | 73 | | { |
| | 5 | 74 | | rawJson = await reader.ReadToEndAsync(ct); |
| | 1 | 75 | | } |
| | | 76 | | |
| | 1 | 77 | | var sourceIp = HttpContext.Connection.RemoteIpAddress?.ToString(); |
| | | 78 | | |
| | 1 | 79 | | _logger.LogDebug( |
| | 1 | 80 | | "Ozon webhook: получен запрос от {Ip}, длина payload: {Length} байт", |
| | 1 | 81 | | sourceIp, rawJson.Length); |
| | | 82 | | |
| | 1 | 83 | | var (response, statusCode) = await _webhookService.ProcessAsync(rawJson, sourceIp, ct); |
| | | 84 | | |
| | | 85 | | // Сериализуем с явным snake_case и устанавливаем Content-Type |
| | 1 | 86 | | var jsonString = JsonSerializer.Serialize(response, response.GetType(), ResponseSerializerOptions); |
| | 1 | 87 | | Response.StatusCode = statusCode; |
| | 1 | 88 | | return Content(jsonString, "application/json"); |
| | | 89 | | } |
| | 1 | 90 | | catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested || ct.IsCancella |
| | | 91 | | { |
| | 1 | 92 | | return ClientAborted("запрос был отменен клиентом/прокси"); |
| | | 93 | | } |
| | | 94 | | // Ozon ждёт ответ 5 секунд и при таймауте рвёт соединение, не дослав тело. |
| | | 95 | | // Kestrel сигналит об этом BadHttpRequestException из ReadToEndAsync — это дефект |
| | | 96 | | // запроса, а не сбой приложения, поэтому не даём ему всплыть в 500 с полной |
| | | 97 | | // трассировкой в лог. |
| | 3 | 98 | | catch (BadHttpRequestException ex) |
| | | 99 | | { |
| | 3 | 100 | | return BadRequestFromClient(ex); |
| | | 101 | | } |
| | 5 | 102 | | } |
| | | 103 | | |
| | | 104 | | /// <summary> |
| | | 105 | | /// Ответ на отменённый клиентом запрос: информационное сообщение в лог вместо ошибки/предупреждения и |
| | | 106 | | /// 499 (Client Closed Request, соглашение nginx), чтобы не шуметь как 500. |
| | | 107 | | /// Ozon этот ответ уже не прочитает — соединения нет. |
| | | 108 | | /// </summary> |
| | | 109 | | private IActionResult ClientAborted(string reason) |
| | | 110 | | { |
| | 3 | 111 | | _logger.LogInformation( |
| | 3 | 112 | | "Ozon webhook: {Reason} (IP: {Ip}, path: {Path})", |
| | 3 | 113 | | reason, |
| | 3 | 114 | | HttpContext.Connection.RemoteIpAddress?.ToString(), |
| | 3 | 115 | | HttpContext.Request.Path); |
| | 3 | 116 | | return StatusCode(499, new { error = "request_canceled" }); |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | /// <summary> |
| | | 120 | | /// Ответ на некорректный запрос. Оборванное соединение (Ozon не дослал тело) отдаём |
| | | 121 | | /// как 499, остальные случаи — со статусом, который предложил Kestrel (400 на битый |
| | | 122 | | /// запрос, 413 на превышение лимита тела). |
| | | 123 | | /// </summary> |
| | | 124 | | private IActionResult BadRequestFromClient(BadHttpRequestException ex) |
| | | 125 | | { |
| | 3 | 126 | | if (HttpContext.RequestAborted.IsCancellationRequested) |
| | 2 | 127 | | return ClientAborted($"тело запроса не дочитано ({ex.Message.TrimEnd('.')})"); |
| | | 128 | | |
| | 1 | 129 | | LogClientProblem($"некорректный запрос ({ex.Message.TrimEnd('.')})"); |
| | 1 | 130 | | return StatusCode(ex.StatusCode, new { error = "bad_request" }); |
| | | 131 | | } |
| | | 132 | | |
| | | 133 | | private void LogClientProblem(string reason) => |
| | 1 | 134 | | _logger.LogWarning( |
| | 1 | 135 | | "Ozon webhook: {Reason} (IP: {Ip}, path: {Path})", |
| | 1 | 136 | | reason, |
| | 1 | 137 | | HttpContext.Connection.RemoteIpAddress?.ToString(), |
| | 1 | 138 | | HttpContext.Request.Path); |
| | | 139 | | } |
| | | 140 | | } |