feat: 상세 로그 다운로드 API 구현 (#140) #141
|
|
@ -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)
|
||||||
|
|
|
||||||
24
SPMS.Application/DTOs/Push/PushLogExportRequestDto.cs
Normal file
24
SPMS.Application/DTOs/Push/PushLogExportRequestDto.cs
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user