From 3d8c57f690da6c3d4d510e6c5363351a95650e33 Mon Sep 17 00:00:00 2001 From: SEAN Date: Wed, 25 Feb 2026 16:39:56 +0900 Subject: [PATCH] =?UTF-8?q?improvement:=20=EC=9D=B4=EB=A0=A5=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /v1/in/stats/history/export 엔드포인트 추가 - history/list와 동일 필터(keyword/status/date) 기준 엑셀 내보내기 - PushSendLogRepository에서 GroupBy 쿼리를 private helper로 리팩토링 - ClosedXML로 엑셀 생성 (메시지코드/제목/서비스명/발송일시/대상수/성공/실패/오픈율/상태) Closes #191 --- SPMS.API/Controllers/StatsController.cs | 10 +++++ .../DTOs/Stats/HistoryExportRequestDto.cs | 18 ++++++++ SPMS.Application/Interfaces/IStatsService.cs | 1 + SPMS.Application/Services/StatsService.cs | 44 +++++++++++++++++++ .../Interfaces/IPushSendLogRepository.cs | 5 +++ .../Repositories/PushSendLogRepository.cs | 41 +++++++++++++---- 6 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 SPMS.Application/DTOs/Stats/HistoryExportRequestDto.cs diff --git a/SPMS.API/Controllers/StatsController.cs b/SPMS.API/Controllers/StatsController.cs index df8b5d8..2fc1957 100644 --- a/SPMS.API/Controllers/StatsController.cs +++ b/SPMS.API/Controllers/StatsController.cs @@ -109,6 +109,16 @@ public class StatsController : ControllerBase return Ok(ApiResponse.Success(result, "조회 성공")); } + [HttpPost("history/export")] + [SwaggerOperation(Summary = "이력 엑셀 내보내기", Description = "이력 목록과 동일한 필터 기준으로 발송 이력을 엑셀(.xlsx)로 내보냅니다. X-Service-Code 헤더 미지정 시 전체 서비스 이력을 내보냅니다.")] + public async Task 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")] [SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")] public async Task GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request) diff --git a/SPMS.Application/DTOs/Stats/HistoryExportRequestDto.cs b/SPMS.Application/DTOs/Stats/HistoryExportRequestDto.cs new file mode 100644 index 0000000..90079f2 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/HistoryExportRequestDto.cs @@ -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; } +} diff --git a/SPMS.Application/Interfaces/IStatsService.cs b/SPMS.Application/Interfaces/IStatsService.cs index c0c4e5f..df922b4 100644 --- a/SPMS.Application/Interfaces/IStatsService.cs +++ b/SPMS.Application/Interfaces/IStatsService.cs @@ -15,4 +15,5 @@ public interface IStatsService Task GetDashboardAsync(long? serviceId, DashboardRequestDto request); Task GetHistoryListAsync(long? serviceId, HistoryListRequestDto request); Task GetHistoryDetailAsync(long? serviceId, HistoryDetailRequestDto request); + Task ExportHistoryAsync(long? serviceId, HistoryExportRequestDto request); } diff --git a/SPMS.Application/Services/StatsService.cs b/SPMS.Application/Services/StatsService.cs index 6a75172..45e1741 100644 --- a/SPMS.Application/Services/StatsService.cs +++ b/SPMS.Application/Services/StatsService.cs @@ -546,6 +546,50 @@ public class StatsService : IStatsService }; } + public async Task 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) { return failReason switch diff --git a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs index 9f51168..3ec1f03 100644 --- a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs +++ b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs @@ -27,6 +27,11 @@ public interface IPushSendLogRepository : IRepository long? serviceId, int page, int size, string? keyword = null, string? status = null, DateTime? startDate = null, DateTime? endDate = null); + Task> GetMessageHistoryAllAsync( + long? serviceId, + string? keyword = null, string? status = null, + DateTime? startDate = null, DateTime? endDate = null, + int maxCount = 100000); } public class HourlyStatRaw diff --git a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs index 1bb9404..d0a7632 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs @@ -164,6 +164,37 @@ public class PushSendLogRepository : Repository, IPushSendLogReposi long? serviceId, int page, int size, string? keyword = null, string? status = 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> 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 BuildMessageHistoryQuery( + long? serviceId, string? keyword, string? status, + DateTime? startDate, DateTime? endDate) { var query = _dbSet .Include(l => l.Message) @@ -209,15 +240,7 @@ public class PushSendLogRepository : Repository, IPushSendLogReposi }; } - var totalCount = await grouped.CountAsync(); - - var items = await grouped - .OrderByDescending(g => g.FirstSentAt) - .Skip((page - 1) * size) - .Take(size) - .ToListAsync(); - - return (items, totalCount); + return grouped; } public async Task> GetFailureStatsByMessageAsync(long serviceId, long messageId, int limit)