feat: 통계 API 구현 (8.1~8.5) (#132)
- POST /v1/in/stats/daily: 기간별 일별 통계 - POST /v1/in/stats/summary: 대시보드 요약 통계 - POST /v1/in/stats/message: 메시지별 발송 통계 - POST /v1/in/stats/hourly: 시간대별 발송 추이 - POST /v1/in/stats/device: 디바이스 분포 통계 - IDailyStatRepository, DailyStatRepository 신규 - IPushSendLogRepository 통계 메서드 확장 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d940948df0
commit
0911fc763a
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.AddScoped<IPushService, PushService>();
|
||||||
services.AddSingleton<IMessageValidationService, MessageValidationService>();
|
services.AddSingleton<IMessageValidationService, MessageValidationService>();
|
||||||
services.AddScoped<IMessageService, MessageService>();
|
services.AddScoped<IMessageService, MessageService>();
|
||||||
|
services.AddScoped<IStatsService, StatsService>();
|
||||||
|
|
||||||
return services;
|
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,
|
long? messageId = null, long? deviceId = null,
|
||||||
PushResult? status = null,
|
PushResult? status = null,
|
||||||
DateTime? startDate = null, DateTime? endDate = 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<IFileRepository, FileRepository>();
|
||||||
services.AddScoped<IMessageRepository, MessageRepository>();
|
services.AddScoped<IMessageRepository, MessageRepository>();
|
||||||
services.AddScoped<IPushSendLogRepository, PushSendLogRepository>();
|
services.AddScoped<IPushSendLogRepository, PushSendLogRepository>();
|
||||||
|
services.AddScoped<IDailyStatRepository, DailyStatRepository>();
|
||||||
|
|
||||||
// External Services
|
// External Services
|
||||||
services.AddScoped<IJwtService, JwtService>();
|
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);
|
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