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 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 GetSummaryAsync(long? serviceId) { var totalDevices = serviceId.HasValue ? await _deviceRepository.CountAsync(d => d.ServiceId == serviceId.Value) : await _deviceRepository.CountAsync(d => true); var activeDevices = serviceId.HasValue ? await _deviceRepository.CountAsync(d => d.ServiceId == serviceId.Value && d.IsActive) : await _deviceRepository.CountAsync(d => d.IsActive); var totalMessages = serviceId.HasValue ? await _messageRepository.CountAsync(m => m.ServiceId == serviceId.Value && !m.IsDeleted) : await _messageRepository.CountAsync(m => !m.IsDeleted); var allStats = serviceId.HasValue ? await _dailyStatRepository.FindAsync(s => s.ServiceId == serviceId.Value) : await _dailyStatRepository.FindAsync(s => true); 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 GetMessageStatAsync(long? serviceId, MessageStatRequestDto request) { if (string.IsNullOrWhiteSpace(request.MessageCode)) throw new SpmsException(ErrorCodes.BadRequest, "message_code는 필수입니다.", 400); var message = serviceId.HasValue ? await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId.Value) : await _messageRepository.GetByMessageCodeAsync(request.MessageCode); if (message == null) throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); DateTime? startDate = ParseOptionalDate(request.StartDate); DateTime? endDate = ParseOptionalDate(request.EndDate); var stats = await _pushSendLogRepository.GetMessageStatsAsync(message.ServiceId, message.Id, startDate, endDate); var dailyStats = await _pushSendLogRepository.GetMessageDailyStatsAsync(message.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 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 GetDeviceStatAsync(long? serviceId) { var devices = serviceId.HasValue ? await _deviceRepository.FindAsync(d => d.ServiceId == serviceId.Value && d.IsActive) : await _deviceRepository.FindAsync(d => 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(); 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 GetSendLogDetailAsync(long? serviceId, SendLogDetailRequestDto request) { var message = serviceId.HasValue ? await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId.Value) : await _messageRepository.GetByMessageCodeAsync(request.MessageCode); 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( message.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 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 = serviceId.HasValue ? await _deviceRepository.FindAsync(d => d.ServiceId == serviceId.Value && d.IsActive) : await _deviceRepository.FindAsync(d => 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(); } public async Task GetFailureStatAsync(long? serviceId, FailureStatRequestDto request) { var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); var startDateTime = startDate.ToDateTime(TimeOnly.MinValue); var endDateTime = endDate.ToDateTime(TimeOnly.MinValue).AddDays(1); var failureStats = await _pushSendLogRepository.GetFailureStatsAsync(serviceId, startDateTime, endDateTime, request.Limit); var totalFail = failureStats.Sum(f => f.Count); var items = failureStats.Select(f => new FailureStatItemDto { FailReason = f.FailReason, Count = f.Count, Ratio = totalFail > 0 ? Math.Round((double)f.Count / totalFail * 100, 2) : 0, Description = GetFailReasonDescription(f.FailReason) }).ToList(); return new FailureStatResponseDto { TotalFail = totalFail, Period = new FailureStatPeriodDto { StartDate = request.StartDate, EndDate = request.EndDate }, Items = items }; } private static string GetFailReasonDescription(string failReason) { return failReason switch { "InvalidToken" => "만료/잘못된 디바이스 토큰", "NotRegistered" => "FCM/APNs에 미등록", "MessageTooBig" => "페이로드 크기 초과 (4KB)", "RateLimit" => "FCM/APNs 발송 제한 초과", "ServerError" => "FCM/APNs 서버 오류", "NetworkError" => "네트워크 연결 실패", "Unknown" => "분류 불가", _ => failReason }; } 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.StatsDateRangeInvalid, "start_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400); if (!DateOnly.TryParseExact(endStr, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var end)) throw new SpmsException(ErrorCodes.StatsDateRangeInvalid, "end_date 형식이 올바르지 않습니다. (yyyy-MM-dd)", 400); if (start > end) throw new SpmsException(ErrorCodes.StatsDateRangeInvalid, "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; } }