feat: 예약 발송 등록/취소 API 구현 (#116)
Some checks failed
SPMS_API/pipeline/head There was a failure building this commit

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/117
This commit is contained in:
김선규 2026-02-10 08:11:04 +00:00
commit fc16884d25
10 changed files with 221 additions and 1 deletions

View File

@ -36,6 +36,24 @@ public class PushController : ControllerBase
return Ok(ApiResponse<PushSendResponseDto>.Success(result));
}
[HttpPost("schedule")]
[SwaggerOperation(Summary = "예약 발송", Description = "지정 시간에 푸시 메시지 발송을 예약합니다.")]
public async Task<IActionResult> ScheduleAsync([FromBody] PushScheduleRequestDto request)
{
var serviceId = GetServiceId();
var result = await _pushService.ScheduleAsync(serviceId, request);
return Ok(ApiResponse<PushScheduleResponseDto>.Success(result));
}
[HttpPost("schedule/cancel")]
[SwaggerOperation(Summary = "예약 취소", Description = "예약된 발송을 취소합니다.")]
public async Task<IActionResult> 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)

View File

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace SPMS.Application.DTOs.Push;
public class PushScheduleCancelRequestDto
{
[Required]
public string ScheduleId { get; set; } = string.Empty;
}

View File

@ -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<int>? Tags { get; set; }
[Required]
public string ScheduledAt { get; set; } = string.Empty;
public Dictionary<string, string>? Variables { get; set; }
}

View File

@ -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";
}

View File

@ -6,4 +6,6 @@ public interface IPushService
{
Task<PushSendResponseDto> SendAsync(long serviceId, PushSendRequestDto request);
Task<PushSendResponseDto> SendByTagAsync(long serviceId, PushSendTagRequestDto request);
Task<PushScheduleResponseDto> ScheduleAsync(long serviceId, PushScheduleRequestDto request);
Task CancelScheduleAsync(PushScheduleCancelRequestDto request);
}

View File

@ -0,0 +1,7 @@
namespace SPMS.Application.Interfaces;
public interface IScheduleCancelStore
{
Task MarkCancelledAsync(string scheduleId, CancellationToken cancellationToken = default);
Task<bool> IsCancelledAsync(string scheduleId, CancellationToken cancellationToken = default);
}

View File

@ -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<PushSendResponseDto> SendAsync(long serviceId, PushSendRequestDto request)
@ -101,6 +106,86 @@ public class PushService : IPushService
};
}
public async Task<PushScheduleResponseDto> 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<string, string>? variables)
{
if (variables == null || variables.Count == 0)

View File

@ -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<ScheduleCancelStore> _logger;
public ScheduleCancelStore(
RedisConnection redis,
IOptions<RedisSettings> settings,
ILogger<ScheduleCancelStore> 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<bool> 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;
}
}
}

View File

@ -52,6 +52,7 @@ public static class DependencyInjection
services.Configure<RedisSettings>(configuration.GetSection(RedisSettings.SectionName));
services.AddSingleton<RedisConnection>();
services.AddSingleton<IDuplicateChecker, DuplicateChecker>();
services.AddSingleton<IScheduleCancelStore, ScheduleCancelStore>();
// RabbitMQ
services.Configure<RabbitMQSettings>(configuration.GetSection(RabbitMQSettings.SectionName));

View File

@ -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<ScheduleWorker> _logger;
public ScheduleWorker(
RabbitMQConnection rabbitConnection,
IOptions<RabbitMQSettings> rabbitSettings,
IScheduleCancelStore scheduleCancelStore,
IServiceScopeFactory scopeFactory,
ILogger<ScheduleWorker> 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)
{