feat: 예약 발송 등록/취소 API 구현 (#116) #117
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SPMS.Application.DTOs.Push;
|
||||
|
||||
public class PushScheduleCancelRequestDto
|
||||
{
|
||||
[Required]
|
||||
public string ScheduleId { get; set; } = string.Empty;
|
||||
}
|
||||
21
SPMS.Application/DTOs/Push/PushScheduleRequestDto.cs
Normal file
21
SPMS.Application/DTOs/Push/PushScheduleRequestDto.cs
Normal 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; }
|
||||
}
|
||||
8
SPMS.Application/DTOs/Push/PushScheduleResponseDto.cs
Normal file
8
SPMS.Application/DTOs/Push/PushScheduleResponseDto.cs
Normal 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";
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
7
SPMS.Application/Interfaces/IScheduleCancelStore.cs
Normal file
7
SPMS.Application/Interfaces/IScheduleCancelStore.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
58
SPMS.Infrastructure/Caching/ScheduleCancelStore.cs
Normal file
58
SPMS.Infrastructure/Caching/ScheduleCancelStore.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user