From 347c9aa4bfef782bd5896f5918f5cc1b9cd00786 Mon Sep 17 00:00:00 2001 From: SEAN Date: Wed, 25 Feb 2026 16:23:11 +0900 Subject: [PATCH] =?UTF-8?q?improvement:=20=EC=9D=B4=EB=A0=A5=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D/=EC=83=81=EC=84=B8=20API=20=EC=B6=94=EA=B0=80=20(#233?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /v1/in/stats/history/list: 메시지별 발송 이력 목록 조회 (keyword/status/date 필터, 페이지네이션) - POST /v1/in/stats/history/detail: 특정 메시지 상세 이력 조회 (기본정보+집계+실패사유 Top 5+본문) - SendStatus.Determine() 규칙 재사용 Closes #233 --- SPMS.API/Controllers/StatsController.cs | 18 ++++ .../DTOs/Stats/HistoryDetailRequestDto.cs | 11 +++ .../DTOs/Stats/HistoryDetailResponseDto.cs | 60 +++++++++++++ .../DTOs/Stats/HistoryListRequestDto.cs | 24 ++++++ .../DTOs/Stats/HistoryListResponseDto.cs | 43 ++++++++++ SPMS.Application/Interfaces/IStatsService.cs | 2 + SPMS.Application/Services/StatsService.cs | 84 +++++++++++++++++++ .../Interfaces/IPushSendLogRepository.cs | 17 ++++ .../Repositories/PushSendLogRepository.cs | 76 +++++++++++++++++ 9 files changed, 335 insertions(+) create mode 100644 SPMS.Application/DTOs/Stats/HistoryDetailRequestDto.cs create mode 100644 SPMS.Application/DTOs/Stats/HistoryDetailResponseDto.cs create mode 100644 SPMS.Application/DTOs/Stats/HistoryListRequestDto.cs create mode 100644 SPMS.Application/DTOs/Stats/HistoryListResponseDto.cs diff --git a/SPMS.API/Controllers/StatsController.cs b/SPMS.API/Controllers/StatsController.cs index a0683fb..df8b5d8 100644 --- a/SPMS.API/Controllers/StatsController.cs +++ b/SPMS.API/Controllers/StatsController.cs @@ -91,6 +91,24 @@ public class StatsController : ControllerBase return Ok(ApiResponse.Success(result, "조회 성공")); } + [HttpPost("history/list")] + [SwaggerOperation(Summary = "이력 목록 조회", Description = "메시지별 발송 이력 목록을 조회합니다. keyword/status/date 필터를 지원합니다. X-Service-Code 헤더 미지정 시 전체 서비스 이력을 조회합니다.")] + public async Task GetHistoryListAsync([FromBody] HistoryListRequestDto request) + { + var serviceId = GetOptionalServiceId(); + var result = await _statsService.GetHistoryListAsync(serviceId, request); + return Ok(ApiResponse.Success(result, "조회 성공")); + } + + [HttpPost("history/detail")] + [SwaggerOperation(Summary = "이력 상세 조회", Description = "특정 메시지의 발송 이력 상세(기본정보+집계+실패사유+본문)를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스에서 검색합니다.")] + public async Task GetHistoryDetailAsync([FromBody] HistoryDetailRequestDto request) + { + var serviceId = GetOptionalServiceId(); + var result = await _statsService.GetHistoryDetailAsync(serviceId, request); + return Ok(ApiResponse.Success(result, "조회 성공")); + } + [HttpPost("send-log")] [SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")] public async Task GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request) diff --git a/SPMS.Application/DTOs/Stats/HistoryDetailRequestDto.cs b/SPMS.Application/DTOs/Stats/HistoryDetailRequestDto.cs new file mode 100644 index 0000000..728263e --- /dev/null +++ b/SPMS.Application/DTOs/Stats/HistoryDetailRequestDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class HistoryDetailRequestDto +{ + [Required] + [JsonPropertyName("message_code")] + public string MessageCode { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Stats/HistoryDetailResponseDto.cs b/SPMS.Application/DTOs/Stats/HistoryDetailResponseDto.cs new file mode 100644 index 0000000..233d067 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/HistoryDetailResponseDto.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class HistoryDetailResponseDto +{ + [JsonPropertyName("message_code")] + public string MessageCode { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("body")] + public string Body { get; set; } = string.Empty; + + [JsonPropertyName("service_name")] + public string ServiceName { get; set; } = string.Empty; + + [JsonPropertyName("first_sent_at")] + public DateTime? FirstSentAt { get; set; } + + [JsonPropertyName("last_sent_at")] + public DateTime? LastSentAt { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("target_count")] + public int TargetCount { get; set; } + + [JsonPropertyName("success_count")] + public int SuccessCount { get; set; } + + [JsonPropertyName("fail_count")] + public int FailCount { get; set; } + + [JsonPropertyName("success_rate")] + public double SuccessRate { get; set; } + + [JsonPropertyName("open_count")] + public int OpenCount { get; set; } + + [JsonPropertyName("open_rate")] + public double OpenRate { get; set; } + + [JsonPropertyName("fail_reasons")] + public List FailReasons { get; set; } = []; +} + +public class HistoryFailReasonDto +{ + [JsonPropertyName("reason")] + public string Reason { get; set; } = string.Empty; + + [JsonPropertyName("count")] + public int Count { get; set; } + + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; +} diff --git a/SPMS.Application/DTOs/Stats/HistoryListRequestDto.cs b/SPMS.Application/DTOs/Stats/HistoryListRequestDto.cs new file mode 100644 index 0000000..53b4dc0 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/HistoryListRequestDto.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class HistoryListRequestDto +{ + [JsonPropertyName("page")] + public int Page { get; set; } = 1; + + [JsonPropertyName("size")] + public int Size { get; set; } = 10; + + [JsonPropertyName("keyword")] + public string? Keyword { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("start_date")] + public string? StartDate { get; set; } + + [JsonPropertyName("end_date")] + public string? EndDate { get; set; } +} diff --git a/SPMS.Application/DTOs/Stats/HistoryListResponseDto.cs b/SPMS.Application/DTOs/Stats/HistoryListResponseDto.cs new file mode 100644 index 0000000..592f5d8 --- /dev/null +++ b/SPMS.Application/DTOs/Stats/HistoryListResponseDto.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; +using SPMS.Application.DTOs.Notice; + +namespace SPMS.Application.DTOs.Stats; + +public class HistoryListResponseDto +{ + [JsonPropertyName("items")] + public List Items { get; set; } = []; + + [JsonPropertyName("pagination")] + public PaginationDto Pagination { get; set; } = new(); +} + +public class HistoryListItemDto +{ + [JsonPropertyName("message_code")] + public string MessageCode { get; set; } = string.Empty; + + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("service_name")] + public string ServiceName { get; set; } = string.Empty; + + [JsonPropertyName("sent_at")] + public DateTime? SentAt { get; set; } + + [JsonPropertyName("target_count")] + public int TargetCount { get; set; } + + [JsonPropertyName("success_count")] + public int SuccessCount { get; set; } + + [JsonPropertyName("fail_count")] + public int FailCount { get; set; } + + [JsonPropertyName("open_rate")] + public double OpenRate { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; +} diff --git a/SPMS.Application/Interfaces/IStatsService.cs b/SPMS.Application/Interfaces/IStatsService.cs index 1e58994..c0c4e5f 100644 --- a/SPMS.Application/Interfaces/IStatsService.cs +++ b/SPMS.Application/Interfaces/IStatsService.cs @@ -13,4 +13,6 @@ public interface IStatsService Task ExportReportAsync(long? serviceId, StatsExportRequestDto request); Task GetFailureStatAsync(long? serviceId, FailureStatRequestDto request); Task GetDashboardAsync(long? serviceId, DashboardRequestDto request); + Task GetHistoryListAsync(long? serviceId, HistoryListRequestDto request); + Task GetHistoryDetailAsync(long? serviceId, HistoryDetailRequestDto request); } diff --git a/SPMS.Application/Services/StatsService.cs b/SPMS.Application/Services/StatsService.cs index dd4c153..6a75172 100644 --- a/SPMS.Application/Services/StatsService.cs +++ b/SPMS.Application/Services/StatsService.cs @@ -462,6 +462,90 @@ public class StatsService : IStatsService }; } + public async Task GetHistoryListAsync(long? serviceId, HistoryListRequestDto request) + { + DateTime? startDate = ParseOptionalDate(request.StartDate); + DateTime? endDate = ParseOptionalDate(request.EndDate); + + var (items, totalCount) = await _pushSendLogRepository.GetMessageHistoryPagedAsync( + serviceId, request.Page, request.Size, + request.Keyword, request.Status, + startDate, endDate); + + var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); + + return new HistoryListResponseDto + { + Items = items.Select(i => new HistoryListItemDto + { + MessageCode = i.MessageCode, + Title = i.Title, + ServiceName = i.ServiceName, + SentAt = i.FirstSentAt, + TargetCount = i.TotalSendCount, + SuccessCount = i.SuccessCount, + FailCount = i.FailCount, + OpenRate = 0, + Status = SendStatus.Determine(i.TotalSendCount, i.SuccessCount) + }).ToList(), + Pagination = new DTOs.Notice.PaginationDto + { + Page = request.Page, + Size = request.Size, + TotalCount = totalCount, + TotalPages = totalPages + } + }; + } + + public async Task GetHistoryDetailAsync(long? serviceId, HistoryDetailRequestDto request) + { + if (string.IsNullOrWhiteSpace(request.MessageCode)) + throw new SpmsException(ErrorCodes.BadRequest, "message_code는 필수입니다.", 400); + + var message = serviceId.HasValue + ? await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId.Value) + : await _messageRepository.GetByMessageCodeAsync(request.MessageCode); + if (message == null) + throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); + + // 집계 + var stats = await _pushSendLogRepository.GetMessageStatsAsync(message.ServiceId, message.Id, null, null); + var totalSend = stats?.TotalSend ?? 0; + var totalSuccess = stats?.TotalSuccess ?? 0; + var totalFail = stats?.TotalFail ?? 0; + var successRate = totalSend > 0 ? Math.Round((double)totalSuccess / totalSend * 100, 2) : 0; + + // 실패사유 Top 5 + var failureStats = await _pushSendLogRepository.GetFailureStatsByMessageAsync(message.ServiceId, message.Id, 5); + + // 서비스명 + var service = await _serviceRepository.GetByIdAsync(message.ServiceId); + + return new HistoryDetailResponseDto + { + MessageCode = message.MessageCode, + Title = message.Title, + Body = message.Body, + ServiceName = service?.ServiceName ?? string.Empty, + FirstSentAt = stats?.FirstSentAt, + LastSentAt = stats?.LastSentAt, + Status = SendStatus.Determine(totalSend, totalSuccess), + TargetCount = totalSend, + SuccessCount = totalSuccess, + FailCount = totalFail, + SuccessRate = successRate, + OpenCount = 0, + OpenRate = 0, + FailReasons = failureStats.Select(f => new HistoryFailReasonDto + { + Reason = f.FailReason, + Count = f.Count, + Description = GetFailReasonDescription(f.FailReason) + }).ToList() + }; + } + private static string GetFailReasonDescription(string failReason) { return failReason switch diff --git a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs index 4fc99f4..9f51168 100644 --- a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs +++ b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs @@ -22,6 +22,11 @@ public interface IPushSendLogRepository : IRepository long? messageId = null, long? deviceId = null, PushResult? status = null, int maxCount = 100000); Task> GetFailureStatsAsync(long? serviceId, DateTime startDate, DateTime endDate, int limit); + Task> GetFailureStatsByMessageAsync(long serviceId, long messageId, int limit); + Task<(IReadOnlyList Items, int TotalCount)> GetMessageHistoryPagedAsync( + long? serviceId, int page, int size, + string? keyword = null, string? status = null, + DateTime? startDate = null, DateTime? endDate = null); } public class HourlyStatRaw @@ -53,3 +58,15 @@ public class FailureStatRaw public string FailReason { get; set; } = string.Empty; public int Count { get; set; } } + +public class MessageHistorySummary +{ + public long MessageId { get; set; } + public string MessageCode { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string ServiceName { get; set; } = string.Empty; + public DateTime FirstSentAt { get; set; } + public int TotalSendCount { get; set; } + public int SuccessCount { get; set; } + public int FailCount { get; set; } +} diff --git a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs index e6b4d6d..1bb9404 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs @@ -160,6 +160,82 @@ public class PushSendLogRepository : Repository, IPushSendLogReposi .ToListAsync(); } + public async Task<(IReadOnlyList Items, int TotalCount)> GetMessageHistoryPagedAsync( + long? serviceId, int page, int size, + string? keyword = null, string? status = null, + DateTime? startDate = null, DateTime? endDate = null) + { + var query = _dbSet + .Include(l => l.Message) + .Include(l => l.Message.Service) + .Where(l => !l.Message.IsDeleted); + + if (serviceId.HasValue) + query = query.Where(l => l.ServiceId == serviceId.Value); + + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(l => l.Message.MessageCode.Contains(keyword) || l.Message.Title.Contains(keyword)); + + if (startDate.HasValue) + query = query.Where(l => l.SentAt >= startDate.Value); + + if (endDate.HasValue) + query = query.Where(l => l.SentAt < endDate.Value.AddDays(1)); + + // 메시지별 GroupBy 집계 + var grouped = query + .GroupBy(l => new { l.MessageId, l.Message.MessageCode, l.Message.Title, ServiceName = l.Message.Service.ServiceName }) + .Select(g => new MessageHistorySummary + { + MessageId = g.Key.MessageId, + MessageCode = g.Key.MessageCode, + Title = g.Key.Title, + ServiceName = g.Key.ServiceName, + FirstSentAt = g.Min(l => l.SentAt), + TotalSendCount = g.Count(), + SuccessCount = g.Count(l => l.Status == PushResult.Success), + FailCount = g.Count(l => l.Status == PushResult.Failed) + }); + + // status 필터 (DB 레벨) + if (!string.IsNullOrWhiteSpace(status)) + { + grouped = status.ToLowerInvariant() switch + { + "complete" => grouped.Where(g => g.SuccessCount > 0), + "failed" => grouped.Where(g => g.TotalSendCount > 0 && g.SuccessCount == 0), + "pending" => grouped.Where(g => g.TotalSendCount == 0), + _ => grouped + }; + } + + var totalCount = await grouped.CountAsync(); + + var items = await grouped + .OrderByDescending(g => g.FirstSentAt) + .Skip((page - 1) * size) + .Take(size) + .ToListAsync(); + + return (items, totalCount); + } + + public async Task> GetFailureStatsByMessageAsync(long serviceId, long messageId, int limit) + { + return await _dbSet + .Where(l => l.ServiceId == serviceId && l.MessageId == messageId + && l.Status == PushResult.Failed && l.FailReason != null) + .GroupBy(l => l.FailReason!) + .Select(g => new FailureStatRaw + { + FailReason = g.Key, + Count = g.Count() + }) + .OrderByDescending(f => f.Count) + .Take(limit) + .ToListAsync(); + } + public async Task> GetFailureStatsAsync(long? serviceId, DateTime startDate, DateTime endDate, int limit) { var query = _dbSet.Where(l => l.Status == PushResult.Failed -- 2.45.1