diff --git a/SPMS.API/Controllers/PushController.cs b/SPMS.API/Controllers/PushController.cs index 77ce4ea..e2c5fb4 100644 --- a/SPMS.API/Controllers/PushController.cs +++ b/SPMS.API/Controllers/PushController.cs @@ -63,6 +63,16 @@ public class PushController : ControllerBase return Ok(ApiResponse.Success(result)); } + [HttpPost("log/export")] + [SwaggerOperation(Summary = "발송 로그 내보내기", Description = "발송 로그를 CSV 파일로 다운로드합니다. 최대 30일, 100,000건.")] + public async Task ExportLogAsync([FromBody] PushLogExportRequestDto request) + { + var serviceId = GetServiceId(); + var fileBytes = await _pushService.ExportLogAsync(serviceId, request); + var fileName = $"push_log_{request.StartDate}_{request.EndDate}.csv"; + return File(fileBytes, "text/csv", fileName); + } + [HttpPost("send/bulk")] [SwaggerOperation(Summary = "대용량 발송", Description = "CSV 파일로 대량 푸시 발송을 요청합니다.")] public async Task SendBulkAsync(IFormFile file, [FromForm(Name = "message_code")] string messageCode) diff --git a/SPMS.Application/DTOs/Push/PushLogExportRequestDto.cs b/SPMS.Application/DTOs/Push/PushLogExportRequestDto.cs new file mode 100644 index 0000000..2331629 --- /dev/null +++ b/SPMS.Application/DTOs/Push/PushLogExportRequestDto.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Push; + +public class PushLogExportRequestDto +{ + [JsonPropertyName("message_code")] + public string? MessageCode { get; set; } + + [JsonPropertyName("device_id")] + public long? DeviceId { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [Required(ErrorMessage = "start_date는 필수입니다.")] + [JsonPropertyName("start_date")] + public string StartDate { get; set; } = string.Empty; + + [Required(ErrorMessage = "end_date는 필수입니다.")] + [JsonPropertyName("end_date")] + public string EndDate { get; set; } = string.Empty; +} diff --git a/SPMS.Application/Interfaces/IPushService.cs b/SPMS.Application/Interfaces/IPushService.cs index 226435d..b78fa02 100644 --- a/SPMS.Application/Interfaces/IPushService.cs +++ b/SPMS.Application/Interfaces/IPushService.cs @@ -12,4 +12,5 @@ public interface IPushService Task SendBulkAsync(long serviceId, Stream csvStream, string messageCode); Task GetJobStatusAsync(long serviceId, JobStatusRequestDto request); Task CancelJobAsync(long serviceId, JobCancelRequestDto request); + Task ExportLogAsync(long serviceId, PushLogExportRequestDto request); } diff --git a/SPMS.Application/Services/PushService.cs b/SPMS.Application/Services/PushService.cs index 315221a..20e69be 100644 --- a/SPMS.Application/Services/PushService.cs +++ b/SPMS.Application/Services/PushService.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.Json; using SPMS.Application.DTOs.Notice; using SPMS.Application.DTOs.Push; @@ -372,6 +373,60 @@ public class PushService : IPushService }; } + public async Task 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> ParseCsvAsync(Stream stream) { var rows = new List(); diff --git a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs index 8d09bb5..a27cf2e 100644 --- a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs +++ b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs @@ -17,6 +17,10 @@ public interface IPushSendLogRepository : IRepository Task<(IReadOnlyList Items, int TotalCount)> GetDetailLogPagedAsync( long serviceId, long messageId, int page, int size, PushResult? status = null, Platform? platform = null); + Task> GetExportLogsAsync( + long serviceId, DateTime startDate, DateTime endDate, + long? messageId = null, long? deviceId = null, + PushResult? status = null, int maxCount = 100000); } public class HourlyStatRaw diff --git a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs index 66b1873..cc83d3f 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs @@ -111,6 +111,31 @@ public class PushSendLogRepository : Repository, IPushSendLogReposi return (items, totalCount); } + public async Task> GetExportLogsAsync( + long serviceId, DateTime startDate, DateTime endDate, + long? messageId = null, long? deviceId = null, + PushResult? status = null, int maxCount = 100000) + { + var query = _dbSet + .Include(l => l.Message) + .Include(l => l.Device) + .Where(l => l.ServiceId == serviceId && l.SentAt >= startDate && l.SentAt < endDate); + + if (messageId.HasValue) + query = query.Where(l => l.MessageId == messageId.Value); + + if (deviceId.HasValue) + query = query.Where(l => l.DeviceId == deviceId.Value); + + if (status.HasValue) + query = query.Where(l => l.Status == status.Value); + + return await query + .OrderByDescending(l => l.SentAt) + .Take(maxCount) + .ToListAsync(); + } + public async Task> GetMessageDailyStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate) { var query = _dbSet.Where(l => l.ServiceId == serviceId && l.MessageId == messageId);