parent
b5de3ca2d1
commit
d717603365
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.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>();
|
||||||
|
|
|
||||||
|
|
@ -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 | ✅ |
|
| 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 | ⬜ |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user