SPMS_API/SPMS.Application/Services/StatsService.cs
SEAN a3b2da5ffb improvement: 통계 서비스 범위 정책 고정 (#229)
- Stats 도메인 에러코드 추가 (171: DateRangeInvalid, 172: ServiceScopeInvalid)
- StatsService ParseDateRange에서 generic BadRequest → StatsDateRangeInvalid로 교체
- StatsController 전 엔드포인트 Swagger Description에 스코프 정책 안내 추가
- SpmsHeaderOperationFilter에서 message/list를 Optional로 반영 (미들웨어 정합)

Closes #229
2026-02-25 15:47:02 +09:00

464 lines
18 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 = 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<MessageStatResponseDto> 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<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 = 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<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 = 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<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 = 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<FailureStatResponseDto> 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;
}
}