feat: 통계 API 구현 (8.1~8.5) (#132) #133

Merged
seonkyu.kim merged 2 commits from feature/#132-stats-api into develop 2026-02-10 14:11:28 +00:00
18 changed files with 748 additions and 5 deletions

View 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);
}
}

View 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;
}

View 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; }
}

View 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; }
}

View 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;
}

View 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; }
}

View 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; }
}

View 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; }
}

View 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; }
}

View File

@ -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;
}

View 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);
}

View 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;
}
}

View 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);
}

View File

@ -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; }
}

View File

@ -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>();

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -1644,11 +1644,11 @@ Milestone: Phase 3: 메시지 & Push Core
| # | 이슈 제목 | Type | Priority | 기능 ID | 상태 |
|---|-----------|------|----------|---------|------|
| 1 | [Feature] 대시보드 요약 조회 API | Feature | High | DSH-01 | |
| 2 | [Feature] 시간대별 발송 추이 조회 API | Feature | Medium | DSH-02 | |
| 3 | [Feature] 플랫폼별 비중 조회 API | Feature | Medium | DSH-03 | |
| 4 | [Feature] 메시지별 전환율 조회 API | Feature | Medium | DSH-04 | |
| 5 | [Feature] 발송 이력 조회 API | Feature | High | DDN-01 | |
| 1 | [Feature] 대시보드 요약 조회 API | Feature | High | DSH-01 | |
| 2 | [Feature] 시간대별 발송 추이 조회 API | Feature | Medium | DSH-02 | |
| 3 | [Feature] 플랫폼별 비중 조회 API | Feature | Medium | DSH-03 | |
| 4 | [Feature] 메시지별 전환율 조회 API | Feature | Medium | DSH-04 | |
| 5 | [Feature] 발송 이력 조회 API | Feature | High | DDN-01 | |
| 6 | [Feature] 발송 상세 로그 조회 API | Feature | High | DDL-02 | ⬜ |
| 7 | [Feature] 통계 리포트 다운로드 API | Feature | Medium | EXP-01 | ⬜ |
| 8 | [Feature] 상세 로그 다운로드 API | Feature | Medium | EXP-02 | ⬜ |