feat: 웹훅 발송 서비스 구현 (#146)

Closes #146
This commit is contained in:
SEAN 2026-02-11 10:10:11 +09:00
parent b5de3ca2d1
commit d717603365
7 changed files with 190 additions and 1 deletions

View File

@ -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();
}

View File

@ -0,0 +1,8 @@
using SPMS.Domain.Enums;
namespace SPMS.Application.Interfaces;
public interface IWebhookService
{
Task SendAsync(long serviceId, WebhookEvent eventType, object data);
}

View File

@ -0,0 +1,7 @@
using SPMS.Domain.Entities;
namespace SPMS.Domain.Interfaces;
public interface IWebhookLogRepository : IRepository<WebhookLog>
{
}

View File

@ -12,6 +12,7 @@ using SPMS.Infrastructure.Push;
using SPMS.Infrastructure.Persistence.Repositories; using SPMS.Infrastructure.Persistence.Repositories;
using SPMS.Infrastructure.Security; using SPMS.Infrastructure.Security;
using SPMS.Infrastructure.Services; using SPMS.Infrastructure.Services;
using SPMS.Infrastructure.Webhook;
using SPMS.Infrastructure.Workers; using SPMS.Infrastructure.Workers;
namespace SPMS.Infrastructure; namespace SPMS.Infrastructure;
@ -41,6 +42,7 @@ public static class DependencyInjection
services.AddScoped<IMessageRepository, MessageRepository>(); services.AddScoped<IMessageRepository, MessageRepository>();
services.AddScoped<IPushSendLogRepository, PushSendLogRepository>(); services.AddScoped<IPushSendLogRepository, PushSendLogRepository>();
services.AddScoped<IDailyStatRepository, DailyStatRepository>(); services.AddScoped<IDailyStatRepository, DailyStatRepository>();
services.AddScoped<IWebhookLogRepository, WebhookLogRepository>();
// External Services // External Services
services.AddScoped<IJwtService, JwtService>(); services.AddScoped<IJwtService, JwtService>();
@ -73,6 +75,10 @@ public static class DependencyInjection
}); });
services.AddSingleton<IApnsSender, ApnsSender>(); services.AddSingleton<IApnsSender, ApnsSender>();
// Webhook
services.AddHttpClient("Webhook");
services.AddSingleton<IWebhookService, WebhookService>();
// Workers // Workers
services.AddHostedService<PushWorker>(); services.AddHostedService<PushWorker>();
services.AddHostedService<ScheduleWorker>(); services.AddHostedService<ScheduleWorker>();

View File

@ -0,0 +1,9 @@
using SPMS.Domain.Entities;
using SPMS.Domain.Interfaces;
namespace SPMS.Infrastructure.Persistence.Repositories;
public class WebhookLogRepository : Repository<WebhookLog>, IWebhookLogRepository
{
public WebhookLogRepository(AppDbContext context) : base(context) { }
}

View File

@ -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<WebhookService> _logger;
private const int MaxRetries = 3;
private const int RetryDelaySeconds = 30;
private const int TimeoutSeconds = 10;
public WebhookService(
IServiceProvider serviceProvider,
IHttpClientFactory httpClientFactory,
ILogger<WebhookService> 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<IServiceRepository>();
var webhookLogRepo = scope.ServiceProvider.GetRequiredService<IWebhookLogRepository>();
var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
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<List<string>>(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()
};
}
}

View File

@ -1654,7 +1654,7 @@ Milestone: Phase 3: 메시지 & Push Core
| 8 | [Feature] 상세 로그 다운로드 API | Feature | Medium | EXP-02 | ✅ | | 8 | [Feature] 상세 로그 다운로드 API | Feature | Medium | EXP-02 | ✅ |
| 9 | [Feature] 실패원인 순위 API | Feature | Medium | ANA-01 | ✅ | | 9 | [Feature] 실패원인 순위 API | Feature | Medium | ANA-01 | ✅ |
| 10 | [Feature] 웹훅 설정 API | Feature | High | WHK-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 | ⬜ | | 12 | [Feature] **DailyStatWorker 구현** | Feature | Medium | AAG-01 | ⬜ |
| 13 | [Feature] **DeadTokenCleanupWorker 구현** | Feature | Medium | DTK-01 | ⬜ | | 13 | [Feature] **DeadTokenCleanupWorker 구현** | Feature | Medium | DTK-01 | ⬜ |
| 14 | [Feature] **데이터 보관 주기 관리 배치** | Feature | Medium | RET-01 | ⬜ | | 14 | [Feature] **데이터 보관 주기 관리 배치** | Feature | Medium | RET-01 | ⬜ |