From fe1dcd0176643bfde7b833b3e736b99ed9a8da7a Mon Sep 17 00:00:00 2001 From: SEAN Date: Tue, 10 Feb 2026 17:06:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=EB=B0=9C=EC=86=A1?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D/=EC=B7=A8=EC=86=8C=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /v1/in/push/schedule (예약 발송 등록) - POST /v1/in/push/schedule/cancel (예약 취소) - ScheduleCancelStore: Redis 기반 예약 취소 추적 - ScheduleWorker: 취소된 예약 메시지 ACK 후 스킵 로직 추가 Closes #116 --- SPMS.API/Controllers/PushController.cs | 18 ++++ .../DTOs/Push/PushScheduleCancelRequestDto.cs | 9 ++ .../DTOs/Push/PushScheduleRequestDto.cs | 21 +++++ .../DTOs/Push/PushScheduleResponseDto.cs | 8 ++ SPMS.Application/Interfaces/IPushService.cs | 2 + .../Interfaces/IScheduleCancelStore.cs | 7 ++ SPMS.Application/Services/PushService.cs | 87 ++++++++++++++++++- .../Caching/ScheduleCancelStore.cs | 58 +++++++++++++ SPMS.Infrastructure/DependencyInjection.cs | 1 + SPMS.Infrastructure/Workers/ScheduleWorker.cs | 11 +++ 10 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 SPMS.Application/DTOs/Push/PushScheduleCancelRequestDto.cs create mode 100644 SPMS.Application/DTOs/Push/PushScheduleRequestDto.cs create mode 100644 SPMS.Application/DTOs/Push/PushScheduleResponseDto.cs create mode 100644 SPMS.Application/Interfaces/IScheduleCancelStore.cs create mode 100644 SPMS.Infrastructure/Caching/ScheduleCancelStore.cs diff --git a/SPMS.API/Controllers/PushController.cs b/SPMS.API/Controllers/PushController.cs index ba3e5e6..e64b5d1 100644 --- a/SPMS.API/Controllers/PushController.cs +++ b/SPMS.API/Controllers/PushController.cs @@ -36,6 +36,24 @@ public class PushController : ControllerBase return Ok(ApiResponse.Success(result)); } + [HttpPost("schedule")] + [SwaggerOperation(Summary = "예약 발송", Description = "지정 시간에 푸시 메시지 발송을 예약합니다.")] + public async Task ScheduleAsync([FromBody] PushScheduleRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _pushService.ScheduleAsync(serviceId, request); + return Ok(ApiResponse.Success(result)); + } + + [HttpPost("schedule/cancel")] + [SwaggerOperation(Summary = "예약 취소", Description = "예약된 발송을 취소합니다.")] + public async Task CancelScheduleAsync([FromBody] PushScheduleCancelRequestDto request) + { + var serviceId = GetServiceId(); + await _pushService.CancelScheduleAsync(request); + return Ok(ApiResponse.Success()); + } + private long GetServiceId() { if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) diff --git a/SPMS.Application/DTOs/Push/PushScheduleCancelRequestDto.cs b/SPMS.Application/DTOs/Push/PushScheduleCancelRequestDto.cs new file mode 100644 index 0000000..ff2553b --- /dev/null +++ b/SPMS.Application/DTOs/Push/PushScheduleCancelRequestDto.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Push; + +public class PushScheduleCancelRequestDto +{ + [Required] + public string ScheduleId { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Push/PushScheduleRequestDto.cs b/SPMS.Application/DTOs/Push/PushScheduleRequestDto.cs new file mode 100644 index 0000000..3178a74 --- /dev/null +++ b/SPMS.Application/DTOs/Push/PushScheduleRequestDto.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace SPMS.Application.DTOs.Push; + +public class PushScheduleRequestDto +{ + [Required] + public string MessageCode { get; set; } = string.Empty; + + [Required] + public string SendType { get; set; } = string.Empty; + + public long? DeviceId { get; set; } + + public List? Tags { get; set; } + + [Required] + public string ScheduledAt { get; set; } = string.Empty; + + public Dictionary? Variables { get; set; } +} diff --git a/SPMS.Application/DTOs/Push/PushScheduleResponseDto.cs b/SPMS.Application/DTOs/Push/PushScheduleResponseDto.cs new file mode 100644 index 0000000..da2b186 --- /dev/null +++ b/SPMS.Application/DTOs/Push/PushScheduleResponseDto.cs @@ -0,0 +1,8 @@ +namespace SPMS.Application.DTOs.Push; + +public class PushScheduleResponseDto +{ + public string ScheduleId { get; set; } = string.Empty; + public string ScheduledAt { get; set; } = string.Empty; + public string Status { get; set; } = "scheduled"; +} diff --git a/SPMS.Application/Interfaces/IPushService.cs b/SPMS.Application/Interfaces/IPushService.cs index b393220..faf36e4 100644 --- a/SPMS.Application/Interfaces/IPushService.cs +++ b/SPMS.Application/Interfaces/IPushService.cs @@ -6,4 +6,6 @@ public interface IPushService { Task SendAsync(long serviceId, PushSendRequestDto request); Task SendByTagAsync(long serviceId, PushSendTagRequestDto request); + Task ScheduleAsync(long serviceId, PushScheduleRequestDto request); + Task CancelScheduleAsync(PushScheduleCancelRequestDto request); } diff --git a/SPMS.Application/Interfaces/IScheduleCancelStore.cs b/SPMS.Application/Interfaces/IScheduleCancelStore.cs new file mode 100644 index 0000000..d287839 --- /dev/null +++ b/SPMS.Application/Interfaces/IScheduleCancelStore.cs @@ -0,0 +1,7 @@ +namespace SPMS.Application.Interfaces; + +public interface IScheduleCancelStore +{ + Task MarkCancelledAsync(string scheduleId, CancellationToken cancellationToken = default); + Task IsCancelledAsync(string scheduleId, CancellationToken cancellationToken = default); +} diff --git a/SPMS.Application/Services/PushService.cs b/SPMS.Application/Services/PushService.cs index 200b364..9dc885a 100644 --- a/SPMS.Application/Services/PushService.cs +++ b/SPMS.Application/Services/PushService.cs @@ -11,11 +11,16 @@ public class PushService : IPushService { private readonly IMessageRepository _messageRepository; private readonly IPushQueueService _pushQueueService; + private readonly IScheduleCancelStore _scheduleCancelStore; - public PushService(IMessageRepository messageRepository, IPushQueueService pushQueueService) + public PushService( + IMessageRepository messageRepository, + IPushQueueService pushQueueService, + IScheduleCancelStore scheduleCancelStore) { _messageRepository = messageRepository; _pushQueueService = pushQueueService; + _scheduleCancelStore = scheduleCancelStore; } public async Task SendAsync(long serviceId, PushSendRequestDto request) @@ -101,6 +106,86 @@ public class PushService : IPushService }; } + public async Task ScheduleAsync(long serviceId, PushScheduleRequestDto request) + { + var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); + if (message == null) + throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); + + var sendType = request.SendType.ToLowerInvariant(); + if (sendType != "single" && sendType != "tag") + throw new SpmsException(ErrorCodes.BadRequest, "send_type은 single 또는 tag만 허용됩니다.", 400); + + if (sendType == "single" && request.DeviceId == null) + throw new SpmsException(ErrorCodes.BadRequest, "send_type=single 시 device_id는 필수입니다.", 400); + + if (sendType == "tag" && (request.Tags == null || request.Tags.Count == 0)) + throw new SpmsException(ErrorCodes.BadRequest, "send_type=tag 시 tags는 필수입니다.", 400); + + var requestId = Guid.NewGuid().ToString("N"); + var scheduleId = $"sch_{DateTime.UtcNow:yyyyMMdd}_{requestId[..8]}"; + + var title = ApplyVariables(message.Title, request.Variables); + var body = ApplyVariables(message.Body, request.Variables); + + PushTargetDto target; + if (sendType == "single") + { + target = new PushTargetDto + { + Type = "device_ids", + Value = JsonSerializer.SerializeToElement(new[] { request.DeviceId!.Value }) + }; + } + else + { + target = new PushTargetDto + { + Type = "tags", + Value = JsonSerializer.SerializeToElement(new { tags = request.Tags, match = "or" }) + }; + } + + var pushMessage = new PushMessageDto + { + MessageId = message.Id.ToString(), + RequestId = requestId, + ServiceId = serviceId, + SendType = sendType, + Title = title, + Body = body, + ImageUrl = message.ImageUrl, + LinkUrl = message.LinkUrl, + CustomData = ParseCustomData(message.CustomData), + Target = target, + CreatedBy = message.CreatedBy, + CreatedAt = DateTime.UtcNow.ToString("o") + }; + + var scheduleMessage = new ScheduleMessageDto + { + ScheduleId = scheduleId, + MessageId = message.Id.ToString(), + ServiceId = serviceId, + ScheduledAt = request.ScheduledAt, + PushMessage = pushMessage + }; + + await _pushQueueService.PublishScheduleMessageAsync(scheduleMessage); + + return new PushScheduleResponseDto + { + ScheduleId = scheduleId, + ScheduledAt = request.ScheduledAt, + Status = "scheduled" + }; + } + + public async Task CancelScheduleAsync(PushScheduleCancelRequestDto request) + { + await _scheduleCancelStore.MarkCancelledAsync(request.ScheduleId); + } + private static string ApplyVariables(string template, Dictionary? variables) { if (variables == null || variables.Count == 0) diff --git a/SPMS.Infrastructure/Caching/ScheduleCancelStore.cs b/SPMS.Infrastructure/Caching/ScheduleCancelStore.cs new file mode 100644 index 0000000..0e5ff6a --- /dev/null +++ b/SPMS.Infrastructure/Caching/ScheduleCancelStore.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SPMS.Application.Interfaces; +using SPMS.Application.Settings; + +namespace SPMS.Infrastructure.Caching; + +public class ScheduleCancelStore : IScheduleCancelStore +{ + private static readonly TimeSpan DefaultTtl = TimeSpan.FromDays(7); + + private readonly RedisConnection _redis; + private readonly RedisSettings _settings; + private readonly ILogger _logger; + + public ScheduleCancelStore( + RedisConnection redis, + IOptions settings, + ILogger logger) + { + _redis = redis; + _settings = settings.Value; + _logger = logger; + } + + public async Task MarkCancelledAsync(string scheduleId, CancellationToken cancellationToken = default) + { + var key = $"{_settings.InstanceName}schedule_cancel:{scheduleId}"; + + try + { + var db = await _redis.GetDatabaseAsync(); + await db.StringSetAsync(key, "1", DefaultTtl); + _logger.LogInformation("예약 취소 등록: scheduleId={ScheduleId}", scheduleId); + } + catch (Exception ex) + { + _logger.LogError(ex, "예약 취소 등록 실패: scheduleId={ScheduleId}", scheduleId); + throw; + } + } + + public async Task IsCancelledAsync(string scheduleId, CancellationToken cancellationToken = default) + { + var key = $"{_settings.InstanceName}schedule_cancel:{scheduleId}"; + + try + { + var db = await _redis.GetDatabaseAsync(); + return await db.KeyExistsAsync(key); + } + catch (Exception ex) + { + _logger.LogError(ex, "예약 취소 확인 실패: scheduleId={ScheduleId} — 취소되지 않은 것으로 처리", scheduleId); + return false; + } + } +} diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 495f11c..42f0899 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -52,6 +52,7 @@ public static class DependencyInjection services.Configure(configuration.GetSection(RedisSettings.SectionName)); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // RabbitMQ services.Configure(configuration.GetSection(RabbitMQSettings.SectionName)); diff --git a/SPMS.Infrastructure/Workers/ScheduleWorker.cs b/SPMS.Infrastructure/Workers/ScheduleWorker.cs index 03fa280..c00bc21 100644 --- a/SPMS.Infrastructure/Workers/ScheduleWorker.cs +++ b/SPMS.Infrastructure/Workers/ScheduleWorker.cs @@ -20,17 +20,20 @@ public class ScheduleWorker : BackgroundService private readonly RabbitMQConnection _rabbitConnection; private readonly RabbitMQSettings _rabbitSettings; + private readonly IScheduleCancelStore _scheduleCancelStore; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; public ScheduleWorker( RabbitMQConnection rabbitConnection, IOptions rabbitSettings, + IScheduleCancelStore scheduleCancelStore, IServiceScopeFactory scopeFactory, ILogger logger) { _rabbitConnection = rabbitConnection; _rabbitSettings = rabbitSettings.Value; + _scheduleCancelStore = scheduleCancelStore; _scopeFactory = scopeFactory; _logger = logger; } @@ -94,6 +97,14 @@ public class ScheduleWorker : BackgroundService continue; } + // 취소된 예약인지 확인 + if (await _scheduleCancelStore.IsCancelledAsync(scheduleMessage.ScheduleId, ct)) + { + _logger.LogInformation("취소된 예약 메시지 — ACK 후 스킵: scheduleId={ScheduleId}", scheduleMessage.ScheduleId); + await channel.BasicAckAsync(result.DeliveryTag, false, ct); + continue; + } + // 아직 예약 시간이 도래하지 않음 → NACK + requeue if (scheduledAt.Value > DateTime.UtcNow) {