SPMS_API/SPMS.Application/Services/PushService.cs
SEAN 3a973f56ce feat: 상세 로그 다운로드 API 구현 (#140)
- POST /v1/in/push/log/export (EXP-02, API_SPMS_07_PUSH_09)
- 발송 로그 CSV 파일 다운로드 (페이징 없이 전체 반환)
- 최대 조회 기간 30일, 최대 100,000건 제한
- message_code, device_id, status 필터 지원

Closes #140
2026-02-11 09:43:47 +09:00

496 lines
18 KiB
C#

using System.Globalization;
using System.Text;
using System.Text.Json;
using SPMS.Application.DTOs.Notice;
using SPMS.Application.DTOs.Push;
using SPMS.Application.Interfaces;
using SPMS.Domain.Common;
using SPMS.Domain.Enums;
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;
private readonly IPushSendLogRepository _pushSendLogRepository;
private readonly IBulkJobStore _bulkJobStore;
public PushService(
IMessageRepository messageRepository,
IPushQueueService pushQueueService,
IScheduleCancelStore scheduleCancelStore,
IPushSendLogRepository pushSendLogRepository,
IBulkJobStore bulkJobStore)
{
_messageRepository = messageRepository;
_pushQueueService = pushQueueService;
_scheduleCancelStore = scheduleCancelStore;
_pushSendLogRepository = pushSendLogRepository;
_bulkJobStore = bulkJobStore;
}
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);
}
public async Task<PushLogResponseDto> GetLogAsync(long serviceId, PushLogRequestDto request)
{
long? messageId = null;
if (!string.IsNullOrWhiteSpace(request.MessageCode))
{
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId);
if (message != null)
messageId = message.Id;
else
return new PushLogResponseDto
{
Items = [],
Pagination = new PaginationDto
{
Page = request.Page,
Size = request.Size,
TotalCount = 0,
TotalPages = 0
}
};
}
PushResult? status = null;
if (!string.IsNullOrWhiteSpace(request.Status))
{
status = request.Status.ToLowerInvariant() switch
{
"success" => PushResult.Success,
"failed" => PushResult.Failed,
_ => null
};
}
DateTime? startDate = null;
if (!string.IsNullOrWhiteSpace(request.StartDate) &&
DateTime.TryParseExact(request.StartDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedStart))
startDate = parsedStart;
DateTime? endDate = null;
if (!string.IsNullOrWhiteSpace(request.EndDate) &&
DateTime.TryParseExact(request.EndDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedEnd))
endDate = parsedEnd;
var (items, totalCount) = await _pushSendLogRepository.GetPagedWithMessageAsync(
serviceId, request.Page, request.Size,
messageId, request.DeviceId, status,
startDate, endDate);
var totalPages = (int)Math.Ceiling((double)totalCount / request.Size);
return new PushLogResponseDto
{
Items = items.Select(l => new PushLogItemDto
{
SendId = l.Id,
MessageCode = l.Message?.MessageCode ?? string.Empty,
DeviceId = l.DeviceId,
Status = l.Status.ToString().ToLowerInvariant(),
FailReason = l.FailReason,
SentAt = l.SentAt
}).ToList(),
Pagination = new PaginationDto
{
Page = request.Page,
Size = request.Size,
TotalCount = totalCount,
TotalPages = totalPages
}
};
}
public async Task<BulkSendResponseDto> SendBulkAsync(long serviceId, Stream csvStream, string messageCode)
{
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(messageCode, serviceId);
if (message == null)
throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404);
var rows = await ParseCsvAsync(csvStream);
if (rows.Count == 0)
throw new SpmsException(ErrorCodes.BadRequest, "CSV 파일에 유효한 데이터가 없습니다.", 400);
var jobId = await _bulkJobStore.CreateJobAsync(serviceId, rows.Count);
foreach (var row in rows)
{
var variables = row.Variables;
var title = ApplyVariables(message.Title, variables);
var body = ApplyVariables(message.Body, 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[] { row.DeviceId })
},
CreatedBy = message.CreatedBy,
CreatedAt = DateTime.UtcNow.ToString("o"),
JobId = jobId
};
await _pushQueueService.PublishPushMessageAsync(pushMessage);
}
return new BulkSendResponseDto
{
JobId = jobId,
Status = "queued",
TotalCount = rows.Count
};
}
public async Task<JobStatusResponseDto> GetJobStatusAsync(long serviceId, JobStatusRequestDto request)
{
if (string.IsNullOrWhiteSpace(request.JobId))
throw new SpmsException(ErrorCodes.BadRequest, "job_id는 필수입니다.", 400);
var job = await _bulkJobStore.GetJobAsync(request.JobId);
if (job == null)
throw new SpmsException(ErrorCodes.JobNotFound, "존재하지 않는 작업 ID입니다.", 404);
if (job.ServiceId != serviceId)
throw new SpmsException(ErrorCodes.JobNotFound, "존재하지 않는 작업 ID입니다.", 404);
var progress = job.TotalCount > 0
? (int)Math.Round((double)(job.SentCount + job.FailedCount) / job.TotalCount * 100)
: 0;
return new JobStatusResponseDto
{
JobId = job.JobId,
Status = job.Status,
TotalCount = job.TotalCount,
SentCount = job.SentCount,
FailedCount = job.FailedCount,
Progress = progress,
StartedAt = job.StartedAt,
CompletedAt = job.CompletedAt
};
}
public async Task<JobCancelResponseDto> CancelJobAsync(long serviceId, JobCancelRequestDto request)
{
if (string.IsNullOrWhiteSpace(request.JobId))
throw new SpmsException(ErrorCodes.BadRequest, "job_id는 필수입니다.", 400);
var job = await _bulkJobStore.GetJobAsync(request.JobId);
if (job == null)
throw new SpmsException(ErrorCodes.JobNotFound, "존재하지 않는 작업 ID입니다.", 404);
if (job.ServiceId != serviceId)
throw new SpmsException(ErrorCodes.JobNotFound, "존재하지 않는 작업 ID입니다.", 404);
if (job.Status == "completed" || job.Status == "cancelled" || job.Status == "failed")
throw new SpmsException(ErrorCodes.JobAlreadyCompleted, "이미 완료되었거나 취소된 작업입니다.", 400);
var cancelledCount = await _bulkJobStore.CancelAsync(request.JobId);
return new JobCancelResponseDto
{
JobId = request.JobId,
Status = "cancelled",
CancelledCount = cancelledCount
};
}
public async Task<byte[]> ExportLogAsync(long serviceId, PushLogExportRequestDto request)
{
if (!DateTime.TryParseExact(request.StartDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var startDate))
throw new SpmsException(ErrorCodes.BadRequest, "start_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400);
if (!DateTime.TryParseExact(request.EndDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var endDate))
throw new SpmsException(ErrorCodes.BadRequest, "end_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400);
if (startDate > endDate)
throw new SpmsException(ErrorCodes.BadRequest, "start_date가 end_date보다 클 수 없습니다.", 400);
if ((endDate - startDate).Days > 30)
throw new SpmsException(ErrorCodes.BadRequest, "조회 기간은 최대 30일입니다.", 400);
var endDateExclusive = endDate.AddDays(1);
long? messageId = null;
if (!string.IsNullOrWhiteSpace(request.MessageCode))
{
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId);
if (message != null)
messageId = message.Id;
}
PushResult? status = null;
if (!string.IsNullOrWhiteSpace(request.Status))
{
status = request.Status.ToLowerInvariant() switch
{
"success" or "sent" => PushResult.Success,
"failed" => PushResult.Failed,
_ => null
};
}
var logs = await _pushSendLogRepository.GetExportLogsAsync(
serviceId, startDate, endDateExclusive, messageId, request.DeviceId, status);
var sb = new StringBuilder();
sb.AppendLine("send_id,message_code,device_id,platform,status,fail_reason,sent_at");
foreach (var log in logs)
{
var msgCode = log.Message?.MessageCode ?? string.Empty;
var platform = log.Device?.Platform.ToString().ToLowerInvariant() ?? string.Empty;
var logStatus = log.Status == PushResult.Success ? "sent" : "failed";
var failReason = log.FailReason?.Replace(",", " ") ?? string.Empty;
sb.AppendLine($"{log.Id},{msgCode},{log.DeviceId},{platform},{logStatus},{failReason},{log.SentAt:yyyy-MM-ddTHH:mm:ss}");
}
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static async Task<List<CsvRow>> ParseCsvAsync(Stream stream)
{
var rows = new List<CsvRow>();
using var reader = new StreamReader(stream);
var headerLine = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(headerLine))
return rows;
var headers = headerLine.Split(',').Select(h => h.Trim()).ToArray();
var deviceIdIndex = Array.FindIndex(headers, h => h.Equals("device_id", StringComparison.OrdinalIgnoreCase));
if (deviceIdIndex < 0)
throw new SpmsException(ErrorCodes.BadRequest, "CSV 헤더에 device_id 컬럼이 필요합니다.", 400);
while (await reader.ReadLineAsync() is { } line)
{
if (string.IsNullOrWhiteSpace(line))
continue;
var values = line.Split(',').Select(v => v.Trim()).ToArray();
if (values.Length <= deviceIdIndex)
continue;
if (!long.TryParse(values[deviceIdIndex], out var deviceId))
continue;
var variables = new Dictionary<string, string>();
for (var i = 0; i < headers.Length && i < values.Length; i++)
{
if (i == deviceIdIndex) continue;
variables[headers[i]] = values[i];
}
rows.Add(new CsvRow { DeviceId = deviceId, Variables = variables });
}
return rows;
}
private class CsvRow
{
public long DeviceId { get; init; }
public Dictionary<string, string> Variables { get; init; } = new();
}
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);
}
}