diff --git a/SPMS.API/Controllers/StatsController.cs b/SPMS.API/Controllers/StatsController.cs index 4334906..724e29f 100644 --- a/SPMS.API/Controllers/StatsController.cs +++ b/SPMS.API/Controllers/StatsController.cs @@ -73,6 +73,15 @@ public class StatsController : ControllerBase return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); } + [HttpPost("failure")] + [SwaggerOperation(Summary = "실패원인 통계 조회", Description = "실패 원인별 집계를 상위 N개로 조회합니다.")] + public async Task GetFailureStatAsync([FromBody] FailureStatRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _statsService.GetFailureStatAsync(serviceId, request); + return Ok(ApiResponse.Success(result, "조회 성공")); + } + [HttpPost("send-log")] [SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다.")] public async Task GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request) diff --git a/SPMS.Application/DTOs/Stats/FailureStatRequestDto.cs b/SPMS.Application/DTOs/Stats/FailureStatRequestDto.cs new file mode 100644 index 0000000..9357971 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/FailureStatRequestDto.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class FailureStatRequestDto +{ + [Required] + [JsonPropertyName("start_date")] + public string StartDate { get; set; } = string.Empty; + + [Required] + [JsonPropertyName("end_date")] + public string EndDate { get; set; } = string.Empty; + + [JsonPropertyName("limit")] + [Range(1, 20)] + public int Limit { get; set; } = 5; +} diff --git a/SPMS.Application/DTOs/Stats/FailureStatResponseDto.cs b/SPMS.Application/DTOs/Stats/FailureStatResponseDto.cs new file mode 100644 index 0000000..4ee58d1 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/FailureStatResponseDto.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class FailureStatResponseDto +{ + [JsonPropertyName("total_fail")] + public int TotalFail { get; set; } + + [JsonPropertyName("period")] + public FailureStatPeriodDto Period { get; set; } = new(); + + [JsonPropertyName("items")] + public List Items { get; set; } = new(); +} + +public class FailureStatPeriodDto +{ + [JsonPropertyName("start_date")] + public string StartDate { get; set; } = string.Empty; + + [JsonPropertyName("end_date")] + public string EndDate { get; set; } = string.Empty; +} + +public class FailureStatItemDto +{ + [JsonPropertyName("fail_reason")] + public string FailReason { get; set; } = string.Empty; + + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("ratio")] + public double Ratio { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} diff --git a/SPMS.Application/Interfaces/IStatsService.cs b/SPMS.Application/Interfaces/IStatsService.cs index 709cf40..10a2877 100644 --- a/SPMS.Application/Interfaces/IStatsService.cs +++ b/SPMS.Application/Interfaces/IStatsService.cs @@ -11,4 +11,5 @@ public interface IStatsService Task GetDeviceStatAsync(long serviceId); Task GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request); Task ExportReportAsync(long serviceId, StatsExportRequestDto request); + Task GetFailureStatAsync(long serviceId, FailureStatRequestDto request); } diff --git a/SPMS.Application/Services/StatsService.cs b/SPMS.Application/Services/StatsService.cs index aeabe09..e194cae 100644 --- a/SPMS.Application/Services/StatsService.cs +++ b/SPMS.Application/Services/StatsService.cs @@ -373,6 +373,52 @@ public class StatsService : IStatsService return stream.ToArray(); } + public async Task GetFailureStatAsync(long serviceId, FailureStatRequestDto request) + { + var (startDate, endDate) = ParseDateRange(request.StartDate, request.EndDate); + + var startDateTime = startDate.ToDateTime(TimeOnly.MinValue); + var endDateTime = endDate.ToDateTime(TimeOnly.MinValue).AddDays(1); + + var failureStats = await _pushSendLogRepository.GetFailureStatsAsync(serviceId, startDateTime, endDateTime, request.Limit); + + var totalFail = failureStats.Sum(f => f.Count); + + var items = failureStats.Select(f => new FailureStatItemDto + { + FailReason = f.FailReason, + Count = f.Count, + Ratio = totalFail > 0 ? Math.Round((double)f.Count / totalFail * 100, 2) : 0, + Description = GetFailReasonDescription(f.FailReason) + }).ToList(); + + return new FailureStatResponseDto + { + TotalFail = totalFail, + Period = new FailureStatPeriodDto + { + StartDate = request.StartDate, + EndDate = request.EndDate + }, + Items = items + }; + } + + private static string GetFailReasonDescription(string failReason) + { + return failReason switch + { + "InvalidToken" => "만료/잘못된 디바이스 토큰", + "NotRegistered" => "FCM/APNs에 미등록", + "MessageTooBig" => "페이로드 크기 초과 (4KB)", + "RateLimit" => "FCM/APNs 발송 제한 초과", + "ServerError" => "FCM/APNs 서버 오류", + "NetworkError" => "네트워크 연결 실패", + "Unknown" => "분류 불가", + _ => failReason + }; + } + private static double CalcCtr(int openCount, int successCount) { return successCount > 0 ? Math.Round((double)openCount / successCount * 100, 2) : 0; diff --git a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs index a27cf2e..a5a98da 100644 --- a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs +++ b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs @@ -21,6 +21,7 @@ public interface IPushSendLogRepository : IRepository long serviceId, DateTime startDate, DateTime endDate, long? messageId = null, long? deviceId = null, PushResult? status = null, int maxCount = 100000); + Task> GetFailureStatsAsync(long serviceId, DateTime startDate, DateTime endDate, int limit); } public class HourlyStatRaw @@ -46,3 +47,9 @@ public class MessageDailyStatRaw public int SendCount { get; set; } public int SuccessCount { get; set; } } + +public class FailureStatRaw +{ + public string FailReason { get; set; } = string.Empty; + public int Count { get; set; } +} diff --git a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs index cc83d3f..7f4b4de 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs @@ -156,4 +156,23 @@ public class PushSendLogRepository : Repository, IPushSendLogReposi .OrderByDescending(d => d.StatDate) .ToListAsync(); } + + public async Task> GetFailureStatsAsync(long serviceId, DateTime startDate, DateTime endDate, int limit) + { + return await _dbSet + .Where(l => l.ServiceId == serviceId + && l.Status == PushResult.Failed + && l.SentAt >= startDate + && l.SentAt < endDate + && l.FailReason != null) + .GroupBy(l => l.FailReason!) + .Select(g => new FailureStatRaw + { + FailReason = g.Key, + Count = g.Count() + }) + .OrderByDescending(f => f.Count) + .Take(limit) + .ToListAsync(); + } } diff --git a/TASKS.md b/TASKS.md index 4cf8866..6698728 100644 --- a/TASKS.md +++ b/TASKS.md @@ -18,14 +18,14 @@ |--------|:------:|:---------:|:------:|-------| | **Public** | 10 | 10 | 0 | Phase 2-2 ✅ | | **Auth** | 6 | 6 | 0 | Phase 2-1 ✅ | -| **Account** | 5 | 5 | 0 | Phase 2-1 ✅ | +| **Account** | 9 | 9 | 0 | Phase 2-1 + 3-2 ✅ | | **Service** | 13 | 13 | 0 | Phase 2-1 ✅ | | **Device** | 7 | 7 | 0 | Phase 2-2 ✅ | | **Message** | 5 | 1 | 4 | Phase 3 ← 다음 | | **Push** | 8 | 5 | 3 | Phase 3 | | **Stats** | 5 | 0 | 5 | Phase 3-2 | | **File** | 6 | 6 | 0 | Phase 2-2 ✅ | -| **총계** | **65** | **53** | **12** | - | +| **총계** | **65** | **57** | **8** | - | --- @@ -1649,9 +1649,9 @@ Milestone: Phase 3: 메시지 & Push Core | 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 | ⬜ | +| 6 | [Feature] 발송 상세 로그 조회 API | Feature | High | DDL-02 | ✅ | +| 7 | [Feature] 통계 리포트 다운로드 API | Feature | Medium | EXP-01 | ✅ | +| 8 | [Feature] 상세 로그 다운로드 API | Feature | Medium | EXP-02 | ✅ | | 9 | [Feature] 실패원인 순위 API | Feature | Medium | ANA-01 | ⬜ | | 10 | [Feature] 웹훅 설정 API | Feature | High | WHK-01 | ⬜ | | 11 | [Feature] **웹훅 발송 서비스** | Feature | High | WHK-02 | ⬜ |