feat: 상세 로그 다운로드 API 구현 (#140)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/141
This commit is contained in:
김선규 2026-02-11 00:47:04 +00:00
commit 519569ab72
6 changed files with 119 additions and 0 deletions

View File

@ -63,6 +63,16 @@ public class PushController : ControllerBase
return Ok(ApiResponse<PushLogResponseDto>.Success(result)); return Ok(ApiResponse<PushLogResponseDto>.Success(result));
} }
[HttpPost("log/export")]
[SwaggerOperation(Summary = "발송 로그 내보내기", Description = "발송 로그를 CSV 파일로 다운로드합니다. 최대 30일, 100,000건.")]
public async Task<IActionResult> 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")] [HttpPost("send/bulk")]
[SwaggerOperation(Summary = "대용량 발송", Description = "CSV 파일로 대량 푸시 발송을 요청합니다.")] [SwaggerOperation(Summary = "대용량 발송", Description = "CSV 파일로 대량 푸시 발송을 요청합니다.")]
public async Task<IActionResult> SendBulkAsync(IFormFile file, [FromForm(Name = "message_code")] string messageCode) public async Task<IActionResult> SendBulkAsync(IFormFile file, [FromForm(Name = "message_code")] string messageCode)

View File

@ -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;
}

View File

@ -12,4 +12,5 @@ public interface IPushService
Task<BulkSendResponseDto> SendBulkAsync(long serviceId, Stream csvStream, string messageCode); Task<BulkSendResponseDto> SendBulkAsync(long serviceId, Stream csvStream, string messageCode);
Task<JobStatusResponseDto> GetJobStatusAsync(long serviceId, JobStatusRequestDto request); Task<JobStatusResponseDto> GetJobStatusAsync(long serviceId, JobStatusRequestDto request);
Task<JobCancelResponseDto> CancelJobAsync(long serviceId, JobCancelRequestDto request); Task<JobCancelResponseDto> CancelJobAsync(long serviceId, JobCancelRequestDto request);
Task<byte[]> ExportLogAsync(long serviceId, PushLogExportRequestDto request);
} }

View File

@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using System.Text;
using System.Text.Json; using System.Text.Json;
using SPMS.Application.DTOs.Notice; using SPMS.Application.DTOs.Notice;
using SPMS.Application.DTOs.Push; using SPMS.Application.DTOs.Push;
@ -372,6 +373,60 @@ public class PushService : IPushService
}; };
} }
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) private static async Task<List<CsvRow>> ParseCsvAsync(Stream stream)
{ {
var rows = new List<CsvRow>(); var rows = new List<CsvRow>();

View File

@ -17,6 +17,10 @@ public interface IPushSendLogRepository : IRepository<PushSendLog>
Task<(IReadOnlyList<PushSendLog> Items, int TotalCount)> GetDetailLogPagedAsync( Task<(IReadOnlyList<PushSendLog> Items, int TotalCount)> GetDetailLogPagedAsync(
long serviceId, long messageId, int page, int size, long serviceId, long messageId, int page, int size,
PushResult? status = null, Platform? platform = null); PushResult? status = null, Platform? platform = null);
Task<IReadOnlyList<PushSendLog>> GetExportLogsAsync(
long serviceId, DateTime startDate, DateTime endDate,
long? messageId = null, long? deviceId = null,
PushResult? status = null, int maxCount = 100000);
} }
public class HourlyStatRaw public class HourlyStatRaw

View File

@ -111,6 +111,31 @@ public class PushSendLogRepository : Repository<PushSendLog>, IPushSendLogReposi
return (items, totalCount); return (items, totalCount);
} }
public async Task<IReadOnlyList<PushSendLog>> 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<List<MessageDailyStatRaw>> GetMessageDailyStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate) public async Task<List<MessageDailyStatRaw>> GetMessageDailyStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate)
{ {
var query = _dbSet.Where(l => l.ServiceId == serviceId && l.MessageId == messageId); var query = _dbSet.Where(l => l.ServiceId == serviceId && l.MessageId == messageId);