SPMS_API/SPMS.Application/Services/PushService.cs
SEAN fe1dcd0176 feat: 예약 발송 등록/취소 API 구현 (#116)
- POST /v1/in/push/schedule (예약 발송 등록)
- POST /v1/in/push/schedule/cancel (예약 취소)
- ScheduleCancelStore: Redis 기반 예약 취소 추적
- ScheduleWorker: 취소된 예약 메시지 ACK 후 스킵 로직 추가

Closes #116
2026-02-10 17:06:04 +09:00

210 lines
7.2 KiB
C#

using System.Text.Json;
using SPMS.Application.DTOs.Push;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Domain.Exceptions;
using SPMS.Domain.Interfaces;
namespace SPMS.Application.Services;
public class PushService : IPushService
{
private readonly IMessageRepository _messageRepository;
private readonly IPushQueueService _pushQueueService;
private readonly IScheduleCancelStore _scheduleCancelStore;
public PushService(
IMessageRepository messageRepository,
IPushQueueService pushQueueService,
IScheduleCancelStore scheduleCancelStore)
{
_messageRepository = messageRepository;
_pushQueueService = pushQueueService;
_scheduleCancelStore = scheduleCancelStore;
}
public async Task<PushSendResponseDto> SendAsync(long serviceId, PushSendRequestDto request)
{
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId);
if (message == null)
throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404);
var title = ApplyVariables(message.Title, request.Variables);
var body = ApplyVariables(message.Body, request.Variables);
var requestId = Guid.NewGuid().ToString("N");
var pushMessage = new PushMessageDto
{
MessageId = message.Id.ToString(),
RequestId = requestId,
ServiceId = serviceId,
SendType = "single",
Title = title,
Body = body,
ImageUrl = message.ImageUrl,
LinkUrl = message.LinkUrl,
CustomData = ParseCustomData(message.CustomData),
Target = new PushTargetDto
{
Type = "device_ids",
Value = JsonSerializer.SerializeToElement(new[] { request.DeviceId })
},
CreatedBy = message.CreatedBy,
CreatedAt = DateTime.UtcNow.ToString("o")
};
await _pushQueueService.PublishPushMessageAsync(pushMessage);
return new PushSendResponseDto
{
RequestId = requestId,
SendType = "single",
Status = "queued"
};
}
public async Task<PushSendResponseDto> SendByTagAsync(long serviceId, PushSendTagRequestDto request)
{
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId);
if (message == null)
throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404);
var requestId = Guid.NewGuid().ToString("N");
var pushMessage = new PushMessageDto
{
MessageId = message.Id.ToString(),
RequestId = requestId,
ServiceId = serviceId,
SendType = "group",
Title = message.Title,
Body = message.Body,
ImageUrl = message.ImageUrl,
LinkUrl = message.LinkUrl,
CustomData = ParseCustomData(message.CustomData),
Target = new PushTargetDto
{
Type = "tags",
Value = JsonSerializer.SerializeToElement(new
{
tags = request.Tags,
match = request.TagMatch
})
},
CreatedBy = message.CreatedBy,
CreatedAt = DateTime.UtcNow.ToString("o")
};
await _pushQueueService.PublishPushMessageAsync(pushMessage);
return new PushSendResponseDto
{
RequestId = requestId,
SendType = "group",
Status = "queued"
};
}
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)
return template;
var result = template;
foreach (var (key, value) in variables)
{
result = result.Replace($"{{{{{key}}}}}", value);
}
return result;
}
private static Dictionary<string, object>? ParseCustomData(string? customData)
{
if (string.IsNullOrWhiteSpace(customData))
return null;
return JsonSerializer.Deserialize<Dictionary<string, object>>(customData);
}
}