feat: 통계 리포트 다운로드 API 구현 (#138)
All checks were successful
SPMS_API/pipeline/head This commit looks good

Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/139
This commit is contained in:
김선규 2026-02-11 00:41:02 +00:00
commit 042e6e1dd6
5 changed files with 110 additions and 0 deletions

View File

@ -63,6 +63,16 @@ public class StatsController : ControllerBase
return Ok(ApiResponse<DeviceStatResponseDto>.Success(result, "조회 성공")); return Ok(ApiResponse<DeviceStatResponseDto>.Success(result, "조회 성공"));
} }
[HttpPost("export")]
[SwaggerOperation(Summary = "통계 리포트 다운로드", Description = "일별/시간대별/플랫폼별 통계를 엑셀(.xlsx) 파일로 다운로드합니다.")]
public async Task<IActionResult> 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")] [HttpPost("send-log")]
[SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다.")] [SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다.")]
public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request) public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request)

View File

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

View File

@ -10,4 +10,5 @@ public interface IStatsService
Task<HourlyStatResponseDto> GetHourlyAsync(long serviceId, HourlyStatRequestDto request); Task<HourlyStatResponseDto> GetHourlyAsync(long serviceId, HourlyStatRequestDto request);
Task<DeviceStatResponseDto> GetDeviceStatAsync(long serviceId); Task<DeviceStatResponseDto> GetDeviceStatAsync(long serviceId);
Task<SendLogDetailResponseDto> GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request); Task<SendLogDetailResponseDto> GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request);
Task<byte[]> ExportReportAsync(long serviceId, StatsExportRequestDto request);
} }

View File

@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.2" /> <PackageReference Include="Microsoft.Extensions.Options" Version="10.0.2" />
</ItemGroup> </ItemGroup>

View File

@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using ClosedXML.Excel;
using SPMS.Application.DTOs.Stats; using SPMS.Application.DTOs.Stats;
using SPMS.Application.Interfaces; using SPMS.Application.Interfaces;
using SPMS.Domain.Common; using SPMS.Domain.Common;
@ -290,6 +291,88 @@ public class StatsService : IStatsService
}; };
} }
public async Task<byte[]> 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) private static double CalcCtr(int openCount, int successCount)
{ {
return successCount > 0 ? Math.Round((double)openCount / successCount * 100, 2) : 0; return successCount > 0 ? Math.Round((double)openCount / successCount * 100, 2) : 0;