improvement: 이력 엑셀 내보내기 API 추가 (#191)

- POST /v1/in/stats/history/export 엔드포인트 추가
- history/list와 동일 필터(keyword/status/date) 기준 엑셀 내보내기
- PushSendLogRepository에서 GroupBy 쿼리를 private helper로 리팩토링
- ClosedXML로 엑셀 생성 (메시지코드/제목/서비스명/발송일시/대상수/성공/실패/오픈율/상태)

Closes #191
This commit is contained in:
SEAN 2026-02-25 16:39:56 +09:00
parent 9350066fb4
commit 3d8c57f690
6 changed files with 110 additions and 9 deletions

View File

@ -109,6 +109,16 @@ public class StatsController : ControllerBase
return Ok(ApiResponse<HistoryDetailResponseDto>.Success(result, "조회 성공")); return Ok(ApiResponse<HistoryDetailResponseDto>.Success(result, "조회 성공"));
} }
[HttpPost("history/export")]
[SwaggerOperation(Summary = "이력 엑셀 내보내기", Description = "이력 목록과 동일한 필터 기준으로 발송 이력을 엑셀(.xlsx)로 내보냅니다. X-Service-Code 헤더 미지정 시 전체 서비스 이력을 내보냅니다.")]
public async Task<IActionResult> ExportHistoryAsync([FromBody] HistoryExportRequestDto request)
{
var serviceId = GetOptionalServiceId();
var fileBytes = await _statsService.ExportHistoryAsync(serviceId, request);
var fileName = $"history_export_{DateTime.UtcNow:yyyyMMdd}.xlsx";
return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
}
[HttpPost("send-log")] [HttpPost("send-log")]
[SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")] [SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request) public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request)

View File

@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Stats;
public class HistoryExportRequestDto
{
[JsonPropertyName("keyword")]
public string? Keyword { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; }
[JsonPropertyName("start_date")]
public string? StartDate { get; set; }
[JsonPropertyName("end_date")]
public string? EndDate { get; set; }
}

View File

@ -15,4 +15,5 @@ public interface IStatsService
Task<DashboardResponseDto> GetDashboardAsync(long? serviceId, DashboardRequestDto request); Task<DashboardResponseDto> GetDashboardAsync(long? serviceId, DashboardRequestDto request);
Task<HistoryListResponseDto> GetHistoryListAsync(long? serviceId, HistoryListRequestDto request); Task<HistoryListResponseDto> GetHistoryListAsync(long? serviceId, HistoryListRequestDto request);
Task<HistoryDetailResponseDto> GetHistoryDetailAsync(long? serviceId, HistoryDetailRequestDto request); Task<HistoryDetailResponseDto> GetHistoryDetailAsync(long? serviceId, HistoryDetailRequestDto request);
Task<byte[]> ExportHistoryAsync(long? serviceId, HistoryExportRequestDto request);
} }

View File

@ -546,6 +546,50 @@ public class StatsService : IStatsService
}; };
} }
public async Task<byte[]> ExportHistoryAsync(long? serviceId, HistoryExportRequestDto request)
{
DateTime? startDate = ParseOptionalDate(request.StartDate);
DateTime? endDate = ParseOptionalDate(request.EndDate);
var items = await _pushSendLogRepository.GetMessageHistoryAllAsync(
serviceId, request.Keyword, request.Status, startDate, endDate);
using var workbook = new XLWorkbook();
var ws = workbook.Worksheets.Add("발송 이력");
// 헤더
ws.Cell(1, 1).Value = "메시지코드";
ws.Cell(1, 2).Value = "제목";
ws.Cell(1, 3).Value = "서비스명";
ws.Cell(1, 4).Value = "발송일시";
ws.Cell(1, 5).Value = "대상수";
ws.Cell(1, 6).Value = "성공";
ws.Cell(1, 7).Value = "실패";
ws.Cell(1, 8).Value = "오픈율(%)";
ws.Cell(1, 9).Value = "상태";
ws.Row(1).Style.Font.Bold = true;
var row = 2;
foreach (var item in items)
{
ws.Cell(row, 1).Value = item.MessageCode;
ws.Cell(row, 2).Value = item.Title;
ws.Cell(row, 3).Value = item.ServiceName;
ws.Cell(row, 4).Value = item.FirstSentAt.ToString("yyyy-MM-dd HH:mm:ss");
ws.Cell(row, 5).Value = item.TotalSendCount;
ws.Cell(row, 6).Value = item.SuccessCount;
ws.Cell(row, 7).Value = item.FailCount;
ws.Cell(row, 8).Value = 0;
ws.Cell(row, 9).Value = SendStatus.Determine(item.TotalSendCount, item.SuccessCount);
row++;
}
ws.Columns().AdjustToContents();
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return stream.ToArray();
}
private static string GetFailReasonDescription(string failReason) private static string GetFailReasonDescription(string failReason)
{ {
return failReason switch return failReason switch

View File

@ -27,6 +27,11 @@ public interface IPushSendLogRepository : IRepository<PushSendLog>
long? serviceId, int page, int size, long? serviceId, int page, int size,
string? keyword = null, string? status = null, string? keyword = null, string? status = null,
DateTime? startDate = null, DateTime? endDate = null); DateTime? startDate = null, DateTime? endDate = null);
Task<IReadOnlyList<MessageHistorySummary>> GetMessageHistoryAllAsync(
long? serviceId,
string? keyword = null, string? status = null,
DateTime? startDate = null, DateTime? endDate = null,
int maxCount = 100000);
} }
public class HourlyStatRaw public class HourlyStatRaw

View File

@ -164,6 +164,37 @@ public class PushSendLogRepository : Repository<PushSendLog>, IPushSendLogReposi
long? serviceId, int page, int size, long? serviceId, int page, int size,
string? keyword = null, string? status = null, string? keyword = null, string? status = null,
DateTime? startDate = null, DateTime? endDate = null) DateTime? startDate = null, DateTime? endDate = null)
{
var grouped = BuildMessageHistoryQuery(serviceId, keyword, status, startDate, endDate);
var totalCount = await grouped.CountAsync();
var items = await grouped
.OrderByDescending(g => g.FirstSentAt)
.Skip((page - 1) * size)
.Take(size)
.ToListAsync();
return (items, totalCount);
}
public async Task<IReadOnlyList<MessageHistorySummary>> GetMessageHistoryAllAsync(
long? serviceId,
string? keyword = null, string? status = null,
DateTime? startDate = null, DateTime? endDate = null,
int maxCount = 100000)
{
var grouped = BuildMessageHistoryQuery(serviceId, keyword, status, startDate, endDate);
return await grouped
.OrderByDescending(g => g.FirstSentAt)
.Take(maxCount)
.ToListAsync();
}
private IQueryable<MessageHistorySummary> BuildMessageHistoryQuery(
long? serviceId, string? keyword, string? status,
DateTime? startDate, DateTime? endDate)
{ {
var query = _dbSet var query = _dbSet
.Include(l => l.Message) .Include(l => l.Message)
@ -209,15 +240,7 @@ public class PushSendLogRepository : Repository<PushSendLog>, IPushSendLogReposi
}; };
} }
var totalCount = await grouped.CountAsync(); return grouped;
var items = await grouped
.OrderByDescending(g => g.FirstSentAt)
.Skip((page - 1) * size)
.Take(size)
.ToListAsync();
return (items, totalCount);
} }
public async Task<List<FailureStatRaw>> GetFailureStatsByMessageAsync(long serviceId, long messageId, int limit) public async Task<List<FailureStatRaw>> GetFailureStatsByMessageAsync(long serviceId, long messageId, int limit)