Compare commits

...

4 Commits

Author SHA1 Message Date
3fc3bb8144 fix: RabbitMQ 상태 모니터링 및 백그라운드 재시도 추가 (#124)
- RabbitMQInitializer를 BackgroundService로 변경 (30초 간격 재시도)
- RabbitMQConnection에 IsConnected 속성 추가
- Health check에 RabbitMQ 연결/초기화 상태 반영
- DI 등록 변경 (Singleton + HostedService 패턴)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:15:42 +09:00
efd6615809 fix: RabbitMQ 연결 실패 시 앱 크래시 방지 (#124)
- StartAsync에서 throw 제거, LogWarning으로 변경
- InitializeAsync 메서드 분리 (재시도 가능)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:06:58 +09:00
355d3269c0 feat: 발송 로그 조회 API 구현 (#122)
All checks were successful
SPMS_API/pipeline/head This commit looks good
Reviewed-on: https://git.ipstein.myds.me/SPMS/SPMS_API/pulls/123
2026-02-10 10:05:50 +00:00
SEAN
b56170f10c feat: 발송 로그 조회 API 구현 (#122)
- POST /v1/in/push/log 엔드포인트 추가
- PushSendLogRepository (페이징, 필터링: message_code, device_id, status, 날짜범위)
- PushService.GetLogAsync 구현
- 누락된 Push DTO 파일 포함 (PushSendRequestDto, PushSendResponseDto, PushSendTagRequestDto)
2026-02-10 17:41:38 +09:00
14 changed files with 348 additions and 67 deletions

View File

@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using SPMS.Domain.Common; using SPMS.Domain.Common;
using SPMS.Infrastructure; using SPMS.Infrastructure;
using SPMS.Infrastructure.Messaging;
namespace SPMS.API.Controllers; namespace SPMS.API.Controllers;
@ -14,10 +15,17 @@ namespace SPMS.API.Controllers;
public class PublicController : ControllerBase public class PublicController : ControllerBase
{ {
private readonly AppDbContext _dbContext; private readonly AppDbContext _dbContext;
private readonly RabbitMQConnection _rabbitConnection;
private readonly RabbitMQInitializer _rabbitInitializer;
public PublicController(AppDbContext dbContext) public PublicController(
AppDbContext dbContext,
RabbitMQConnection rabbitConnection,
RabbitMQInitializer rabbitInitializer)
{ {
_dbContext = dbContext; _dbContext = dbContext;
_rabbitConnection = rabbitConnection;
_rabbitInitializer = rabbitInitializer;
} }
[HttpPost("health")] [HttpPost("health")]
@ -39,11 +47,26 @@ public class PublicController : ControllerBase
allHealthy = false; allHealthy = false;
} }
// 2. Redis 연결 확인 (Phase 2에서 구현 예정) // 2. Redis 연결 확인 (Phase 3-2에서 구현 예정)
checks["redis"] = new { status = "not_configured" }; checks["redis"] = new { status = "not_configured" };
// 3. RabbitMQ 연결 확인 (Phase 2에서 구현 예정) // 3. RabbitMQ 연결 확인
checks["rabbitmq"] = new { status = "not_configured" }; var rabbitConnected = _rabbitConnection.IsConnected;
var rabbitInitialized = _rabbitInitializer.IsInitialized;
if (rabbitConnected && rabbitInitialized)
{
checks["rabbitmq"] = new { status = "healthy" };
}
else
{
checks["rabbitmq"] = new
{
status = "unhealthy",
connected = rabbitConnected,
initialized = rabbitInitialized
};
allHealthy = false;
}
if (allHealthy) if (allHealthy)
{ {

View File

@ -54,6 +54,15 @@ public class PushController : ControllerBase
return Ok(ApiResponse.Success()); return Ok(ApiResponse.Success());
} }
[HttpPost("log")]
[SwaggerOperation(Summary = "발송 로그 조회", Description = "푸시 발송 이력을 페이지 단위로 조회합니다.")]
public async Task<IActionResult> GetLogAsync([FromBody] PushLogRequestDto request)
{
var serviceId = GetServiceId();
var result = await _pushService.GetLogAsync(serviceId, request);
return Ok(ApiResponse<PushLogResponseDto>.Success(result));
}
private long GetServiceId() private long GetServiceId()
{ {
if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId) if (HttpContext.Items.TryGetValue("ServiceId", out var serviceIdObj) && serviceIdObj is long serviceId)

View File

@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace SPMS.Application.DTOs.Push;
public class PushLogRequestDto
{
[JsonPropertyName("page")]
public int Page { get; set; } = 1;
[JsonPropertyName("size")]
public int Size { get; set; } = 20;
[JsonPropertyName("message_code")]
public string? MessageCode { get; set; }
[JsonPropertyName("device_id")]
public long? DeviceId { 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; }
}

View File

@ -0,0 +1,34 @@
using System.Text.Json.Serialization;
using SPMS.Application.DTOs.Notice;
namespace SPMS.Application.DTOs.Push;
public class PushLogResponseDto
{
[JsonPropertyName("items")]
public List<PushLogItemDto> Items { get; set; } = [];
[JsonPropertyName("pagination")]
public PaginationDto Pagination { get; set; } = new();
}
public class PushLogItemDto
{
[JsonPropertyName("send_id")]
public long SendId { get; set; }
[JsonPropertyName("message_code")]
public string MessageCode { get; set; } = string.Empty;
[JsonPropertyName("device_id")]
public long DeviceId { get; set; }
[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; }
}

View File

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace SPMS.Application.DTOs.Push;
public class PushSendRequestDto
{
[Required]
public string MessageCode { get; set; } = string.Empty;
[Required]
public long DeviceId { get; set; }
public Dictionary<string, string>? Variables { get; set; }
}

View File

@ -0,0 +1,8 @@
namespace SPMS.Application.DTOs.Push;
public class PushSendResponseDto
{
public string RequestId { get; set; } = string.Empty;
public string SendType { get; set; } = string.Empty;
public string Status { get; set; } = "queued";
}

View File

@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace SPMS.Application.DTOs.Push;
public class PushSendTagRequestDto
{
[Required]
public string MessageCode { get; set; } = string.Empty;
[Required]
[MinLength(1)]
public List<int> Tags { get; set; } = [];
public string TagMatch { get; set; } = "or";
}

View File

@ -8,4 +8,5 @@ public interface IPushService
Task<PushSendResponseDto> SendByTagAsync(long serviceId, PushSendTagRequestDto request); Task<PushSendResponseDto> SendByTagAsync(long serviceId, PushSendTagRequestDto request);
Task<PushScheduleResponseDto> ScheduleAsync(long serviceId, PushScheduleRequestDto request); Task<PushScheduleResponseDto> ScheduleAsync(long serviceId, PushScheduleRequestDto request);
Task CancelScheduleAsync(PushScheduleCancelRequestDto request); Task CancelScheduleAsync(PushScheduleCancelRequestDto request);
Task<PushLogResponseDto> GetLogAsync(long serviceId, PushLogRequestDto request);
} }

View File

@ -1,7 +1,10 @@
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using SPMS.Application.DTOs.Notice;
using SPMS.Application.DTOs.Push; using SPMS.Application.DTOs.Push;
using SPMS.Application.Interfaces; using SPMS.Application.Interfaces;
using SPMS.Domain.Common; using SPMS.Domain.Common;
using SPMS.Domain.Enums;
using SPMS.Domain.Exceptions; using SPMS.Domain.Exceptions;
using SPMS.Domain.Interfaces; using SPMS.Domain.Interfaces;
@ -12,15 +15,18 @@ public class PushService : IPushService
private readonly IMessageRepository _messageRepository; private readonly IMessageRepository _messageRepository;
private readonly IPushQueueService _pushQueueService; private readonly IPushQueueService _pushQueueService;
private readonly IScheduleCancelStore _scheduleCancelStore; private readonly IScheduleCancelStore _scheduleCancelStore;
private readonly IPushSendLogRepository _pushSendLogRepository;
public PushService( public PushService(
IMessageRepository messageRepository, IMessageRepository messageRepository,
IPushQueueService pushQueueService, IPushQueueService pushQueueService,
IScheduleCancelStore scheduleCancelStore) IScheduleCancelStore scheduleCancelStore,
IPushSendLogRepository pushSendLogRepository)
{ {
_messageRepository = messageRepository; _messageRepository = messageRepository;
_pushQueueService = pushQueueService; _pushQueueService = pushQueueService;
_scheduleCancelStore = scheduleCancelStore; _scheduleCancelStore = scheduleCancelStore;
_pushSendLogRepository = pushSendLogRepository;
} }
public async Task<PushSendResponseDto> SendAsync(long serviceId, PushSendRequestDto request) public async Task<PushSendResponseDto> SendAsync(long serviceId, PushSendRequestDto request)
@ -186,6 +192,77 @@ public class PushService : IPushService
await _scheduleCancelStore.MarkCancelledAsync(request.ScheduleId); await _scheduleCancelStore.MarkCancelledAsync(request.ScheduleId);
} }
public async Task<PushLogResponseDto> GetLogAsync(long serviceId, PushLogRequestDto request)
{
long? messageId = null;
if (!string.IsNullOrWhiteSpace(request.MessageCode))
{
var message = await _messageRepository.GetByMessageCodeAndServiceAsync(request.MessageCode, serviceId);
if (message != null)
messageId = message.Id;
else
return new PushLogResponseDto
{
Items = [],
Pagination = new PaginationDto
{
Page = request.Page,
Size = request.Size,
TotalCount = 0,
TotalPages = 0
}
};
}
PushResult? status = null;
if (!string.IsNullOrWhiteSpace(request.Status))
{
status = request.Status.ToLowerInvariant() switch
{
"success" => PushResult.Success,
"failed" => PushResult.Failed,
_ => null
};
}
DateTime? startDate = null;
if (!string.IsNullOrWhiteSpace(request.StartDate) &&
DateTime.TryParseExact(request.StartDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedStart))
startDate = parsedStart;
DateTime? endDate = null;
if (!string.IsNullOrWhiteSpace(request.EndDate) &&
DateTime.TryParseExact(request.EndDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsedEnd))
endDate = parsedEnd;
var (items, totalCount) = await _pushSendLogRepository.GetPagedWithMessageAsync(
serviceId, request.Page, request.Size,
messageId, request.DeviceId, status,
startDate, endDate);
var totalPages = (int)Math.Ceiling((double)totalCount / request.Size);
return new PushLogResponseDto
{
Items = items.Select(l => new PushLogItemDto
{
SendId = l.Id,
MessageCode = l.Message?.MessageCode ?? string.Empty,
DeviceId = l.DeviceId,
Status = l.Status.ToString().ToLowerInvariant(),
FailReason = l.FailReason,
SentAt = l.SentAt
}).ToList(),
Pagination = new PaginationDto
{
Page = request.Page,
Size = request.Size,
TotalCount = totalCount,
TotalPages = totalPages
}
};
}
private static string ApplyVariables(string template, Dictionary<string, string>? variables) private static string ApplyVariables(string template, Dictionary<string, string>? variables)
{ {
if (variables == null || variables.Count == 0) if (variables == null || variables.Count == 0)

View File

@ -0,0 +1,13 @@
using SPMS.Domain.Entities;
using SPMS.Domain.Enums;
namespace SPMS.Domain.Interfaces;
public interface IPushSendLogRepository : IRepository<PushSendLog>
{
Task<(IReadOnlyList<PushSendLog> Items, int TotalCount)> GetPagedWithMessageAsync(
long serviceId, int page, int size,
long? messageId = null, long? deviceId = null,
PushResult? status = null,
DateTime? startDate = null, DateTime? endDate = null);
}

View File

@ -39,6 +39,7 @@ public static class DependencyInjection
services.AddScoped<IDeviceRepository, DeviceRepository>(); services.AddScoped<IDeviceRepository, DeviceRepository>();
services.AddScoped<IFileRepository, FileRepository>(); services.AddScoped<IFileRepository, FileRepository>();
services.AddScoped<IMessageRepository, MessageRepository>(); services.AddScoped<IMessageRepository, MessageRepository>();
services.AddScoped<IPushSendLogRepository, PushSendLogRepository>();
// External Services // External Services
services.AddScoped<IJwtService, JwtService>(); services.AddScoped<IJwtService, JwtService>();
@ -57,7 +58,8 @@ public static class DependencyInjection
// RabbitMQ // RabbitMQ
services.Configure<RabbitMQSettings>(configuration.GetSection(RabbitMQSettings.SectionName)); services.Configure<RabbitMQSettings>(configuration.GetSection(RabbitMQSettings.SectionName));
services.AddSingleton<RabbitMQConnection>(); services.AddSingleton<RabbitMQConnection>();
services.AddHostedService<RabbitMQInitializer>(); services.AddSingleton<RabbitMQInitializer>();
services.AddHostedService(sp => sp.GetRequiredService<RabbitMQInitializer>());
services.AddScoped<IPushQueueService, PushQueueService>(); services.AddScoped<IPushQueueService, PushQueueService>();
// Push Senders // Push Senders

View File

@ -12,6 +12,8 @@ public class RabbitMQConnection : IAsyncDisposable
private readonly SemaphoreSlim _semaphore = new(1, 1); private readonly SemaphoreSlim _semaphore = new(1, 1);
private IConnection? _connection; private IConnection? _connection;
public bool IsConnected => _connection is { IsOpen: true };
public RabbitMQConnection( public RabbitMQConnection(
IOptions<RabbitMQSettings> settings, IOptions<RabbitMQSettings> settings,
ILogger<RabbitMQConnection> logger) ILogger<RabbitMQConnection> logger)

View File

@ -6,11 +6,14 @@ using SPMS.Application.Settings;
namespace SPMS.Infrastructure.Messaging; namespace SPMS.Infrastructure.Messaging;
public class RabbitMQInitializer : IHostedService public class RabbitMQInitializer : BackgroundService
{ {
private readonly RabbitMQConnection _connection; private readonly RabbitMQConnection _connection;
private readonly RabbitMQSettings _settings; private readonly RabbitMQSettings _settings;
private readonly ILogger<RabbitMQInitializer> _logger; private readonly ILogger<RabbitMQInitializer> _logger;
private static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(30);
public bool IsInitialized { get; private set; }
public RabbitMQInitializer( public RabbitMQInitializer(
RabbitMQConnection connection, RabbitMQConnection connection,
@ -22,70 +25,76 @@ public class RabbitMQInitializer : IHostedService
_logger = logger; _logger = logger;
} }
public async Task StartAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
try while (!stoppingToken.IsCancellationRequested)
{ {
await using var channel = await _connection.CreateChannelAsync(cancellationToken); try
// Exchange 선언: Direct, Durable
await channel.ExchangeDeclareAsync(
exchange: _settings.Exchange,
type: ExchangeType.Direct,
durable: true,
autoDelete: false,
arguments: null,
cancellationToken: cancellationToken);
_logger.LogInformation("Exchange 선언 완료: {Exchange}", _settings.Exchange);
var queueArgs = new Dictionary<string, object?>
{ {
{ "x-message-ttl", _settings.MessageTtl } await InitializeAsync(stoppingToken);
}; IsInitialized = true;
_logger.LogInformation("RabbitMQ 초기화 완료");
// Push Queue 선언 return;
await channel.QueueDeclareAsync( }
queue: _settings.PushQueue, catch (Exception ex)
durable: true, {
exclusive: false, _logger.LogWarning(ex, "RabbitMQ 초기화 실패 — {RetrySeconds}초 후 재시도합니다.", RetryInterval.TotalSeconds);
autoDelete: false, await Task.Delay(RetryInterval, stoppingToken);
arguments: queueArgs, }
cancellationToken: cancellationToken);
await channel.QueueBindAsync(
queue: _settings.PushQueue,
exchange: _settings.Exchange,
routingKey: "push",
cancellationToken: cancellationToken);
_logger.LogInformation("Queue 선언 및 바인딩 완료: {Queue} → {Exchange} (routing_key: push)",
_settings.PushQueue, _settings.Exchange);
// Schedule Queue 선언
await channel.QueueDeclareAsync(
queue: _settings.ScheduleQueue,
durable: true,
exclusive: false,
autoDelete: false,
arguments: queueArgs,
cancellationToken: cancellationToken);
await channel.QueueBindAsync(
queue: _settings.ScheduleQueue,
exchange: _settings.Exchange,
routingKey: "schedule",
cancellationToken: cancellationToken);
_logger.LogInformation("Queue 선언 및 바인딩 완료: {Queue} → {Exchange} (routing_key: schedule)",
_settings.ScheduleQueue, _settings.Exchange);
}
catch (Exception ex)
{
_logger.LogError(ex, "RabbitMQ Exchange/Queue 초기화 실패");
throw;
} }
} }
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; private async Task InitializeAsync(CancellationToken cancellationToken)
{
await using var channel = await _connection.CreateChannelAsync(cancellationToken);
await channel.ExchangeDeclareAsync(
exchange: _settings.Exchange,
type: ExchangeType.Direct,
durable: true,
autoDelete: false,
arguments: null,
cancellationToken: cancellationToken);
_logger.LogInformation("Exchange 선언 완료: {Exchange}", _settings.Exchange);
var queueArgs = new Dictionary<string, object?>
{
{ "x-message-ttl", _settings.MessageTtl }
};
await channel.QueueDeclareAsync(
queue: _settings.PushQueue,
durable: true,
exclusive: false,
autoDelete: false,
arguments: queueArgs,
cancellationToken: cancellationToken);
await channel.QueueBindAsync(
queue: _settings.PushQueue,
exchange: _settings.Exchange,
routingKey: "push",
cancellationToken: cancellationToken);
_logger.LogInformation("Queue 선언 및 바인딩 완료: {Queue} → {Exchange} (routing_key: push)",
_settings.PushQueue, _settings.Exchange);
await channel.QueueDeclareAsync(
queue: _settings.ScheduleQueue,
durable: true,
exclusive: false,
autoDelete: false,
arguments: queueArgs,
cancellationToken: cancellationToken);
await channel.QueueBindAsync(
queue: _settings.ScheduleQueue,
exchange: _settings.Exchange,
routingKey: "schedule",
cancellationToken: cancellationToken);
_logger.LogInformation("Queue 선언 및 바인딩 완료: {Queue} → {Exchange} (routing_key: schedule)",
_settings.ScheduleQueue, _settings.Exchange);
}
} }

View File

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
using SPMS.Domain.Entities;
using SPMS.Domain.Enums;
using SPMS.Domain.Interfaces;
namespace SPMS.Infrastructure.Persistence.Repositories;
public class PushSendLogRepository : Repository<PushSendLog>, IPushSendLogRepository
{
public PushSendLogRepository(AppDbContext context) : base(context) { }
public async Task<(IReadOnlyList<PushSendLog> Items, int TotalCount)> GetPagedWithMessageAsync(
long serviceId, int page, int size,
long? messageId = null, long? deviceId = null,
PushResult? status = null,
DateTime? startDate = null, DateTime? endDate = null)
{
var query = _dbSet
.Include(l => l.Message)
.Where(l => l.ServiceId == serviceId);
if (messageId.HasValue)
query = query.Where(l => l.MessageId == messageId.Value);
if (deviceId.HasValue)
query = query.Where(l => l.DeviceId == deviceId.Value);
if (status.HasValue)
query = query.Where(l => l.Status == status.Value);
if (startDate.HasValue)
query = query.Where(l => l.SentAt >= startDate.Value);
if (endDate.HasValue)
query = query.Where(l => l.SentAt < endDate.Value.AddDays(1));
var totalCount = await query.CountAsync();
var items = await query
.OrderByDescending(l => l.SentAt)
.Skip((page - 1) * size)
.Take(size)
.ToListAsync();
return (items, totalCount);
}
}