improvement: 이력 목록/상세 API 추가 (#233)
All checks were successful
SPMS_API/pipeline/head This commit looks good
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/234
This commit is contained in:
commit
9350066fb4
|
|
@ -91,6 +91,24 @@ public class StatsController : ControllerBase
|
||||||
return Ok(ApiResponse<DashboardResponseDto>.Success(result, "조회 성공"));
|
return Ok(ApiResponse<DashboardResponseDto>.Success(result, "조회 성공"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("history/list")]
|
||||||
|
[SwaggerOperation(Summary = "이력 목록 조회", Description = "메시지별 발송 이력 목록을 조회합니다. keyword/status/date 필터를 지원합니다. X-Service-Code 헤더 미지정 시 전체 서비스 이력을 조회합니다.")]
|
||||||
|
public async Task<IActionResult> GetHistoryListAsync([FromBody] HistoryListRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetHistoryListAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<HistoryListResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("history/detail")]
|
||||||
|
[SwaggerOperation(Summary = "이력 상세 조회", Description = "특정 메시지의 발송 이력 상세(기본정보+집계+실패사유+본문)를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스에서 검색합니다.")]
|
||||||
|
public async Task<IActionResult> GetHistoryDetailAsync([FromBody] HistoryDetailRequestDto request)
|
||||||
|
{
|
||||||
|
var serviceId = GetOptionalServiceId();
|
||||||
|
var result = await _statsService.GetHistoryDetailAsync(serviceId, request);
|
||||||
|
return Ok(ApiResponse<HistoryDetailResponseDto>.Success(result, "조회 성공"));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("send-log")]
|
[HttpPost("send-log")]
|
||||||
[SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
[SwaggerOperation(Summary = "발송 상세 로그 조회", Description = "특정 메시지의 개별 디바이스별 발송 상세 로그를 조회합니다. X-Service-Code 헤더 미지정 시 전체 서비스 통계를 조회합니다.")]
|
||||||
public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request)
|
public async Task<IActionResult> GetSendLogDetailAsync([FromBody] SendLogDetailRequestDto request)
|
||||||
|
|
|
||||||
11
SPMS.Application/DTOs/Stats/HistoryDetailRequestDto.cs
Normal file
11
SPMS.Application/DTOs/Stats/HistoryDetailRequestDto.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
60
SPMS.Application/DTOs/Stats/HistoryDetailResponseDto.cs
Normal file
60
SPMS.Application/DTOs/Stats/HistoryDetailResponseDto.cs
Normal file
|
|
@ -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<HistoryFailReasonDto> 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;
|
||||||
|
}
|
||||||
24
SPMS.Application/DTOs/Stats/HistoryListRequestDto.cs
Normal file
24
SPMS.Application/DTOs/Stats/HistoryListRequestDto.cs
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
43
SPMS.Application/DTOs/Stats/HistoryListResponseDto.cs
Normal file
43
SPMS.Application/DTOs/Stats/HistoryListResponseDto.cs
Normal file
|
|
@ -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<HistoryListItemDto> 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;
|
||||||
|
}
|
||||||
|
|
@ -13,4 +13,6 @@ public interface IStatsService
|
||||||
Task<byte[]> ExportReportAsync(long? serviceId, StatsExportRequestDto request);
|
Task<byte[]> ExportReportAsync(long? serviceId, StatsExportRequestDto request);
|
||||||
Task<FailureStatResponseDto> GetFailureStatAsync(long? serviceId, FailureStatRequestDto request);
|
Task<FailureStatResponseDto> GetFailureStatAsync(long? serviceId, FailureStatRequestDto request);
|
||||||
Task<DashboardResponseDto> GetDashboardAsync(long? serviceId, DashboardRequestDto request);
|
Task<DashboardResponseDto> GetDashboardAsync(long? serviceId, DashboardRequestDto request);
|
||||||
|
Task<HistoryListResponseDto> GetHistoryListAsync(long? serviceId, HistoryListRequestDto request);
|
||||||
|
Task<HistoryDetailResponseDto> GetHistoryDetailAsync(long? serviceId, HistoryDetailRequestDto request);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,90 @@ public class StatsService : IStatsService
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<HistoryListResponseDto> 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<HistoryDetailResponseDto> 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)
|
private static string GetFailReasonDescription(string failReason)
|
||||||
{
|
{
|
||||||
return failReason switch
|
return failReason switch
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ public interface IPushSendLogRepository : IRepository<PushSendLog>
|
||||||
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);
|
Task<List<FailureStatRaw>> GetFailureStatsAsync(long? serviceId, DateTime startDate, DateTime endDate, int limit);
|
||||||
|
Task<List<FailureStatRaw>> GetFailureStatsByMessageAsync(long serviceId, long messageId, int limit);
|
||||||
|
Task<(IReadOnlyList<MessageHistorySummary> 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
|
public class HourlyStatRaw
|
||||||
|
|
@ -53,3 +58,15 @@ public class FailureStatRaw
|
||||||
public string FailReason { get; set; } = string.Empty;
|
public string FailReason { get; set; } = string.Empty;
|
||||||
public int Count { get; set; }
|
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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,82 @@ public class PushSendLogRepository : Repository<PushSendLog>, IPushSendLogReposi
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<MessageHistorySummary> 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<List<FailureStatRaw>> 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<List<FailureStatRaw>> GetFailureStatsAsync(long? serviceId, DateTime startDate, DateTime endDate, int limit)
|
public async Task<List<FailureStatRaw>> GetFailureStatsAsync(long? serviceId, DateTime startDate, DateTime endDate, int limit)
|
||||||
{
|
{
|
||||||
var query = _dbSet.Where(l => l.Status == PushResult.Failed
|
var query = _dbSet.Where(l => l.Status == PushResult.Failed
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user