- ErrorCodes.ServiceScopeRequired("133") 추가
- SpmsException.Forbidden 팩토리 추가
- ServiceCodeMiddleware 3-카테고리 라우팅 (SKIP/REQUIRED/OPTIONAL_FOR_ADMIN)
- Swagger 필터 stats/device-list X-Service-Code optional 표시
- StatsController/DeviceController GetOptionalServiceId() 적용
- IStatsService/IDeviceService/레포지토리 시그니처 long? serviceId 변경
- StatsService/DeviceService null serviceId 전체 서비스 모드 처리
Closes #199
464 lines
18 KiB
C#
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.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;
|
|
}
|
|
}
|