improvement: 메시지 목록 확장 (#224) #225

Merged
seonkyu.kim merged 1 commits from improvement/#224-message-list-extension into develop 2026-02-25 05:31:09 +00:00
8 changed files with 106 additions and 13 deletions

View File

@ -32,10 +32,10 @@ public class MessageController : ControllerBase
} }
[HttpPost("list")] [HttpPost("list")]
[SwaggerOperation(Summary = "메시지 목록 조회", Description = "서비스별 메시지 목록을 페이지 단위로 조회합니다.")] [SwaggerOperation(Summary = "메시지 목록 조회", Description = "메시지 목록을 페이지 단위로 조회합니다. X-Service-Code 헤더가 있으면 해당 서비스만, 없으면 전체 서비스 메시지를 반환합니다. send_status 필터(complete/pending/failed)를 지원합니다.")]
public async Task<IActionResult> GetListAsync([FromBody] MessageListRequestDto request) public async Task<IActionResult> GetListAsync([FromBody] MessageListRequestDto request)
{ {
var serviceId = GetServiceId(); var serviceId = GetServiceIdOrNull();
var result = await _messageService.GetListAsync(serviceId, request); var result = await _messageService.GetListAsync(serviceId, request);
return Ok(ApiResponse<MessageListResponseDto>.Success(result)); return Ok(ApiResponse<MessageListResponseDto>.Success(result));
} }
@ -83,6 +83,13 @@ public class MessageController : ControllerBase
throw new SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400); throw new SpmsException(ErrorCodes.BadRequest, "서비스 식별 정보가 없습니다.", 400);
} }
private long? GetServiceIdOrNull()
{
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)
return serviceId;
return null;
}
private long GetAdminId() private long GetAdminId()
{ {
var adminIdClaim = User.FindFirst("adminId")?.Value; var adminIdClaim = User.FindFirst("adminId")?.Value;

View File

@ -31,7 +31,8 @@ public class ServiceCodeMiddleware
// === OPTIONAL_FOR_ADMIN: 관리자는 X-Service-Code 선택 === // === OPTIONAL_FOR_ADMIN: 관리자는 X-Service-Code 선택 ===
if (path.StartsWithSegments("/v1/in/stats") || if (path.StartsWithSegments("/v1/in/stats") ||
path.StartsWithSegments("/v1/in/device/list")) path.StartsWithSegments("/v1/in/device/list") ||
path.StartsWithSegments("/v1/in/message/list"))
{ {
if (context.Request.Headers.TryGetValue("X-Service-Code", out var optionalCode) && if (context.Request.Headers.TryGetValue("X-Service-Code", out var optionalCode) &&
!string.IsNullOrWhiteSpace(optionalCode)) !string.IsNullOrWhiteSpace(optionalCode))

View File

@ -15,4 +15,10 @@ public class MessageListRequestDto
[JsonPropertyName("is_active")] [JsonPropertyName("is_active")]
public bool? IsActive { get; set; } public bool? IsActive { get; set; }
[JsonPropertyName("service_code")]
public string? ServiceCode { get; set; }
[JsonPropertyName("send_status")]
public string? SendStatus { get; set; }
} }

View File

@ -25,4 +25,13 @@ public class MessageSummaryDto
[JsonPropertyName("created_at")] [JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
[JsonPropertyName("service_name")]
public string ServiceName { get; set; } = string.Empty;
[JsonPropertyName("service_code")]
public string ServiceCode { get; set; } = string.Empty;
[JsonPropertyName("latest_send_status")]
public string LatestSendStatus { get; set; } = "pending";
} }

View File

@ -5,7 +5,7 @@ namespace SPMS.Application.Interfaces;
public interface IMessageService public interface IMessageService
{ {
Task<MessageSaveResponseDto> SaveAsync(long serviceId, long adminId, MessageSaveRequestDto request); Task<MessageSaveResponseDto> SaveAsync(long serviceId, long adminId, MessageSaveRequestDto request);
Task<MessageListResponseDto> GetListAsync(long serviceId, MessageListRequestDto request); Task<MessageListResponseDto> GetListAsync(long? serviceId, MessageListRequestDto request);
Task<MessageInfoResponseDto> GetInfoAsync(long serviceId, MessageInfoRequestDto request); Task<MessageInfoResponseDto> GetInfoAsync(long serviceId, MessageInfoRequestDto request);
Task DeleteAsync(long serviceId, MessageDeleteRequestDto request); Task DeleteAsync(long serviceId, MessageDeleteRequestDto request);
Task<MessagePreviewResponseDto> PreviewAsync(long serviceId, MessagePreviewRequestDto request); Task<MessagePreviewResponseDto> PreviewAsync(long serviceId, MessagePreviewRequestDto request);

View File

@ -63,26 +63,28 @@ public class MessageService : IMessageService
}; };
} }
public async Task<MessageListResponseDto> GetListAsync(long serviceId, MessageListRequestDto request) public async Task<MessageListResponseDto> GetListAsync(long? serviceId, MessageListRequestDto request)
{ {
var page = Math.Max(1, request.Page); var page = Math.Max(1, request.Page);
var size = Math.Clamp(request.Size, 1, 100); var size = Math.Clamp(request.Size, 1, 100);
var (items, totalCount) = await _messageRepository.GetPagedByServiceAsync( var (items, totalCount) = await _messageRepository.GetPagedForListAsync(
serviceId, page, size, serviceId, page, size,
m => (request.Keyword == null || m.Title.Contains(request.Keyword) || m.Body.Contains(request.Keyword)) request.Keyword, request.IsActive, request.SendStatus);
&& (request.IsActive == null || m.IsDeleted != request.IsActive));
var totalPages = (int)Math.Ceiling((double)totalCount / size); var totalPages = (int)Math.Ceiling((double)totalCount / size);
return new MessageListResponseDto return new MessageListResponseDto
{ {
Items = items.Select(m => new MessageSummaryDto Items = items.Select(p => new MessageSummaryDto
{ {
MessageCode = m.MessageCode, MessageCode = p.MessageCode,
Title = m.Title, Title = p.Title,
IsActive = !m.IsDeleted, IsActive = p.IsActive,
CreatedAt = m.CreatedAt CreatedAt = p.CreatedAt,
ServiceName = p.ServiceName,
ServiceCode = p.ServiceCode,
LatestSendStatus = DetermineSendStatus(p.TotalSendCount, p.SuccessCount)
}).ToList(), }).ToList(),
Pagination = new PaginationDto Pagination = new PaginationDto
{ {
@ -94,6 +96,12 @@ public class MessageService : IMessageService
}; };
} }
private static string DetermineSendStatus(int totalSend, int successCount)
{
if (totalSend == 0) return "pending";
return successCount > 0 ? "complete" : "failed";
}
public async Task<MessageInfoResponseDto> GetInfoAsync(long serviceId, MessageInfoRequestDto request) public async Task<MessageInfoResponseDto> GetInfoAsync(long serviceId, MessageInfoRequestDto request)
{ {
if (string.IsNullOrWhiteSpace(request.MessageCode)) if (string.IsNullOrWhiteSpace(request.MessageCode))

View File

@ -11,4 +11,19 @@ public interface IMessageRepository : IRepository<Message>
Task<(IReadOnlyList<Message> Items, int TotalCount)> GetPagedByServiceAsync( Task<(IReadOnlyList<Message> Items, int TotalCount)> GetPagedByServiceAsync(
long serviceId, int page, int size, long serviceId, int page, int size,
Expression<Func<Message, bool>>? predicate = null); Expression<Func<Message, bool>>? predicate = null);
Task<(IReadOnlyList<MessageListProjection> Items, int TotalCount)> GetPagedForListAsync(
long? serviceId, int page, int size,
string? keyword = null, bool? isActive = null, string? sendStatus = null);
}
public class MessageListProjection
{
public string MessageCode { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public bool IsActive { get; set; }
public DateTime CreatedAt { get; set; }
public string ServiceName { get; set; } = string.Empty;
public string ServiceCode { get; set; } = string.Empty;
public int TotalSendCount { get; set; }
public int SuccessCount { get; set; }
} }

View File

@ -1,6 +1,7 @@
using System.Linq.Expressions; using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SPMS.Domain.Entities; using SPMS.Domain.Entities;
using SPMS.Domain.Enums;
using SPMS.Domain.Interfaces; using SPMS.Domain.Interfaces;
namespace SPMS.Infrastructure.Persistence.Repositories; namespace SPMS.Infrastructure.Persistence.Repositories;
@ -52,4 +53,50 @@ public class MessageRepository : Repository<Message>, IMessageRepository
return (items, totalCount); return (items, totalCount);
} }
public async Task<(IReadOnlyList<MessageListProjection> Items, int TotalCount)> GetPagedForListAsync(
long? serviceId, int page, int size,
string? keyword = null, bool? isActive = null, string? sendStatus = null)
{
var query = _dbSet.Where(m => !m.IsDeleted);
if (serviceId.HasValue)
query = query.Where(m => m.ServiceId == serviceId.Value);
if (!string.IsNullOrWhiteSpace(keyword))
query = query.Where(m => m.Title.Contains(keyword) || m.Body.Contains(keyword));
if (isActive.HasValue)
query = query.Where(m => m.IsDeleted != isActive.Value);
var projected = query.Select(m => new MessageListProjection
{
MessageCode = m.MessageCode,
Title = m.Title,
IsActive = !m.IsDeleted,
CreatedAt = m.CreatedAt,
ServiceName = m.Service.ServiceName,
ServiceCode = m.Service.ServiceCode,
TotalSendCount = _context.PushSendLogs.Count(l => l.MessageId == m.Id),
SuccessCount = _context.PushSendLogs.Count(l => l.MessageId == m.Id && l.Status == PushResult.Success)
});
// 발송 상태 필터
if (sendStatus == "complete")
projected = projected.Where(p => p.SuccessCount > 0);
else if (sendStatus == "failed")
projected = projected.Where(p => p.TotalSendCount > 0 && p.SuccessCount == 0);
else if (sendStatus == "pending")
projected = projected.Where(p => p.TotalSendCount == 0);
var totalCount = await projected.CountAsync();
var items = await projected
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * size)
.Take(size)
.ToListAsync();
return (items, totalCount);
}
} }