feat: 웹훅 설정 API 구현 (#144)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/145
This commit is contained in:
김선규 2026-02-11 01:06:25 +00:00
commit b5de3ca2d1
10 changed files with 1251 additions and 2 deletions

View File

@ -207,6 +207,22 @@ public class ServiceController : ControllerBase
return Ok(ApiResponse<ServiceTagsResponseDto>.Success(result)); return Ok(ApiResponse<ServiceTagsResponseDto>.Success(result));
} }
[HttpPost("webhook/config")]
[SwaggerOperation(Summary = "웹훅 설정", Description = "서비스의 웹훅 URL과 구독 이벤트 타입을 설정합니다.")]
public async Task<IActionResult> ConfigureWebhookAsync([FromBody] WebhookConfigRequestDto request)
{
var result = await _serviceManagementService.ConfigureWebhookAsync(request.ServiceCode, request);
return Ok(ApiResponse<WebhookConfigResponseDto>.Success(result));
}
[HttpPost("webhook/info")]
[SwaggerOperation(Summary = "웹훅 설정 조회", Description = "서비스의 현재 웹훅 설정을 조회합니다.")]
public async Task<IActionResult> GetWebhookConfigAsync([FromBody] WebhookInfoRequestDto request)
{
var result = await _serviceManagementService.GetWebhookConfigAsync(request.ServiceCode);
return Ok(ApiResponse<WebhookConfigResponseDto>.Success(result));
}
[HttpPost("{serviceCode}/ip/list")] [HttpPost("{serviceCode}/ip/list")]
[SwaggerOperation( [SwaggerOperation(
Summary = "IP 화이트리스트 조회", Summary = "IP 화이트리스트 조회",

View File

@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Service;
public class WebhookConfigRequestDto
{
[Required]
[JsonPropertyName("service_code")]
public string ServiceCode { get; set; } = string.Empty;
[JsonPropertyName("webhook_url")]
[Url]
public string? WebhookUrl { get; set; }
[JsonPropertyName("events")]
public List<string>? Events { get; set; }
}
public class WebhookInfoRequestDto
{
[Required]
[JsonPropertyName("service_code")]
public string ServiceCode { get; set; } = string.Empty;
}

View File

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Service;
public class WebhookConfigResponseDto
{
[JsonPropertyName("webhook_url")]
public string? WebhookUrl { get; set; }
[JsonPropertyName("events")]
public List<string> Events { get; set; } = new();
[JsonPropertyName("is_active")]
public bool IsActive { get; set; }
}

View File

@ -19,6 +19,10 @@ public interface IServiceManagementService
Task<ServiceTagsResponseDto> GetTagsAsync(ServiceTagsRequestDto request); Task<ServiceTagsResponseDto> GetTagsAsync(ServiceTagsRequestDto request);
Task<ServiceTagsResponseDto> UpdateTagsAsync(UpdateServiceTagsRequestDto request); Task<ServiceTagsResponseDto> UpdateTagsAsync(UpdateServiceTagsRequestDto request);
// Webhook
Task<WebhookConfigResponseDto> ConfigureWebhookAsync(string serviceCode, WebhookConfigRequestDto request);
Task<WebhookConfigResponseDto> GetWebhookConfigAsync(string serviceCode);
// IP Whitelist // IP Whitelist
Task<IpListResponseDto> GetIpListAsync(string serviceCode); Task<IpListResponseDto> GetIpListAsync(string serviceCode);
Task<ServiceIpDto> AddIpAsync(string serviceCode, AddIpRequestDto request); Task<ServiceIpDto> AddIpAsync(string serviceCode, AddIpRequestDto request);

View File

@ -524,6 +524,60 @@ public class ServiceManagementService : IServiceManagementService
}; };
} }
public async Task<WebhookConfigResponseDto> ConfigureWebhookAsync(string serviceCode, WebhookConfigRequestDto request)
{
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service is null)
throw new SpmsException(ErrorCodes.NotFound, "서비스를 찾을 수 없습니다.", 404);
if (request.Events != null)
{
var validEvents = new HashSet<string> { "push_sent", "push_failed", "push_clicked" };
foreach (var evt in request.Events)
{
if (!validEvents.Contains(evt))
throw new SpmsException(ErrorCodes.BadRequest, $"유효하지 않은 이벤트 타입: {evt}", 400);
}
}
service.WebhookUrl = request.WebhookUrl;
service.WebhookEvents = request.Events != null && request.Events.Count > 0
? JsonSerializer.Serialize(request.Events)
: null;
service.UpdatedAt = DateTime.UtcNow;
_serviceRepository.Update(service);
await _unitOfWork.SaveChangesAsync();
return BuildWebhookConfigResponse(service);
}
public async Task<WebhookConfigResponseDto> GetWebhookConfigAsync(string serviceCode)
{
var service = await _serviceRepository.GetByServiceCodeAsync(serviceCode);
if (service is null)
throw new SpmsException(ErrorCodes.NotFound, "서비스를 찾을 수 없습니다.", 404);
return BuildWebhookConfigResponse(service);
}
private static WebhookConfigResponseDto BuildWebhookConfigResponse(Service service)
{
var events = new List<string>();
if (!string.IsNullOrEmpty(service.WebhookEvents))
{
try { events = JsonSerializer.Deserialize<List<string>>(service.WebhookEvents) ?? new(); }
catch { /* ignore */ }
}
return new WebhookConfigResponseDto
{
WebhookUrl = service.WebhookUrl,
Events = events,
IsActive = !string.IsNullOrEmpty(service.WebhookUrl) && events.Count > 0
};
}
public async Task<IpListResponseDto> GetIpListAsync(string serviceCode) public async Task<IpListResponseDto> GetIpListAsync(string serviceCode)
{ {
var service = await _serviceRepository.GetByServiceCodeWithIpsAsync(serviceCode); var service = await _serviceRepository.GetByServiceCodeWithIpsAsync(serviceCode);

View File

@ -15,6 +15,7 @@ public class Service : BaseEntity
public string? ApnsPrivateKey { get; set; } public string? ApnsPrivateKey { get; set; }
public string? FcmCredentials { get; set; } public string? FcmCredentials { get; set; }
public string? WebhookUrl { get; set; } public string? WebhookUrl { get; set; }
public string? WebhookEvents { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
public SubTier SubTier { get; set; } public SubTier SubTier { get; set; }
public DateTime? SubStartedAt { get; set; } public DateTime? SubStartedAt { get; set; }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SPMS.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddWebhookEventsToService : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "WebhookEvents",
table: "Service",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "WebhookEvents",
table: "Service");
}
}
}

View File

@ -721,6 +721,9 @@ namespace SPMS.Infrastructure.Migrations
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<string>("WebhookEvents")
.HasColumnType("longtext");
b.Property<string>("WebhookUrl") b.Property<string>("WebhookUrl")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("varchar(500)"); .HasColumnType("varchar(500)");

View File

@ -1652,8 +1652,8 @@ Milestone: Phase 3: 메시지 & Push Core
| 6 | [Feature] 발송 상세 로그 조회 API | Feature | High | DDL-02 | ✅ | | 6 | [Feature] 발송 상세 로그 조회 API | Feature | High | DDL-02 | ✅ |
| 7 | [Feature] 통계 리포트 다운로드 API | Feature | Medium | EXP-01 | ✅ | | 7 | [Feature] 통계 리포트 다운로드 API | Feature | Medium | EXP-01 | ✅ |
| 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 | ⬜ |