diff --git a/SPMS.API/Controllers/StatsController.cs b/SPMS.API/Controllers/StatsController.cs new file mode 100644 index 0000000..532434b --- /dev/null +++ b/SPMS.API/Controllers/StatsController.cs @@ -0,0 +1,73 @@ +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using SPMS.Application.DTOs.Stats; +using SPMS.Application.Interfaces; +using SPMS.Domain.Common; + +namespace SPMS.API.Controllers; + +[ApiController] +[Route("v1/in/stats")] +[ApiExplorerSettings(GroupName = "stats")] +public class StatsController : ControllerBase +{ + private readonly IStatsService _statsService; + + public StatsController(IStatsService statsService) + { + _statsService = statsService; + } + + [HttpPost("daily")] + [SwaggerOperation(Summary = "일별 통계 조회", Description = "기간별 일별 발송/성공/실패/열람 통계를 조회합니다.")] + public async Task GetDailyAsync([FromBody] DailyStatRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _statsService.GetDailyAsync(serviceId, request); + return Ok(ApiResponse.Success(result, "조회 성공")); + } + + [HttpPost("summary")] + [SwaggerOperation(Summary = "요약 통계 조회", Description = "대시보드 요약 통계를 조회합니다.")] + public async Task GetSummaryAsync() + { + var serviceId = GetServiceId(); + var result = await _statsService.GetSummaryAsync(serviceId); + return Ok(ApiResponse.Success(result, "조회 성공")); + } + + [HttpPost("message")] + [SwaggerOperation(Summary = "메시지별 통계 조회", Description = "특정 메시지의 발송 통계를 조회합니다.")] + public async Task GetMessageStatAsync([FromBody] MessageStatRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _statsService.GetMessageStatAsync(serviceId, request); + return Ok(ApiResponse.Success(result, "조회 성공")); + } + + [HttpPost("hourly")] + [SwaggerOperation(Summary = "시간대별 통계 조회", Description = "시간대별 발송 추이를 조회합니다.")] + public async Task GetHourlyAsync([FromBody] HourlyStatRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _statsService.GetHourlyAsync(serviceId, request); + return Ok(ApiResponse.Success(result, "조회 성공")); + } + + [HttpPost("device")] + [SwaggerOperation(Summary = "디바이스 통계 조회", Description = "플랫폼/모델별 디바이스 분포를 조회합니다.")] + public async Task GetDeviceStatAsync() + { + var serviceId = GetServiceId(); + var result = await _statsService.GetDeviceStatAsync(serviceId); + return Ok(ApiResponse.Success(result, "조회 성공")); + } + + private long GetServiceId() + { + if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) + return serviceId; + + throw new Domain.Exceptions.SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400); + } +} diff --git a/SPMS.Application/DTOs/Stats/DailyStatRequestDto.cs b/SPMS.Application/DTOs/Stats/DailyStatRequestDto.cs new file mode 100644 index 0000000..20c8ce2 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/DailyStatRequestDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class DailyStatRequestDto +{ + [JsonPropertyName("start_date")] + public string StartDate { get; set; } = string.Empty; + + [JsonPropertyName("end_date")] + public string EndDate { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Stats/DailyStatResponseDto.cs b/SPMS.Application/DTOs/Stats/DailyStatResponseDto.cs new file mode 100644 index 0000000..e688fe2 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/DailyStatResponseDto.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class DailyStatResponseDto +{ + [JsonPropertyName("items")] + public List Items { get; set; } = []; + + [JsonPropertyName("summary")] + public DailyStatSummaryDto Summary { get; set; } = new(); +} + +public class DailyStatItemDto +{ + [JsonPropertyName("stat_date")] + public string StatDate { get; set; } = string.Empty; + + [JsonPropertyName("send_count")] + public int SendCount { get; set; } + + [JsonPropertyName("success_count")] + public int SuccessCount { get; set; } + + [JsonPropertyName("fail_count")] + public int FailCount { get; set; } + + [JsonPropertyName("open_count")] + public int OpenCount { get; set; } + + [JsonPropertyName("ctr")] + public double Ctr { get; set; } +} + +public class DailyStatSummaryDto +{ + [JsonPropertyName("total_send")] + public int TotalSend { get; set; } + + [JsonPropertyName("total_success")] + public int TotalSuccess { get; set; } + + [JsonPropertyName("total_fail")] + public int TotalFail { get; set; } + + [JsonPropertyName("total_open")] + public int TotalOpen { get; set; } + + [JsonPropertyName("avg_ctr")] + public double AvgCtr { get; set; } +} diff --git a/SPMS.Application/DTOs/Stats/DeviceStatResponseDto.cs b/SPMS.Application/DTOs/Stats/DeviceStatResponseDto.cs new file mode 100644 index 0000000..c771865 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/DeviceStatResponseDto.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class DeviceStatResponseDto +{ + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("by_platform")] + public List ByPlatform { get; set; } = []; + + [JsonPropertyName("by_push_agreed")] + public List ByPushAgreed { get; set; } = []; + + [JsonPropertyName("by_tag")] + public List ByTag { get; set; } = []; +} + +public class PlatformStatDto +{ + [JsonPropertyName("platform")] + public string Platform { get; set; } = string.Empty; + + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("ratio")] + public double Ratio { get; set; } +} + +public class PushAgreedStatDto +{ + [JsonPropertyName("agreed")] + public bool Agreed { get; set; } + + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("ratio")] + public double Ratio { get; set; } +} + +public class TagStatDto +{ + [JsonPropertyName("tag_index")] + public int TagIndex { get; set; } + + [JsonPropertyName("tag_name")] + public string TagName { get; set; } = string.Empty; + + [JsonPropertyName("count")] + public int Count { get; set; } +} diff --git a/SPMS.Application/DTOs/Stats/HourlyStatRequestDto.cs b/SPMS.Application/DTOs/Stats/HourlyStatRequestDto.cs new file mode 100644 index 0000000..84ccebb --- /dev/null +++ b/SPMS.Application/DTOs/Stats/HourlyStatRequestDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class HourlyStatRequestDto +{ + [JsonPropertyName("start_date")] + public string StartDate { get; set; } = string.Empty; + + [JsonPropertyName("end_date")] + public string EndDate { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Stats/HourlyStatResponseDto.cs b/SPMS.Application/DTOs/Stats/HourlyStatResponseDto.cs new file mode 100644 index 0000000..37678e4 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/HourlyStatResponseDto.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class HourlyStatResponseDto +{ + [JsonPropertyName("items")] + public List Items { get; set; } = []; + + [JsonPropertyName("best_hours")] + public List BestHours { get; set; } = []; +} + +public class HourlyStatItemDto +{ + [JsonPropertyName("hour")] + public int Hour { get; set; } + + [JsonPropertyName("send_count")] + public int SendCount { get; set; } + + [JsonPropertyName("open_count")] + public int OpenCount { get; set; } + + [JsonPropertyName("ctr")] + public double Ctr { get; set; } +} diff --git a/SPMS.Application/DTOs/Stats/MessageStatRequestDto.cs b/SPMS.Application/DTOs/Stats/MessageStatRequestDto.cs new file mode 100644 index 0000000..f0912ef --- /dev/null +++ b/SPMS.Application/DTOs/Stats/MessageStatRequestDto.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class MessageStatRequestDto +{ + [JsonPropertyName("message_code")] + public string MessageCode { get; set; } = string.Empty; + + [JsonPropertyName("start_date")] + public string? StartDate { get; set; } + + [JsonPropertyName("end_date")] + public string? EndDate { get; set; } +} diff --git a/SPMS.Application/DTOs/Stats/MessageStatResponseDto.cs b/SPMS.Application/DTOs/Stats/MessageStatResponseDto.cs new file mode 100644 index 0000000..6594e1b --- /dev/null +++ b/SPMS.Application/DTOs/Stats/MessageStatResponseDto.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class MessageStatResponseDto +{ + [JsonPropertyName("message_code")] + public string MessageCode { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("total_send")] + public int TotalSend { get; set; } + + [JsonPropertyName("total_success")] + public int TotalSuccess { get; set; } + + [JsonPropertyName("total_fail")] + public int TotalFail { get; set; } + + [JsonPropertyName("total_open")] + public int TotalOpen { get; set; } + + [JsonPropertyName("ctr")] + public double Ctr { get; set; } + + [JsonPropertyName("first_sent_at")] + public DateTime? FirstSentAt { get; set; } + + [JsonPropertyName("last_sent_at")] + public DateTime? LastSentAt { get; set; } + + [JsonPropertyName("daily")] + public List Daily { get; set; } = []; +} + +public class MessageDailyItemDto +{ + [JsonPropertyName("stat_date")] + public string StatDate { get; set; } = string.Empty; + + [JsonPropertyName("send_count")] + public int SendCount { get; set; } + + [JsonPropertyName("open_count")] + public int OpenCount { get; set; } + + [JsonPropertyName("ctr")] + public double Ctr { get; set; } +} diff --git a/SPMS.Application/DTOs/Stats/SummaryStatResponseDto.cs b/SPMS.Application/DTOs/Stats/SummaryStatResponseDto.cs new file mode 100644 index 0000000..a64a4fc --- /dev/null +++ b/SPMS.Application/DTOs/Stats/SummaryStatResponseDto.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class SummaryStatResponseDto +{ + [JsonPropertyName("total_devices")] + public int TotalDevices { get; set; } + + [JsonPropertyName("active_devices")] + public int ActiveDevices { get; set; } + + [JsonPropertyName("total_messages")] + public int TotalMessages { get; set; } + + [JsonPropertyName("total_send")] + public long TotalSend { get; set; } + + [JsonPropertyName("total_success")] + public long TotalSuccess { get; set; } + + [JsonPropertyName("total_open")] + public long TotalOpen { get; set; } + + [JsonPropertyName("avg_ctr")] + public double AvgCtr { get; set; } + + [JsonPropertyName("today")] + public PeriodStatDto Today { get; set; } = new(); + + [JsonPropertyName("this_month")] + public PeriodStatDto ThisMonth { get; set; } = new(); +} + +public class PeriodStatDto +{ + [JsonPropertyName("send_count")] + public int SendCount { get; set; } + + [JsonPropertyName("success_count")] + public int SuccessCount { get; set; } + + [JsonPropertyName("open_count")] + public int OpenCount { get; set; } + + [JsonPropertyName("ctr")] + public double Ctr { get; set; } +} diff --git a/SPMS.Application/DependencyInjection.cs b/SPMS.Application/DependencyInjection.cs index 4446192..775048a 100644 --- a/SPMS.Application/DependencyInjection.cs +++ b/SPMS.Application/DependencyInjection.cs @@ -21,6 +21,7 @@ public static class DependencyInjection services.AddScoped(); services.AddSingleton(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/SPMS.Application/Interfaces/IStatsService.cs b/SPMS.Application/Interfaces/IStatsService.cs new file mode 100644 index 0000000..7560229 --- /dev/null +++ b/SPMS.Application/Interfaces/IStatsService.cs @@ -0,0 +1,12 @@ +using SPMS.Application.DTOs.Stats; + +namespace SPMS.Application.Interfaces; + +public interface IStatsService +{ + Task GetDailyAsync(long serviceId, DailyStatRequestDto request); + Task GetSummaryAsync(long serviceId); + Task GetMessageStatAsync(long serviceId, MessageStatRequestDto request); + Task GetHourlyAsync(long serviceId, HourlyStatRequestDto request); + Task GetDeviceStatAsync(long serviceId); +} diff --git a/SPMS.Application/Services/StatsService.cs b/SPMS.Application/Services/StatsService.cs new file mode 100644 index 0000000..6915eb6 --- /dev/null +++ b/SPMS.Application/Services/StatsService.cs @@ -0,0 +1,263 @@ +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 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 = 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 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 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 = 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(); + 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 + }; + } + + 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; + } +} diff --git a/SPMS.Domain/Interfaces/IDailyStatRepository.cs b/SPMS.Domain/Interfaces/IDailyStatRepository.cs new file mode 100644 index 0000000..3e2b341 --- /dev/null +++ b/SPMS.Domain/Interfaces/IDailyStatRepository.cs @@ -0,0 +1,9 @@ +using SPMS.Domain.Entities; + +namespace SPMS.Domain.Interfaces; + +public interface IDailyStatRepository : IRepository +{ + Task> GetByDateRangeAsync(long serviceId, DateOnly startDate, DateOnly endDate); + Task GetByDateAsync(long serviceId, DateOnly date); +} diff --git a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs index 1574976..8eb4b3d 100644 --- a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs +++ b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs @@ -10,4 +10,32 @@ public interface IPushSendLogRepository : IRepository long? messageId = null, long? deviceId = null, PushResult? status = null, DateTime? startDate = null, DateTime? endDate = null); + + Task> GetHourlyStatsAsync(long serviceId, DateTime startDate, DateTime endDate); + Task GetMessageStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate); + Task> GetMessageDailyStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate); +} + +public class HourlyStatRaw +{ + public int Hour { get; set; } + public int SendCount { get; set; } + public int SuccessCount { get; set; } + public int FailCount { get; set; } +} + +public class MessageStatRaw +{ + public int TotalSend { get; set; } + public int TotalSuccess { get; set; } + public int TotalFail { get; set; } + public DateTime? FirstSentAt { get; set; } + public DateTime? LastSentAt { get; set; } +} + +public class MessageDailyStatRaw +{ + public DateOnly StatDate { get; set; } + public int SendCount { get; set; } + public int SuccessCount { get; set; } } diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 1a867c3..f0f665e 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -40,6 +40,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // External Services services.AddScoped(); diff --git a/SPMS.Infrastructure/Persistence/Repositories/DailyStatRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/DailyStatRepository.cs new file mode 100644 index 0000000..55dd2a7 --- /dev/null +++ b/SPMS.Infrastructure/Persistence/Repositories/DailyStatRepository.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using SPMS.Domain.Entities; +using SPMS.Domain.Interfaces; + +namespace SPMS.Infrastructure.Persistence.Repositories; + +public class DailyStatRepository : Repository, IDailyStatRepository +{ + public DailyStatRepository(AppDbContext context) : base(context) { } + + public async Task> GetByDateRangeAsync(long serviceId, DateOnly startDate, DateOnly endDate) + { + return await _dbSet + .Where(s => s.ServiceId == serviceId && s.StatDate >= startDate && s.StatDate <= endDate) + .OrderByDescending(s => s.StatDate) + .ToListAsync(); + } + + public async Task GetByDateAsync(long serviceId, DateOnly date) + { + return await _dbSet + .FirstOrDefaultAsync(s => s.ServiceId == serviceId && s.StatDate == date); + } +} diff --git a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs index a8d8fa5..30efa06 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs @@ -44,4 +44,66 @@ public class PushSendLogRepository : Repository, IPushSendLogReposi return (items, totalCount); } + + public async Task> GetHourlyStatsAsync(long serviceId, DateTime startDate, DateTime endDate) + { + return await _dbSet + .Where(l => l.ServiceId == serviceId && l.SentAt >= startDate && l.SentAt < endDate) + .GroupBy(l => l.SentAt.Hour) + .Select(g => new HourlyStatRaw + { + Hour = g.Key, + SendCount = g.Count(), + SuccessCount = g.Count(l => l.Status == PushResult.Success), + FailCount = g.Count(l => l.Status == PushResult.Failed) + }) + .OrderBy(h => h.Hour) + .ToListAsync(); + } + + public async Task GetMessageStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate) + { + var query = _dbSet.Where(l => l.ServiceId == serviceId && l.MessageId == messageId); + + if (startDate.HasValue) + query = query.Where(l => l.SentAt >= startDate.Value); + if (endDate.HasValue) + query = query.Where(l => l.SentAt < endDate.Value.AddDays(1)); + + var hasData = await query.AnyAsync(); + if (!hasData) return null; + + return await query + .GroupBy(_ => 1) + .Select(g => new MessageStatRaw + { + TotalSend = g.Count(), + TotalSuccess = g.Count(l => l.Status == PushResult.Success), + TotalFail = g.Count(l => l.Status == PushResult.Failed), + FirstSentAt = g.Min(l => l.SentAt), + LastSentAt = g.Max(l => l.SentAt) + }) + .FirstOrDefaultAsync(); + } + + public async Task> GetMessageDailyStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate) + { + var query = _dbSet.Where(l => l.ServiceId == serviceId && l.MessageId == messageId); + + if (startDate.HasValue) + query = query.Where(l => l.SentAt >= startDate.Value); + if (endDate.HasValue) + query = query.Where(l => l.SentAt < endDate.Value.AddDays(1)); + + return await query + .GroupBy(l => DateOnly.FromDateTime(l.SentAt)) + .Select(g => new MessageDailyStatRaw + { + StatDate = g.Key, + SendCount = g.Count(), + SuccessCount = g.Count(l => l.Status == PushResult.Success) + }) + .OrderByDescending(d => d.StatDate) + .ToListAsync(); + } }