feat: 실패원인 순위 API 구현 (#142) #143

Merged
seonkyu.kim merged 1 commits from feature/#142-failure-stats into develop 2026-02-11 00:54:19 +00:00
8 changed files with 145 additions and 5 deletions

View File

@ -73,6 +73,15 @@ public class StatsController : ControllerBase
return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); return File(fileBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName);
} }
[HttpPost("failure")]
[SwaggerOperation(Summary = "실패원인 통계 조회", Description = "실패 원인별 집계를 상위 N개로 조회합니다.")]
public async Task<IActionResult> GetFailureStatAsync([FromBody] FailureStatRequestDto request)
{
var serviceId = GetServiceId();
var result = await _statsService.GetFailureStatAsync(serviceId, request);
return Ok(ApiResponse<FailureStatResponseDto>.Success(result, "조회 성공"));
}
[HttpPost("send-log")] [HttpPost("send-log")]
[SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다.")] [SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다.")]
public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request) public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request)

View File

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

View File

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

View File

@ -11,4 +11,5 @@ public interface IStatsService
Task<DeviceStatResponseDto> GetDeviceStatAsync(long serviceId); Task<DeviceStatResponseDto> GetDeviceStatAsync(long serviceId);
Task<SendLogDetailResponseDto> GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request); Task<SendLogDetailResponseDto> GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request);
Task<byte[]> ExportReportAsync(long serviceId, StatsExportRequestDto request); Task<byte[]> ExportReportAsync(long serviceId, StatsExportRequestDto request);
Task<FailureStatResponseDto> GetFailureStatAsync(long serviceId, FailureStatRequestDto request);
} }

View File

@ -373,6 +373,52 @@ public class StatsService : IStatsService
return stream.ToArray(); return stream.ToArray();
} }
public async Task<FailureStatResponseDto> 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) private static double CalcCtr(int openCount, int successCount)
{ {
return successCount > 0 ? Math.Round((double)openCount / successCount * 100, 2) : 0; return successCount > 0 ? Math.Round((double)openCount / successCount * 100, 2) : 0;

View File

@ -21,6 +21,7 @@ public interface IPushSendLogRepository : IRepository<PushSendLog>
long serviceId, DateTime startDate, DateTime endDate, long serviceId, DateTime startDate, DateTime endDate,
long? messageId = null, long? deviceId = null, long? messageId = null, long? deviceId = null,
PushResult? status = null, int maxCount = 100000); PushResult? status = null, int maxCount = 100000);
Task<List<FailureStatRaw>> GetFailureStatsAsync(long serviceId, DateTime startDate, DateTime endDate, int limit);
} }
public class HourlyStatRaw public class HourlyStatRaw
@ -46,3 +47,9 @@ public class MessageDailyStatRaw
public int SendCount { get; set; } public int SendCount { get; set; }
public int SuccessCount { get; set; } public int SuccessCount { get; set; }
} }
public class FailureStatRaw
{
public string FailReason { get; set; } = string.Empty;
public int Count { get; set; }
}

View File

@ -156,4 +156,23 @@ public class PushSendLogRepository : Repository<PushSendLog>, IPushSendLogReposi
.OrderByDescending(d => d.StatDate) .OrderByDescending(d => d.StatDate)
.ToListAsync(); .ToListAsync();
} }
public async Task<List<FailureStatRaw>> 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();
}
} }

View File

@ -18,14 +18,14 @@
|--------|:------:|:---------:|:------:|-------| |--------|:------:|:---------:|:------:|-------|
| **Public** | 10 | 10 | 0 | Phase 2-2 ✅ | | **Public** | 10 | 10 | 0 | Phase 2-2 ✅ |
| **Auth** | 6 | 6 | 0 | Phase 2-1 ✅ | | **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 ✅ | | **Service** | 13 | 13 | 0 | Phase 2-1 ✅ |
| **Device** | 7 | 7 | 0 | Phase 2-2 ✅ | | **Device** | 7 | 7 | 0 | Phase 2-2 ✅ |
| **Message** | 5 | 1 | 4 | Phase 3 ← 다음 | | **Message** | 5 | 1 | 4 | Phase 3 ← 다음 |
| **Push** | 8 | 5 | 3 | Phase 3 | | **Push** | 8 | 5 | 3 | Phase 3 |
| **Stats** | 5 | 0 | 5 | Phase 3-2 | | **Stats** | 5 | 0 | 5 | Phase 3-2 |
| **File** | 6 | 6 | 0 | Phase 2-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 | ✅ | | 3 | [Feature] 플랫폼별 비중 조회 API | Feature | Medium | DSH-03 | ✅ |
| 4 | [Feature] 메시지별 전환율 조회 API | Feature | Medium | DSH-04 | ✅ | | 4 | [Feature] 메시지별 전환율 조회 API | Feature | Medium | DSH-04 | ✅ |
| 5 | [Feature] 발송 이력 조회 API | Feature | High | DDN-01 | ✅ | | 5 | [Feature] 발송 이력 조회 API | Feature | High | DDN-01 | ✅ |
| 6 | [Feature] 발송 상세 로그 조회 API | Feature | High | DDL-02 | | | 6 | [Feature] 발송 상세 로그 조회 API | Feature | High | DDL-02 | |
| 7 | [Feature] 통계 리포트 다운로드 API | Feature | Medium | EXP-01 | | | 7 | [Feature] 통계 리포트 다운로드 API | Feature | Medium | EXP-01 | |
| 8 | [Feature] 상세 로그 다운로드 API | Feature | Medium | EXP-02 | | | 8 | [Feature] 상세 로그 다운로드 API | Feature | Medium | EXP-02 | |
| 9 | [Feature] 실패원인 순위 API | Feature | Medium | ANA-01 | ⬜ | | 9 | [Feature] 실패원인 순위 API | Feature | Medium | ANA-01 | ⬜ |
| 10 | [Feature] 웹훅 설정 API | Feature | High | WHK-01 | ⬜ | | 10 | [Feature] 웹훅 설정 API | Feature | High | WHK-01 | ⬜ |
| 11 | [Feature] **웹훅 발송 서비스** | Feature | High | WHK-02 | ⬜ | | 11 | [Feature] **웹훅 발송 서비스** | Feature | High | WHK-02 | ⬜ |