From d3bd4356a8b5f8c485bcd0ce97be3f97e79d85c9 Mon Sep 17 00:00:00 2001 From: SEAN Date: Wed, 11 Feb 2026 09:38:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=B5=EA=B3=84=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#138)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /v1/in/stats/export (EXP-01) - 일별/시간대별/플랫폼별 통계를 엑셀(.xlsx) 3시트로 생성 - ClosedXML 패키지 추가 Closes #138 --- SPMS.API/Controllers/StatsController.cs | 10 +++ .../DTOs/Stats/StatsExportRequestDto.cs | 15 ++++ SPMS.Application/Interfaces/IStatsService.cs | 1 + SPMS.Application/SPMS.Application.csproj | 1 + SPMS.Application/Services/StatsService.cs | 83 +++++++++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 SPMS.Application/DTOs/Stats/StatsExportRequestDto.cs diff --git a/SPMS.API/Controllers/StatsController.cs b/SPMS.API/Controllers/StatsController.cs index 7a19f46..4334906 100644 --- a/SPMS.API/Controllers/StatsController.cs +++ b/SPMS.API/Controllers/StatsController.cs @@ -63,6 +63,16 @@ public class StatsController : ControllerBase return Ok(ApiResponse.Success(result, "조회 성공")); } + [HttpPost("export")] + [SwaggerOperation(Summary = "통계 리포트 다운로드", Description = "일별/시간대별/플랫폼별 통계를 엑셀(.xlsx) 파일로 다운로드합니다.")] + public async Task ExportReportAsync([FromBody] StatsExportRequestDto request) + { + var serviceId = GetServiceId(); + var fileBytes = await _statsService.ExportReportAsync(serviceId, request); + var fileName = $"stats_report_{request.StartDate}_{request.EndDate}.xlsx"; + return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); + } + [HttpPost("send-log")] [SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다.")] public async Task GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request) diff --git a/SPMS.Application/DTOs/Stats/StatsExportRequestDto.cs b/SPMS.Application/DTOs/Stats/StatsExportRequestDto.cs new file mode 100644 index 0000000..68e5b69 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/StatsExportRequestDto.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class StatsExportRequestDto +{ + [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/IStatsService.cs b/SPMS.Application/Interfaces/IStatsService.cs index 825c9af..709cf40 100644 --- a/SPMS.Application/Interfaces/IStatsService.cs +++ b/SPMS.Application/Interfaces/IStatsService.cs @@ -10,4 +10,5 @@ public interface IStatsService Task GetHourlyAsync(long serviceId, HourlyStatRequestDto request); Task GetDeviceStatAsync(long serviceId); Task GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request); + Task ExportReportAsync(long serviceId, StatsExportRequestDto request); } diff --git a/SPMS.Application/SPMS.Application.csproj b/SPMS.Application/SPMS.Application.csproj index 4030c29..4680c0c 100644 --- a/SPMS.Application/SPMS.Application.csproj +++ b/SPMS.Application/SPMS.Application.csproj @@ -13,6 +13,7 @@ + diff --git a/SPMS.Application/Services/StatsService.cs b/SPMS.Application/Services/StatsService.cs index 7859249..aeabe09 100644 --- a/SPMS.Application/Services/StatsService.cs +++ b/SPMS.Application/Services/StatsService.cs @@ -1,4 +1,5 @@ using System.Globalization; +using ClosedXML.Excel; using SPMS.Application.DTOs.Stats; using SPMS.Application.Interfaces; using SPMS.Domain.Common; @@ -290,6 +291,88 @@ public class StatsService : IStatsService }; } + public async Task ExportReportAsync(long serviceId, StatsExportRequestDto request) + { + var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); + + using var workbook = new XLWorkbook(); + + // Sheet 1: 일별 통계 + var dailyStats = await _dailyStatRepository.GetByDateRangeAsync(serviceId, startDate, endDate); + var ws1 = workbook.Worksheets.Add("일별 통계"); + ws1.Cell(1, 1).Value = "날짜"; + ws1.Cell(1, 2).Value = "발송"; + ws1.Cell(1, 3).Value = "성공"; + ws1.Cell(1, 4).Value = "실패"; + ws1.Cell(1, 5).Value = "열람"; + ws1.Cell(1, 6).Value = "CTR(%)"; + ws1.Row(1).Style.Font.Bold = true; + + var row = 2; + foreach (var s in dailyStats.OrderBy(s => s.StatDate)) + { + ws1.Cell(row, 1).Value = s.StatDate.ToString("yyyy-MM-dd"); + ws1.Cell(row, 2).Value = s.SentCnt; + ws1.Cell(row, 3).Value = s.SuccessCnt; + ws1.Cell(row, 4).Value = s.FailCnt; + ws1.Cell(row, 5).Value = s.OpenCnt; + ws1.Cell(row, 6).Value = CalcCtr(s.OpenCnt, s.SuccessCnt); + row++; + } + ws1.Columns().AdjustToContents(); + + // Sheet 2: 시간대별 통계 + var startDateTime = startDate.ToDateTime(TimeOnly.MinValue); + var endDateTime = endDate.ToDateTime(TimeOnly.MinValue).AddDays(1); + var hourlyStats = await _pushSendLogRepository.GetHourlyStatsAsync(serviceId, startDateTime, endDateTime); + + var ws2 = workbook.Worksheets.Add("시간대별 통계"); + ws2.Cell(1, 1).Value = "시간"; + ws2.Cell(1, 2).Value = "발송"; + ws2.Cell(1, 3).Value = "성공"; + ws2.Cell(1, 4).Value = "실패"; + ws2.Row(1).Style.Font.Bold = true; + + for (var hour = 0; hour < 24; hour++) + { + var stat = hourlyStats.FirstOrDefault(h => h.Hour == hour); + ws2.Cell(hour + 2, 1).Value = $"{hour:D2}:00"; + ws2.Cell(hour + 2, 2).Value = stat?.SendCount ?? 0; + ws2.Cell(hour + 2, 3).Value = stat?.SuccessCount ?? 0; + ws2.Cell(hour + 2, 4).Value = stat?.FailCount ?? 0; + } + ws2.Columns().AdjustToContents(); + + // Sheet 3: 플랫폼별 통계 + var devices = await _deviceRepository.FindAsync(d => d.ServiceId == serviceId && d.IsActive); + var total = devices.Count; + + var ws3 = workbook.Worksheets.Add("플랫폼별 통계"); + ws3.Cell(1, 1).Value = "플랫폼"; + ws3.Cell(1, 2).Value = "수량"; + ws3.Cell(1, 3).Value = "비율(%)"; + ws3.Row(1).Style.Font.Bold = true; + + var platformGroups = devices + .GroupBy(d => d.Platform) + .OrderByDescending(g => g.Count()) + .ToList(); + + row = 2; + foreach (var g in platformGroups) + { + ws3.Cell(row, 1).Value = g.Key.ToString(); + ws3.Cell(row, 2).Value = g.Count(); + ws3.Cell(row, 3).Value = total > 0 ? Math.Round((double)g.Count() / total * 100, 2) : 0; + row++; + } + ws3.Columns().AdjustToContents(); + + using var stream = new MemoryStream(); + workbook.SaveAs(stream); + return stream.ToArray(); + } + private static double CalcCtr(int openCount, int successCount) { return successCount > 0 ? Math.Round((double)openCount / successCount * 100, 2) : 0; -- 2.45.1