- POST /v1/in/stats/export (EXP-01) - 일별/시간대별/플랫폼별 통계를 엑셀(.xlsx) 3시트로 생성 - ClosedXML 패키지 추가 Closes #138
402 lines
15 KiB
C#
402 lines
15 KiB
C#
using System.Globalization;
|
|
using ClosedXML.Excel;
|
|
using SPMS.Application.DTOs.Stats;
|
|
using SPMS.Application.Interfaces;
|
|
using SPMS.Domain.Common;
|
|
using SPMS.Domain.Entities;
|
|
using SPMS.Domain.Enums;
|
|
using SPMS.Domain.Exceptions;
|
|
using SPMS.Domain.Interfaces;
|
|
|
|
namespace SPMS.Application.Services;
|
|
|
|
public class StatsService : IStatsService
|
|
{
|
|
private readonly IDailyStatRepository _dailyStatRepository;
|
|
private readonly IPushSendLogRepository _pushSendLogRepository;
|
|
private readonly IDeviceRepository _deviceRepository;
|
|
private readonly IMessageRepository _messageRepository;
|
|
|
|
public StatsService(
|
|
IDailyStatRepository dailyStatRepository,
|
|
IPushSendLogRepository pushSendLogRepository,
|
|
IDeviceRepository deviceRepository,
|
|
IMessageRepository messageRepository)
|
|
{
|
|
_dailyStatRepository = dailyStatRepository;
|
|
_pushSendLogRepository = pushSendLogRepository;
|
|
_deviceRepository = deviceRepository;
|
|
_messageRepository = messageRepository;
|
|
}
|
|
|
|
public async Task<DailyStatResponseDto> GetDailyAsync(long serviceId, DailyStatRequestDto request)
|
|
{
|
|
var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate);
|
|
|
|
var stats = await _dailyStatRepository.GetByDateRangeAsync(serviceId, startDate, endDate);
|
|
|
|
var items = stats.Select(s => new DailyStatItemDto
|
|
{
|
|
StatDate = s.StatDate.ToString("yyyy-MM-dd"),
|
|
SendCount = s.SentCnt,
|
|
SuccessCount = s.SuccessCnt,
|
|
FailCount = s.FailCnt,
|
|
OpenCount = s.OpenCnt,
|
|
Ctr = CalcCtr(s.OpenCnt, s.SuccessCnt)
|
|
}).ToList();
|
|
|
|
var totalSend = stats.Sum(s => s.SentCnt);
|
|
var totalSuccess = stats.Sum(s => s.SuccessCnt);
|
|
var totalFail = stats.Sum(s => s.FailCnt);
|
|
var totalOpen = stats.Sum(s => s.OpenCnt);
|
|
|
|
return new DailyStatResponseDto
|
|
{
|
|
Items = items,
|
|
Summary = new DailyStatSummaryDto
|
|
{
|
|
TotalSend = totalSend,
|
|
TotalSuccess = totalSuccess,
|
|
TotalFail = totalFail,
|
|
TotalOpen = totalOpen,
|
|
AvgCtr = CalcCtr(totalOpen, totalSuccess)
|
|
}
|
|
};
|
|
}
|
|
|
|
public async Task<SummaryStatResponseDto> GetSummaryAsync(long serviceId)
|
|
{
|
|
var totalDevices = await _deviceRepository.CountAsync(d => d.ServiceId == serviceId);
|
|
var activeDevices = await _deviceRepository.CountAsync(d => d.ServiceId == serviceId && d.IsActive);
|
|
var totalMessages = await _messageRepository.CountAsync(m => m.ServiceId == serviceId && !m.IsDeleted);
|
|
|
|
var allStats = await _dailyStatRepository.FindAsync(s => s.ServiceId == serviceId);
|
|
var totalSend = allStats.Sum(s => (long)s.SentCnt);
|
|
var totalSuccess = allStats.Sum(s => (long)s.SuccessCnt);
|
|
var totalOpen = allStats.Sum(s => (long)s.OpenCnt);
|
|
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
|
var todayStat = await _dailyStatRepository.GetByDateAsync(serviceId, today);
|
|
|
|
var monthStart = new DateOnly(today.Year, today.Month, 1);
|
|
var monthStats = await _dailyStatRepository.GetByDateRangeAsync(serviceId, monthStart, today);
|
|
|
|
return new SummaryStatResponseDto
|
|
{
|
|
TotalDevices = totalDevices,
|
|
ActiveDevices = activeDevices,
|
|
TotalMessages = totalMessages,
|
|
TotalSend = totalSend,
|
|
TotalSuccess = totalSuccess,
|
|
TotalOpen = totalOpen,
|
|
AvgCtr = totalSuccess > 0 ? Math.Round((double)totalOpen / totalSuccess * 100, 2) : 0,
|
|
Today = new PeriodStatDto
|
|
{
|
|
SendCount = todayStat?.SentCnt ?? 0,
|
|
SuccessCount = todayStat?.SuccessCnt ?? 0,
|
|
OpenCount = todayStat?.OpenCnt ?? 0,
|
|
Ctr = CalcCtr(todayStat?.OpenCnt ?? 0, todayStat?.SuccessCnt ?? 0)
|
|
},
|
|
ThisMonth = new PeriodStatDto
|
|
{
|
|
SendCount = monthStats.Sum(s => s.SentCnt),
|
|
SuccessCount = monthStats.Sum(s => s.SuccessCnt),
|
|
OpenCount = monthStats.Sum(s => s.OpenCnt),
|
|
Ctr = CalcCtr(monthStats.Sum(s => s.OpenCnt), monthStats.Sum(s => s.SuccessCnt))
|
|
}
|
|
};
|
|
}
|
|
|
|
public async Task<MessageStatResponseDto> GetMessageStatAsync(long serviceId, MessageStatRequestDto request)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.MessageCode))
|
|
throw new SpmsException(ErrorCodes.BadRequest, "message_code는 필수입니다.", 400);
|
|
|
|
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId);
|
|
if (message == null)
|
|
throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404);
|
|
|
|
DateTime? startDate = ParseOptionalDate(request.StartDate);
|
|
DateTime? endDate = ParseOptionalDate(request.EndDate);
|
|
|
|
var stats = await _pushSendLogRepository.GetMessageStatsAsync(serviceId, message.Id, startDate, endDate);
|
|
var dailyStats = await _pushSendLogRepository.GetMessageDailyStatsAsync(serviceId, message.Id, startDate, endDate);
|
|
|
|
return new MessageStatResponseDto
|
|
{
|
|
MessageCode = message.MessageCode,
|
|
Title = message.Title,
|
|
TotalSend = stats?.TotalSend ?? 0,
|
|
TotalSuccess = stats?.TotalSuccess ?? 0,
|
|
TotalFail = stats?.TotalFail ?? 0,
|
|
TotalOpen = 0,
|
|
Ctr = 0,
|
|
FirstSentAt = stats?.FirstSentAt,
|
|
LastSentAt = stats?.LastSentAt,
|
|
Daily = dailyStats.Select(d => new MessageDailyItemDto
|
|
{
|
|
StatDate = d.StatDate.ToString("yyyy-MM-dd"),
|
|
SendCount = d.SendCount,
|
|
OpenCount = 0,
|
|
Ctr = 0
|
|
}).ToList()
|
|
};
|
|
}
|
|
|
|
public async Task<HourlyStatResponseDto> GetHourlyAsync(long serviceId, HourlyStatRequestDto request)
|
|
{
|
|
var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate);
|
|
|
|
var startDateTime = startDate.ToDateTime(TimeOnly.MinValue);
|
|
var endDateTime = endDate.ToDateTime(TimeOnly.MinValue).AddDays(1);
|
|
|
|
var hourlyStats = await _pushSendLogRepository.GetHourlyStatsAsync(serviceId, startDateTime, endDateTime);
|
|
|
|
var items = Enumerable.Range(0, 24).Select(hour =>
|
|
{
|
|
var stat = hourlyStats.FirstOrDefault(h => h.Hour == hour);
|
|
return new HourlyStatItemDto
|
|
{
|
|
Hour = hour,
|
|
SendCount = stat?.SendCount ?? 0,
|
|
OpenCount = 0,
|
|
Ctr = 0
|
|
};
|
|
}).ToList();
|
|
|
|
var bestHours = items
|
|
.Where(i => i.SendCount > 0)
|
|
.OrderByDescending(i => i.SendCount)
|
|
.Take(3)
|
|
.Select(i => i.Hour)
|
|
.ToList();
|
|
|
|
return new HourlyStatResponseDto
|
|
{
|
|
Items = items,
|
|
BestHours = bestHours
|
|
};
|
|
}
|
|
|
|
public async Task<DeviceStatResponseDto> GetDeviceStatAsync(long serviceId)
|
|
{
|
|
var devices = await _deviceRepository.FindAsync(d => d.ServiceId == serviceId && d.IsActive);
|
|
var total = devices.Count;
|
|
|
|
var byPlatform = devices
|
|
.GroupBy(d => d.Platform)
|
|
.Select(g => new PlatformStatDto
|
|
{
|
|
Platform = g.Key.ToString().ToLowerInvariant(),
|
|
Count = g.Count(),
|
|
Ratio = total > 0 ? Math.Round((double)g.Count() / total * 100, 2) : 0
|
|
})
|
|
.OrderByDescending(p => p.Count)
|
|
.ToList();
|
|
|
|
var byPushAgreed = devices
|
|
.GroupBy(d => d.PushAgreed)
|
|
.Select(g => new PushAgreedStatDto
|
|
{
|
|
Agreed = g.Key,
|
|
Count = g.Count(),
|
|
Ratio = total > 0 ? Math.Round((double)g.Count() / total * 100, 2) : 0
|
|
})
|
|
.OrderByDescending(p => p.Count)
|
|
.ToList();
|
|
|
|
var tagCounts = new Dictionary<string, int>();
|
|
foreach (var device in devices)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(device.Tags)) continue;
|
|
var tags = device.Tags.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
foreach (var tag in tags)
|
|
{
|
|
tagCounts.TryGetValue(tag, out var count);
|
|
tagCounts[tag] = count + 1;
|
|
}
|
|
}
|
|
|
|
var byTag = tagCounts
|
|
.OrderByDescending(kv => kv.Value)
|
|
.Select((kv, index) => new TagStatDto
|
|
{
|
|
TagIndex = index,
|
|
TagName = kv.Key,
|
|
Count = kv.Value
|
|
})
|
|
.ToList();
|
|
|
|
return new DeviceStatResponseDto
|
|
{
|
|
Total = total,
|
|
ByPlatform = byPlatform,
|
|
ByPushAgreed = byPushAgreed,
|
|
ByTag = byTag
|
|
};
|
|
}
|
|
|
|
public async Task<SendLogDetailResponseDto> GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request)
|
|
{
|
|
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId);
|
|
if (message == null)
|
|
throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404);
|
|
|
|
PushResult? status = null;
|
|
if (!string.IsNullOrWhiteSpace(request.Status))
|
|
{
|
|
status = request.Status.ToLowerInvariant() switch
|
|
{
|
|
"success" => PushResult.Success,
|
|
"failed" => PushResult.Failed,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
Platform? platform = null;
|
|
if (!string.IsNullOrWhiteSpace(request.Platform))
|
|
{
|
|
platform = request.Platform.ToLowerInvariant() switch
|
|
{
|
|
"ios" => Domain.Enums.Platform.iOS,
|
|
"android" => Domain.Enums.Platform.Android,
|
|
"web" => Domain.Enums.Platform.Web,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
var (items, totalCount) = await _pushSendLogRepository.GetDetailLogPagedAsync(
|
|
serviceId, message.Id, request.Page, request.Size, status, platform);
|
|
|
|
var totalPages = (int)Math.Ceiling((double)totalCount / request.Size);
|
|
|
|
return new SendLogDetailResponseDto
|
|
{
|
|
Items = items.Select(l => new SendLogDetailItemDto
|
|
{
|
|
DeviceId = l.DeviceId,
|
|
DeviceToken = l.Device?.DeviceToken ?? string.Empty,
|
|
Platform = l.Device?.Platform.ToString().ToLowerInvariant() ?? string.Empty,
|
|
Status = l.Status.ToString().ToLowerInvariant(),
|
|
FailReason = l.FailReason,
|
|
SentAt = l.SentAt
|
|
}).ToList(),
|
|
Pagination = new DTOs.Notice.PaginationDto
|
|
{
|
|
Page = request.Page,
|
|
Size = request.Size,
|
|
TotalCount = totalCount,
|
|
TotalPages = totalPages
|
|
}
|
|
};
|
|
}
|
|
|
|
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)
|
|
{
|
|
return successCount > 0 ? Math.Round((double)openCount / successCount * 100, 2) : 0;
|
|
}
|
|
|
|
private static (DateOnly Start, DateOnly End) ParseDateRange(string startStr, string endStr)
|
|
{
|
|
if (!DateOnly.TryParseExact(startStr, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start))
|
|
throw new SpmsException(ErrorCodes.BadRequest, "start_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400);
|
|
|
|
if (!DateOnly.TryParseExact(endStr, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end))
|
|
throw new SpmsException(ErrorCodes.BadRequest, "end_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400);
|
|
|
|
if (start > end)
|
|
throw new SpmsException(ErrorCodes.BadRequest, "start_date가 end_date보다 클 수 없습니다.", 400);
|
|
|
|
return (start, end);
|
|
}
|
|
|
|
private static DateTime? ParseOptionalDate(string? dateStr)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(dateStr)) return null;
|
|
return DateTime.TryParseExact(dateStr, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
|
? date : null;
|
|
}
|
|
}
|