diff --git a/SPMS.API/Controllers/StatsController.cs b/SPMS.API/Controllers/StatsController.cs index 544d566..a0683fb 100644 --- a/SPMS.API/Controllers/StatsController.cs +++ b/SPMS.API/Controllers/StatsController.cs @@ -82,6 +82,15 @@ public class StatsController : ControllerBase return Ok(ApiResponse.Success(result, "조회 성공")); } + [HttpPost("dashboard")] + [SwaggerOperation(Summary = "대시보드 통합 조회", Description = "KPI, 일별 추이, 시간대별 분포, 플랫폼 비율, 상위 메시지를 한번에 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")] + public async Task GetDashboardAsync([FromBody] DashboardRequestDto request) + { + var serviceId = GetOptionalServiceId(); + var result = await _statsService.GetDashboardAsync(serviceId, request); + return Ok(ApiResponse.Success(result, "조회 성공")); + } + [HttpPost("send-log")] [SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")] public async Task GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request) diff --git a/SPMS.Application/DTOs/Stats/DashboardRequestDto.cs b/SPMS.Application/DTOs/Stats/DashboardRequestDto.cs new file mode 100644 index 0000000..aa2f47d --- /dev/null +++ b/SPMS.Application/DTOs/Stats/DashboardRequestDto.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class DashboardRequestDto +{ + [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/DashboardResponseDto.cs b/SPMS.Application/DTOs/Stats/DashboardResponseDto.cs new file mode 100644 index 0000000..0d72d5b --- /dev/null +++ b/SPMS.Application/DTOs/Stats/DashboardResponseDto.cs @@ -0,0 +1,72 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class DashboardResponseDto +{ + [JsonPropertyName("kpi")] + public DashboardKpiDto Kpi { get; set; } = new(); + + [JsonPropertyName("daily")] + public List Daily { get; set; } = []; + + [JsonPropertyName("hourly")] + public List Hourly { get; set; } = []; + + [JsonPropertyName("platform_share")] + public List PlatformShare { get; set; } = []; + + [JsonPropertyName("top_messages")] + public List TopMessages { get; set; } = []; +} + +public class DashboardKpiDto +{ + [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("active_service_count")] + public int ActiveServiceCount { get; set; } + + [JsonPropertyName("today")] + public PeriodStatDto Today { get; set; } = new(); + + [JsonPropertyName("this_month")] + public PeriodStatDto ThisMonth { get; set; } = new(); +} + +public class TopMessageDto +{ + [JsonPropertyName("message_code")] + public string MessageCode { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("service_name")] + public string ServiceName { get; set; } = string.Empty; + + [JsonPropertyName("total_send_count")] + public int TotalSendCount { get; set; } + + [JsonPropertyName("success_count")] + public int SuccessCount { get; set; } +} diff --git a/SPMS.Application/Interfaces/IStatsService.cs b/SPMS.Application/Interfaces/IStatsService.cs index c71eb42..1e58994 100644 --- a/SPMS.Application/Interfaces/IStatsService.cs +++ b/SPMS.Application/Interfaces/IStatsService.cs @@ -12,4 +12,5 @@ public interface IStatsService Task GetSendLogDetailAsync(long? serviceId, SendLogDetailRequestDto request); Task ExportReportAsync(long? serviceId, StatsExportRequestDto request); Task GetFailureStatAsync(long? serviceId, FailureStatRequestDto request); + Task GetDashboardAsync(long? serviceId, DashboardRequestDto request); } diff --git a/SPMS.Application/Services/StatsService.cs b/SPMS.Application/Services/StatsService.cs index 6fb144b..dd4c153 100644 --- a/SPMS.Application/Services/StatsService.cs +++ b/SPMS.Application/Services/StatsService.cs @@ -16,17 +16,20 @@ public class StatsService : IStatsService private readonly IPushSendLogRepository _pushSendLogRepository; private readonly IDeviceRepository _deviceRepository; private readonly IMessageRepository _messageRepository; + private readonly IServiceRepository _serviceRepository; public StatsService( IDailyStatRepository dailyStatRepository, IPushSendLogRepository pushSendLogRepository, IDeviceRepository deviceRepository, - IMessageRepository messageRepository) + IMessageRepository messageRepository, + IServiceRepository serviceRepository) { _dailyStatRepository = dailyStatRepository; _pushSendLogRepository = pushSendLogRepository; _deviceRepository = deviceRepository; _messageRepository = messageRepository; + _serviceRepository = serviceRepository; } public async Task GetDailyAsync(long? serviceId, DailyStatRequestDto request) @@ -420,6 +423,45 @@ public class StatsService : IStatsService }; } + public async Task GetDashboardAsync(long? serviceId, DashboardRequestDto request) + { + // EF Core DbContext는 thread-safe하지 않으므로 순차 실행 + var summary = await GetSummaryAsync(serviceId); + var daily = await GetDailyAsync(serviceId, new DailyStatRequestDto { StartDate = request.StartDate, EndDate = request.EndDate }); + var hourly = await GetHourlyAsync(serviceId, new HourlyStatRequestDto { StartDate = request.StartDate, EndDate = request.EndDate }); + var device = await GetDeviceStatAsync(serviceId); + var activeServiceCount = await _serviceRepository.CountAsync(s => s.Status == ServiceStatus.Active && !s.IsDeleted); + var topMessages = await _messageRepository.GetTopBySendCountAsync(serviceId, 5); + + return new DashboardResponseDto + { + Kpi = new DashboardKpiDto + { + TotalDevices = summary.TotalDevices, + ActiveDevices = summary.ActiveDevices, + TotalMessages = summary.TotalMessages, + TotalSend = summary.TotalSend, + TotalSuccess = summary.TotalSuccess, + TotalOpen = summary.TotalOpen, + AvgCtr = summary.AvgCtr, + ActiveServiceCount = activeServiceCount, + Today = summary.Today, + ThisMonth = summary.ThisMonth + }, + Daily = daily.Items, + Hourly = hourly.Items, + PlatformShare = device.ByPlatform, + TopMessages = topMessages.Select(m => new TopMessageDto + { + MessageCode = m.MessageCode, + Title = m.Title, + ServiceName = m.ServiceName, + TotalSendCount = m.TotalSendCount, + SuccessCount = m.SuccessCount + }).ToList() + }; + } + private static string GetFailReasonDescription(string failReason) { return failReason switch diff --git a/SPMS.Domain/Interfaces/IMessageRepository.cs b/SPMS.Domain/Interfaces/IMessageRepository.cs index a393b5d..1264b1e 100644 --- a/SPMS.Domain/Interfaces/IMessageRepository.cs +++ b/SPMS.Domain/Interfaces/IMessageRepository.cs @@ -16,6 +16,7 @@ public interface IMessageRepository : IRepository string? keyword = null, bool? isActive = null, string? sendStatus = null); Task GetByMessageCodeWithDetailsAsync(string messageCode, long serviceId); Task<(int TotalSendCount, int SuccessCount)> GetSendStatsAsync(long messageId); + Task> GetTopBySendCountAsync(long? serviceId, int count); } public class MessageListProjection diff --git a/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs index 04babc0..0b1d349 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/MessageRepository.cs @@ -77,6 +77,29 @@ public class MessageRepository : Repository, IMessageRepository return stats != null ? (stats.Total, stats.Success) : (0, 0); } + public async Task> GetTopBySendCountAsync(long? serviceId, int count) + { + var query = _dbSet.Where(m => !m.IsDeleted); + if (serviceId.HasValue) + query = query.Where(m => m.ServiceId == serviceId.Value); + + return await query.Select(m => new MessageListProjection + { + MessageCode = m.MessageCode, + Title = m.Title, + IsActive = !m.IsDeleted, + CreatedAt = m.CreatedAt, + ServiceName = m.Service.ServiceName, + ServiceCode = m.Service.ServiceCode, + TotalSendCount = _context.PushSendLogs.Count(l => l.MessageId == m.Id), + SuccessCount = _context.PushSendLogs.Count(l => l.MessageId == m.Id && l.Status == PushResult.Success) + }) + .Where(p => p.TotalSendCount > 0) + .OrderByDescending(p => p.TotalSendCount) + .Take(count) + .ToListAsync(); + } + public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedForListAsync( long? serviceId, int page, int size, string? keyword = null, bool? isActive = null, string? sendStatus = null)