SPMS_API/SPMS.Application/Services/StatsService.cs
SEAN bcc40b4c01 feat: 발송 상세 로그 조회 API 구현 (#136)
- POST /v1/in/stats/send-log (DDL-02)
- 특정 메시지의 개별 디바이스별 발송 로그 조회
- 플랫폼(iOS/Android/Web), 성공/실패 필터 지원
- Device Include로 디바이스 토큰, 플랫폼 정보 포함

Closes #136
2026-02-11 09:29:03 +09:00

319 lines
12 KiB
C#

using System.Globalization;
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
}
};
}
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;
}
}