From d717603365eb23dd18582368b485c4471ff3eaff Mon Sep 17 00:00:00 2001 From: SEAN Date: Wed, 11 Feb 2026 10:10:11 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9B=B9=ED=9B=85=20=EB=B0=9C=EC=86=A1?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #146 --- .../DTOs/Webhook/WebhookPayloadDto.cs | 18 +++ .../Interfaces/IWebhookService.cs | 8 + .../Interfaces/IWebhookLogRepository.cs | 7 + SPMS.Infrastructure/DependencyInjection.cs | 6 + .../Repositories/WebhookLogRepository.cs | 9 ++ SPMS.Infrastructure/Webhook/WebhookService.cs | 141 ++++++++++++++++++ TASKS.md | 2 +- 7 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 SPMS.Application/DTOs/Webhook/WebhookPayloadDto.cs create mode 100644 SPMS.Application/Interfaces/IWebhookService.cs create mode 100644 SPMS.Domain/Interfaces/IWebhookLogRepository.cs create mode 100644 SPMS.Infrastructure/Persistence/Repositories/WebhookLogRepository.cs create mode 100644 SPMS.Infrastructure/Webhook/WebhookService.cs diff --git a/SPMS.Application/DTOs/Webhook/WebhookPayloadDto.cs b/SPMS.Application/DTOs/Webhook/WebhookPayloadDto.cs new file mode 100644 index 0000000..e10926a --- /dev/null +++ b/SPMS.Application/DTOs/Webhook/WebhookPayloadDto.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Webhook; + +public class WebhookPayloadDto +{ + [JsonPropertyName("event_type")] + public string EventType { get; set; } = string.Empty; + + [JsonPropertyName("timestamp")] + public DateTime Timestamp { get; set; } + + [JsonPropertyName("service_code")] + public string ServiceCode { get; set; } = string.Empty; + + [JsonPropertyName("data")] + public object Data { get; set; } = new(); +} diff --git a/SPMS.Application/Interfaces/IWebhookService.cs b/SPMS.Application/Interfaces/IWebhookService.cs new file mode 100644 index 0000000..781419c --- /dev/null +++ b/SPMS.Application/Interfaces/IWebhookService.cs @@ -0,0 +1,8 @@ +using SPMS.Domain.Enums; + +namespace SPMS.Application.Interfaces; + +public interface IWebhookService +{ + Task SendAsync(long serviceId, WebhookEvent eventType, object data); +} diff --git a/SPMS.Domain/Interfaces/IWebhookLogRepository.cs b/SPMS.Domain/Interfaces/IWebhookLogRepository.cs new file mode 100644 index 0000000..a25d7df --- /dev/null +++ b/SPMS.Domain/Interfaces/IWebhookLogRepository.cs @@ -0,0 +1,7 @@ +using SPMS.Domain.Entities; + +namespace SPMS.Domain.Interfaces; + +public interface IWebhookLogRepository : IRepository +{ +} diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index f0f665e..57f05b3 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -12,6 +12,7 @@ using SPMS.Infrastructure.Push; using SPMS.Infrastructure.Persistence.Repositories; using SPMS.Infrastructure.Security; using SPMS.Infrastructure.Services; +using SPMS.Infrastructure.Webhook; using SPMS.Infrastructure.Workers; namespace SPMS.Infrastructure; @@ -41,6 +42,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // External Services services.AddScoped(); @@ -73,6 +75,10 @@ public static class DependencyInjection }); services.AddSingleton(); + // Webhook + services.AddHttpClient("Webhook"); + services.AddSingleton(); + // Workers services.AddHostedService(); services.AddHostedService(); diff --git a/SPMS.Infrastructure/Persistence/Repositories/WebhookLogRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/WebhookLogRepository.cs new file mode 100644 index 0000000..16badb1 --- /dev/null +++ b/SPMS.Infrastructure/Persistence/Repositories/WebhookLogRepository.cs @@ -0,0 +1,9 @@ +using SPMS.Domain.Entities; +using SPMS.Domain.Interfaces; + +namespace SPMS.Infrastructure.Persistence.Repositories; + +public class WebhookLogRepository : Repository, IWebhookLogRepository +{ + public WebhookLogRepository(AppDbContext context) : base(context) { } +} diff --git a/SPMS.Infrastructure/Webhook/WebhookService.cs b/SPMS.Infrastructure/Webhook/WebhookService.cs new file mode 100644 index 0000000..d36a5c0 --- /dev/null +++ b/SPMS.Infrastructure/Webhook/WebhookService.cs @@ -0,0 +1,141 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SPMS.Application.DTOs.Webhook; +using SPMS.Application.Interfaces; +using SPMS.Domain.Entities; +using SPMS.Domain.Enums; +using SPMS.Domain.Interfaces; + +namespace SPMS.Infrastructure.Webhook; + +public class WebhookService : IWebhookService +{ + private readonly IServiceProvider _serviceProvider; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + private const int MaxRetries = 3; + private const int RetryDelaySeconds = 30; + private const int TimeoutSeconds = 10; + + public WebhookService( + IServiceProvider serviceProvider, + IHttpClientFactory httpClientFactory, + ILogger logger) + { + _serviceProvider = serviceProvider; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public Task SendAsync(long serviceId, WebhookEvent eventType, object data) + { + _ = Task.Run(() => SendWithRetryAsync(serviceId, eventType, data)); + return Task.CompletedTask; + } + + private async Task SendWithRetryAsync(long serviceId, WebhookEvent eventType, object data) + { + using var scope = _serviceProvider.CreateScope(); + var serviceRepo = scope.ServiceProvider.GetRequiredService(); + var webhookLogRepo = scope.ServiceProvider.GetRequiredService(); + var unitOfWork = scope.ServiceProvider.GetRequiredService(); + + var service = await serviceRepo.GetByIdAsync(serviceId); + if (service == null || string.IsNullOrEmpty(service.WebhookUrl)) + return; + + var eventName = EventTypeToString(eventType); + if (!IsEventSubscribed(service, eventName)) + return; + + var payload = new WebhookPayloadDto + { + EventType = eventName, + Timestamp = DateTime.UtcNow, + ServiceCode = service.ServiceCode, + Data = data + }; + + var jsonPayload = JsonSerializer.Serialize(payload); + var client = _httpClientFactory.CreateClient("Webhook"); + client.Timeout = TimeSpan.FromSeconds(TimeoutSeconds); + + int? responseCode = null; + string? responseBody = null; + var success = false; + + for (var attempt = 1; attempt <= MaxRetries; attempt++) + { + try + { + var content = new StringContent(jsonPayload, System.Text.Encoding.UTF8, "application/json"); + var response = await client.PostAsync(service.WebhookUrl, content); + + responseCode = (int)response.StatusCode; + responseBody = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + success = true; + _logger.LogInformation("Webhook sent successfully to {Url} (attempt {Attempt})", service.WebhookUrl, attempt); + break; + } + + _logger.LogWarning("Webhook failed with status {StatusCode} (attempt {Attempt}/{MaxRetries})", + responseCode, attempt, MaxRetries); + } + catch (Exception ex) + { + responseBody = ex.Message; + _logger.LogWarning(ex, "Webhook request failed (attempt {Attempt}/{MaxRetries})", attempt, MaxRetries); + } + + if (attempt < MaxRetries) + await Task.Delay(TimeSpan.FromSeconds(RetryDelaySeconds)); + } + + var log = new WebhookLog + { + ServiceId = serviceId, + WebhookUrl = service.WebhookUrl, + EventType = eventType, + Payload = jsonPayload, + Status = success ? WebhookStatus.Success : WebhookStatus.Failed, + ResponseCode = responseCode, + ResponseBody = responseBody?.Length > 2000 ? responseBody[..2000] : responseBody, + SentAt = DateTime.UtcNow + }; + + await webhookLogRepo.AddAsync(log); + await unitOfWork.SaveChangesAsync(); + } + + private static bool IsEventSubscribed(Service service, string eventName) + { + if (string.IsNullOrEmpty(service.WebhookEvents)) + return false; + + try + { + var events = JsonSerializer.Deserialize>(service.WebhookEvents); + return events?.Contains(eventName) == true; + } + catch + { + return false; + } + } + + private static string EventTypeToString(WebhookEvent eventType) + { + return eventType switch + { + WebhookEvent.PushSent => "push_sent", + WebhookEvent.PushFailed => "push_failed", + WebhookEvent.PushClicked => "push_clicked", + _ => eventType.ToString().ToLowerInvariant() + }; + } +} diff --git a/TASKS.md b/TASKS.md index c6f7c27..e0daec6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1654,7 +1654,7 @@ Milestone: Phase 3: 메시지 & Push Core | 8 | [Feature] 상세 로그 다운로드 API | Feature | Medium | EXP-02 | ✅ | | 9 | [Feature] 실패원인 순위 API | Feature | Medium | ANA-01 | ✅ | | 10 | [Feature] 웹훅 설정 API | Feature | High | WHK-01 | ✅ | -| 11 | [Feature] **웹훅 발송 서비스** | Feature | High | WHK-02 | ⬜ | +| 11 | [Feature] **웹훅 발송 서비스** | Feature | High | WHK-02 | ✅ | | 12 | [Feature] **DailyStatWorker 구현** | Feature | Medium | AAG-01 | ⬜ | | 13 | [Feature] **DeadTokenCleanupWorker 구현** | Feature | Medium | DTK-01 | ⬜ | | 14 | [Feature] **데이터 보관 주기 관리 배치** | Feature | Medium | RET-01 | ⬜ | -- 2.45.1