feat: 통계 API 구현 (8.1~8.5) (#132) #133
73
SPMS.API/Controllers/StatsController.cs
Normal file
73
SPMS.API/Controllers/StatsController.cs
Normal file
|
|
@ -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<IActionResult> GetDailyAsync([FromBody] DailyStatRequestDto request)
|
||||
{
|
||||
var serviceId = GetServiceId();
|
||||
var result = await _statsService.GetDailyAsync(serviceId, request);
|
||||
return Ok(ApiResponse<DailyStatResponseDto>.Success(result, "조회 성공"));
|
||||
}
|
||||
|
||||
[HttpPost("summary")]
|
||||
[SwaggerOperation(Summary = "요약 통계 조회", Description = "대시보드 요약 통계를 조회합니다.")]
|
||||
public async Task<IActionResult> GetSummaryAsync()
|
||||
{
|
||||
var serviceId = GetServiceId();
|
||||
var result = await _statsService.GetSummaryAsync(serviceId);
|
||||
return Ok(ApiResponse<SummaryStatResponseDto>.Success(result, "조회 성공"));
|
||||
}
|
||||
|
||||
[HttpPost("message")]
|
||||
[SwaggerOperation(Summary = "메시지별 통계 조회", Description = "특정 메시지의 발송 통계를 조회합니다.")]
|
||||
public async Task<IActionResult> GetMessageStatAsync([FromBody] MessageStatRequestDto request)
|
||||
{
|
||||
var serviceId = GetServiceId();
|
||||
var result = await _statsService.GetMessageStatAsync(serviceId, request);
|
||||
return Ok(ApiResponse<MessageStatResponseDto>.Success(result, "조회 성공"));
|
||||
}
|
||||
|
||||
[HttpPost("hourly")]
|
||||
[SwaggerOperation(Summary = "시간대별 통계 조회", Description = "시간대별 발송 추이를 조회합니다.")]
|
||||
public async Task<IActionResult> GetHourlyAsync([FromBody] HourlyStatRequestDto request)
|
||||
{
|
||||
var serviceId = GetServiceId();
|
||||
var result = await _statsService.GetHourlyAsync(serviceId, request);
|
||||
return Ok(ApiResponse<HourlyStatResponseDto>.Success(result, "조회 성공"));
|
||||
}
|
||||
|
||||
[HttpPost("device")]
|
||||
[SwaggerOperation(Summary = "디바이스 통계 조회", Description = "플랫폼/모델별 디바이스 분포를 조회합니다.")]
|
||||
public async Task<IActionResult> GetDeviceStatAsync()
|
||||
{
|
||||
var serviceId = GetServiceId();
|
||||
var result = await _statsService.GetDeviceStatAsync(serviceId);
|
||||
return Ok(ApiResponse<DeviceStatResponseDto>.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);
|
||||
}
|
||||
}
|
||||
12
SPMS.Application/DTOs/Stats/DailyStatRequestDto.cs
Normal file
12
SPMS.Application/DTOs/Stats/DailyStatRequestDto.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
51
SPMS.Application/DTOs/Stats/DailyStatResponseDto.cs
Normal file
51
SPMS.Application/DTOs/Stats/DailyStatResponseDto.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SPMS.Application.DTOs.Stats;
|
||||
|
||||
public class DailyStatResponseDto
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public List<DailyStatItemDto> 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; }
|
||||
}
|
||||
54
SPMS.Application/DTOs/Stats/DeviceStatResponseDto.cs
Normal file
54
SPMS.Application/DTOs/Stats/DeviceStatResponseDto.cs
Normal file
|
|
@ -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<PlatformStatDto> ByPlatform { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("by_push_agreed")]
|
||||
public List<PushAgreedStatDto> ByPushAgreed { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("by_tag")]
|
||||
public List<TagStatDto> 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; }
|
||||
}
|
||||
12
SPMS.Application/DTOs/Stats/HourlyStatRequestDto.cs
Normal file
12
SPMS.Application/DTOs/Stats/HourlyStatRequestDto.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
27
SPMS.Application/DTOs/Stats/HourlyStatResponseDto.cs
Normal file
27
SPMS.Application/DTOs/Stats/HourlyStatResponseDto.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SPMS.Application.DTOs.Stats;
|
||||
|
||||
public class HourlyStatResponseDto
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public List<HourlyStatItemDto> Items { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("best_hours")]
|
||||
public List<int> 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; }
|
||||
}
|
||||
15
SPMS.Application/DTOs/Stats/MessageStatRequestDto.cs
Normal file
15
SPMS.Application/DTOs/Stats/MessageStatRequestDto.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
51
SPMS.Application/DTOs/Stats/MessageStatResponseDto.cs
Normal file
51
SPMS.Application/DTOs/Stats/MessageStatResponseDto.cs
Normal file
|
|
@ -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<MessageDailyItemDto> 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; }
|
||||
}
|
||||
48
SPMS.Application/DTOs/Stats/SummaryStatResponseDto.cs
Normal file
48
SPMS.Application/DTOs/Stats/SummaryStatResponseDto.cs
Normal file
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ public static class DependencyInjection
|
|||
services.AddScoped<IPushService, PushService>();
|
||||
services.AddSingleton<IMessageValidationService, MessageValidationService>();
|
||||
services.AddScoped<IMessageService, MessageService>();
|
||||
services.AddScoped<IStatsService, StatsService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
|
|
|||
12
SPMS.Application/Interfaces/IStatsService.cs
Normal file
12
SPMS.Application/Interfaces/IStatsService.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using SPMS.Application.DTOs.Stats;
|
||||
|
||||
namespace SPMS.Application.Interfaces;
|
||||
|
||||
public interface IStatsService
|
||||
{
|
||||
Task<DailyStatResponseDto> GetDailyAsync(long serviceId, DailyStatRequestDto request);
|
||||
Task<SummaryStatResponseDto> GetSummaryAsync(long serviceId);
|
||||
Task<MessageStatResponseDto> GetMessageStatAsync(long serviceId, MessageStatRequestDto request);
|
||||
Task<HourlyStatResponseDto> GetHourlyAsync(long serviceId, HourlyStatRequestDto request);
|
||||
Task<DeviceStatResponseDto> GetDeviceStatAsync(long serviceId);
|
||||
}
|
||||
263
SPMS.Application/Services/StatsService.cs
Normal file
263
SPMS.Application/Services/StatsService.cs
Normal file
|
|
@ -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<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
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
9
SPMS.Domain/Interfaces/IDailyStatRepository.cs
Normal file
9
SPMS.Domain/Interfaces/IDailyStatRepository.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using SPMS.Domain.Entities;
|
||||
|
||||
namespace SPMS.Domain.Interfaces;
|
||||
|
||||
public interface IDailyStatRepository : IRepository<DailyStat>
|
||||
{
|
||||
Task<IReadOnlyList<DailyStat>> GetByDateRangeAsync(long serviceId, DateOnly startDate, DateOnly endDate);
|
||||
Task<DailyStat?> GetByDateAsync(long serviceId, DateOnly date);
|
||||
}
|
||||
|
|
@ -10,4 +10,32 @@ public interface IPushSendLogRepository : IRepository<PushSendLog>
|
|||
long? messageId = null, long? deviceId = null,
|
||||
PushResult? status = null,
|
||||
DateTime? startDate = null, DateTime? endDate = null);
|
||||
|
||||
Task<List<HourlyStatRaw>> GetHourlyStatsAsync(long serviceId, DateTime startDate, DateTime endDate);
|
||||
Task<MessageStatRaw?> GetMessageStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate);
|
||||
Task<List<MessageDailyStatRaw>> 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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ public static class DependencyInjection
|
|||
services.AddScoped<IFileRepository, FileRepository>();
|
||||
services.AddScoped<IMessageRepository, MessageRepository>();
|
||||
services.AddScoped<IPushSendLogRepository, PushSendLogRepository>();
|
||||
services.AddScoped<IDailyStatRepository, DailyStatRepository>();
|
||||
|
||||
// External Services
|
||||
services.AddScoped<IJwtService, JwtService>();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using SPMS.Domain.Entities;
|
||||
using SPMS.Domain.Interfaces;
|
||||
|
||||
namespace SPMS.Infrastructure.Persistence.Repositories;
|
||||
|
||||
public class DailyStatRepository : Repository<DailyStat>, IDailyStatRepository
|
||||
{
|
||||
public DailyStatRepository(AppDbContext context) : base(context) { }
|
||||
|
||||
public async Task<IReadOnlyList<DailyStat>> 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<DailyStat?> GetByDateAsync(long serviceId, DateOnly date)
|
||||
{
|
||||
return await _dbSet
|
||||
.FirstOrDefaultAsync(s => s.ServiceId == serviceId && s.StatDate == date);
|
||||
}
|
||||
}
|
||||
|
|
@ -44,4 +44,66 @@ public class PushSendLogRepository : Repository<PushSendLog>, IPushSendLogReposi
|
|||
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
public async Task<List<HourlyStatRaw>> 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<MessageStatRaw?> 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<List<MessageDailyStatRaw>> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user