diff --git a/SPMS.API/Controllers/StatsController.cs b/SPMS.API/Controllers/StatsController.cs index 532434b..7a19f46 100644 --- a/SPMS.API/Controllers/StatsController.cs +++ b/SPMS.API/Controllers/StatsController.cs @@ -63,6 +63,15 @@ public class StatsController : ControllerBase return Ok(ApiResponse.Success(result, "조회 성공")); } + [HttpPost("send-log")] + [SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다.")] + public async Task GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request) + { + var serviceId = GetServiceId(); + var result = await _statsService.GetSendLogDetailAsync(serviceId, request); + return Ok(ApiResponse.Success(result, "조회 성공")); + } + private long GetServiceId() { if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) diff --git a/SPMS.Application/DTOs/Stats/SendLogDetailRequestDto.cs b/SPMS.Application/DTOs/Stats/SendLogDetailRequestDto.cs new file mode 100644 index 0000000..33c005d --- /dev/null +++ b/SPMS.Application/DTOs/Stats/SendLogDetailRequestDto.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace SPMS.Application.DTOs.Stats; + +public class SendLogDetailRequestDto +{ + [Required(ErrorMessage = "message_code는 필수입니다.")] + [JsonPropertyName("message_code")] + public string MessageCode { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("platform")] + public string? Platform { get; set; } + + [JsonPropertyName("page")] + public int Page { get; set; } = 1; + + [JsonPropertyName("size")] + public int Size { get; set; } = 20; +} diff --git a/SPMS.Application/DTOs/Stats/SendLogDetailResponseDto.cs b/SPMS.Application/DTOs/Stats/SendLogDetailResponseDto.cs new file mode 100644 index 0000000..d8f55cb --- /dev/null +++ b/SPMS.Application/DTOs/Stats/SendLogDetailResponseDto.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; +using SPMS.Application.DTOs.Notice; + +namespace SPMS.Application.DTOs.Stats; + +public class SendLogDetailResponseDto +{ + [JsonPropertyName("items")] + public List Items { get; set; } = []; + + [JsonPropertyName("pagination")] + public PaginationDto Pagination { get; set; } = new(); +} + +public class SendLogDetailItemDto +{ + [JsonPropertyName("device_id")] + public long DeviceId { get; set; } + + [JsonPropertyName("device_token")] + public string DeviceToken { get; set; } = string.Empty; + + [JsonPropertyName("platform")] + public string Platform { get; set; } = string.Empty; + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("fail_reason")] + public string? FailReason { get; set; } + + [JsonPropertyName("sent_at")] + public DateTime SentAt { get; set; } +} diff --git a/SPMS.Application/Interfaces/IStatsService.cs b/SPMS.Application/Interfaces/IStatsService.cs index 7560229..825c9af 100644 --- a/SPMS.Application/Interfaces/IStatsService.cs +++ b/SPMS.Application/Interfaces/IStatsService.cs @@ -9,4 +9,5 @@ public interface IStatsService Task GetMessageStatAsync(long serviceId, MessageStatRequestDto request); Task GetHourlyAsync(long serviceId, HourlyStatRequestDto request); Task GetDeviceStatAsync(long serviceId); + Task GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request); } diff --git a/SPMS.Application/Services/StatsService.cs b/SPMS.Application/Services/StatsService.cs index 6915eb6..7859249 100644 --- a/SPMS.Application/Services/StatsService.cs +++ b/SPMS.Application/Services/StatsService.cs @@ -235,6 +235,61 @@ public class StatsService : IStatsService }; } + public async Task GetSendLogDetailAsync(long serviceId, SendLogDetailRequestDto request) + { + var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId); + if (message == null) + throw new SpmsException(ErrorCodes.MessageNotFound, "존재하지 않는 메시지 코드입니다.", 404); + + PushResult? status = null; + if (!string.IsNullOrWhiteSpace(request.Status)) + { + status = request.Status.ToLowerInvariant() switch + { + "success" => PushResult.Success, + "failed" => PushResult.Failed, + _ => null + }; + } + + Platform? platform = null; + if (!string.IsNullOrWhiteSpace(request.Platform)) + { + platform = request.Platform.ToLowerInvariant() switch + { + "ios" => Domain.Enums.Platform.iOS, + "android" => Domain.Enums.Platform.Android, + "web" => Domain.Enums.Platform.Web, + _ => null + }; + } + + var (items, totalCount) = await _pushSendLogRepository.GetDetailLogPagedAsync( + serviceId, message.Id, request.Page, request.Size, status, platform); + + var totalPages = (int)Math.Ceiling((double)totalCount / request.Size); + + return new SendLogDetailResponseDto + { + Items = items.Select(l => new SendLogDetailItemDto + { + DeviceId = l.DeviceId, + DeviceToken = l.Device?.DeviceToken ?? string.Empty, + Platform = l.Device?.Platform.ToString().ToLowerInvariant() ?? string.Empty, + Status = l.Status.ToString().ToLowerInvariant(), + FailReason = l.FailReason, + SentAt = l.SentAt + }).ToList(), + Pagination = new DTOs.Notice.PaginationDto + { + Page = request.Page, + Size = request.Size, + TotalCount = totalCount, + TotalPages = totalPages + } + }; + } + 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 8eb4b3d..8d09bb5 100644 --- a/SPMS.Domain/Interfaces/IPushSendLogRepository.cs +++ b/SPMS.Domain/Interfaces/IPushSendLogRepository.cs @@ -14,6 +14,9 @@ public interface IPushSendLogRepository : IRepository Task> GetHourlyStatsAsync(long serviceId, DateTime startDate, DateTime endDate); Task GetMessageStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate); Task> GetMessageDailyStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate); + Task<(IReadOnlyList Items, int TotalCount)> GetDetailLogPagedAsync( + long serviceId, long messageId, int page, int size, + PushResult? status = null, Platform? platform = null); } public class HourlyStatRaw diff --git a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs index 30efa06..66b1873 100644 --- a/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs +++ b/SPMS.Infrastructure/Persistence/Repositories/PushSendLogRepository.cs @@ -86,6 +86,31 @@ public class PushSendLogRepository : Repository, IPushSendLogReposi .FirstOrDefaultAsync(); } + public async Task<(IReadOnlyList Items, int TotalCount)> GetDetailLogPagedAsync( + long serviceId, long messageId, int page, int size, + PushResult? status = null, Platform? platform = null) + { + var query = _dbSet + .Include(l => l.Device) + .Where(l => l.ServiceId == serviceId && l.MessageId == messageId); + + if (status.HasValue) + query = query.Where(l => l.Status == status.Value); + + if (platform.HasValue) + query = query.Where(l => l.Device.Platform == platform.Value); + + var totalCount = await query.CountAsync(); + + var items = await query + .OrderByDescending(l => l.SentAt) + .Skip((page - 1) * size) + .Take(size) + .ToListAsync(); + + return (items, totalCount); + } + public async Task> GetMessageDailyStatsAsync(long serviceId, long messageId, DateTime? startDate, DateTime? endDate) { var query = _dbSet.Where(l => l.ServiceId == serviceId && l.MessageId == messageId);