feat: 웹훅 발송 서비스 구현 (#146)
All checks were successful
SPMS_API/pipeline/head This commit looks good
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/147
This commit is contained in:
commit
9ab7d4786d
18
SPMS.Application/DTOs/Webhook/WebhookPayloadDto.cs
Normal file
18
SPMS.Application/DTOs/Webhook/WebhookPayloadDto.cs
Normal 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();
|
||||
}
|
||||
8
SPMS.Application/Interfaces/IWebhookService.cs
Normal file
8
SPMS.Application/Interfaces/IWebhookService.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
using SPMS.Domain.Enums;
|
||||
|
||||
namespace SPMS.Application.Interfaces;
|
||||
|
||||
public interface IWebhookService
|
||||
{
|
||||
Task SendAsync(long serviceId, WebhookEvent eventType, object data);
|
||||
}
|
||||
7
SPMS.Domain/Interfaces/IWebhookLogRepository.cs
Normal file
7
SPMS.Domain/Interfaces/IWebhookLogRepository.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
using SPMS.Domain.Entities;
|
||||
|
||||
namespace SPMS.Domain.Interfaces;
|
||||
|
||||
public interface IWebhookLogRepository : IRepository<WebhookLog>
|
||||
{
|
||||
}
|
||||
|
|
@ -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<IMessageRepository, MessageRepository>();
|
||||
services.AddScoped<IPushSendLogRepository, PushSendLogRepository>();
|
||||
services.AddScoped<IDailyStatRepository, DailyStatRepository>();
|
||||
services.AddScoped<IWebhookLogRepository, WebhookLogRepository>();
|
||||
|
||||
// External Services
|
||||
services.AddScoped<IJwtService, JwtService>();
|
||||
|
|
@ -73,6 +75,10 @@ public static class DependencyInjection
|
|||
});
|
||||
services.AddSingleton<IApnsSender, ApnsSender>();
|
||||
|
||||
// Webhook
|
||||
services.AddHttpClient("Webhook");
|
||||
services.AddSingleton<IWebhookService, WebhookService>();
|
||||
|
||||
// Workers
|
||||
services.AddHostedService<PushWorker>();
|
||||
services.AddHostedService<ScheduleWorker>();
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
}
|
||||
141
SPMS.Infrastructure/Webhook/WebhookService.cs
Normal file
141
SPMS.Infrastructure/Webhook/WebhookService.cs
Normal 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
2
TASKS.md
2
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 | ⬜ |
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user