feat: 예약 발송 등록/취소 API 구현 (#116)
- POST /v1/in/push/schedule (예약 발송 등록) - POST /v1/in/push/schedule/cancel (예약 취소) - ScheduleCancelStore: Redis 기반 예약 취소 추적 - ScheduleWorker: 취소된 예약 메시지 ACK 후 스킵 로직 추가 Closes #116
This commit is contained in:
parent
47dff6b2f0
commit
fe1dcd0176
|
|
@ -36,6 +36,24 @@ public class PushController : ControllerBase
|
||||||
return Ok(ApiResponse<PushSendResponseDto>.Success(result));
|
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()
|
private long GetServiceId()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
|
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> SendAsync(long serviceId, PushSendRequestDto request);
|
||||||
Task<PushSendResponseDto> SendByTagAsync(long serviceId, PushSendTagRequestDto 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 IMessageRepository _messageRepository;
|
||||||
private readonly IPushQueueService _pushQueueService;
|
private readonly IPushQueueService _pushQueueService;
|
||||||
|
private readonly IScheduleCancelStore _scheduleCancelStore;
|
||||||
|
|
||||||
public PushService(IMessageRepository messageRepository, IPushQueueService pushQueueService)
|
public PushService(
|
||||||
|
IMessageRepository messageRepository,
|
||||||
|
IPushQueueService pushQueueService,
|
||||||
|
IScheduleCancelStore scheduleCancelStore)
|
||||||
{
|
{
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
_pushQueueService = pushQueueService;
|
_pushQueueService = pushQueueService;
|
||||||
|
_scheduleCancelStore = scheduleCancelStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PushSendResponseDto> SendAsync(long serviceId, PushSendRequestDto request)
|
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)
|
private static string ApplyVariables(string template, Dictionary<string, string>? variables)
|
||||||
{
|
{
|
||||||
if (variables == null || variables.Count == 0)
|
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.Configure<RedisSettings>(configuration.GetSection(RedisSettings.SectionName));
|
||||||
services.AddSingleton<RedisConnection>();
|
services.AddSingleton<RedisConnection>();
|
||||||
services.AddSingleton<IDuplicateChecker, DuplicateChecker>();
|
services.AddSingleton<IDuplicateChecker, DuplicateChecker>();
|
||||||
|
services.AddSingleton<IScheduleCancelStore, ScheduleCancelStore>();
|
||||||
|
|
||||||
// RabbitMQ
|
// RabbitMQ
|
||||||
services.Configure<RabbitMQSettings>(configuration.GetSection(RabbitMQSettings.SectionName));
|
services.Configure<RabbitMQSettings>(configuration.GetSection(RabbitMQSettings.SectionName));
|
||||||
|
|
|
||||||
|
|
@ -20,17 +20,20 @@ public class ScheduleWorker : BackgroundService
|
||||||
|
|
||||||
private readonly RabbitMQConnection _rabbitConnection;
|
private readonly RabbitMQConnection _rabbitConnection;
|
||||||
private readonly RabbitMQSettings _rabbitSettings;
|
private readonly RabbitMQSettings _rabbitSettings;
|
||||||
|
private readonly IScheduleCancelStore _scheduleCancelStore;
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly ILogger<ScheduleWorker> _logger;
|
private readonly ILogger<ScheduleWorker> _logger;
|
||||||
|
|
||||||
public ScheduleWorker(
|
public ScheduleWorker(
|
||||||
RabbitMQConnection rabbitConnection,
|
RabbitMQConnection rabbitConnection,
|
||||||
IOptions<RabbitMQSettings> rabbitSettings,
|
IOptions<RabbitMQSettings> rabbitSettings,
|
||||||
|
IScheduleCancelStore scheduleCancelStore,
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
ILogger<ScheduleWorker> logger)
|
ILogger<ScheduleWorker> logger)
|
||||||
{
|
{
|
||||||
_rabbitConnection = rabbitConnection;
|
_rabbitConnection = rabbitConnection;
|
||||||
_rabbitSettings = rabbitSettings.Value;
|
_rabbitSettings = rabbitSettings.Value;
|
||||||
|
_scheduleCancelStore = scheduleCancelStore;
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +97,14 @@ public class ScheduleWorker : BackgroundService
|
||||||
continue;
|
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
|
// 아직 예약 시간이 도래하지 않음 → NACK + requeue
|
||||||
if (scheduledAt.Value > DateTime.UtcNow)
|
if (scheduledAt.Value > DateTime.UtcNow)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user