feat: FCM 발송 모듈 구현 (#104)

This commit is contained in:
SEAN 2026-02-10 15:44:32 +09:00
parent 08aa74138f
commit 7a250847f4
5 changed files with 246 additions and 0 deletions

View File

@ -0,0 +1,11 @@
namespace SPMS.Application.DTOs.Push;
public class PushResultDto
{
public string DeviceToken { get; set; } = string.Empty;
public bool IsSuccess { get; set; }
public string? ErrorCode { get; set; }
public string? ErrorMessage { get; set; }
public bool ShouldRemoveDevice { get; set; }
public bool ShouldRetry { get; set; }
}

View File

@ -0,0 +1,24 @@
using SPMS.Application.DTOs.Push;
namespace SPMS.Application.Interfaces;
public interface IFcmSender
{
Task<PushResultDto> SendAsync(
string fcmCredentialsJson,
string deviceToken,
string title,
string body,
string? imageUrl,
Dictionary<string, string>? data,
CancellationToken cancellationToken = default);
Task<List<PushResultDto>> SendBatchAsync(
string fcmCredentialsJson,
List<string> deviceTokens,
string title,
string body,
string? imageUrl,
Dictionary<string, string>? data,
CancellationToken cancellationToken = default);
}

View File

@ -7,6 +7,7 @@ using SPMS.Domain.Interfaces;
using SPMS.Infrastructure.Auth;
using SPMS.Infrastructure.Messaging;
using SPMS.Infrastructure.Persistence;
using SPMS.Infrastructure.Push;
using SPMS.Infrastructure.Persistence.Repositories;
using SPMS.Infrastructure.Security;
using SPMS.Infrastructure.Services;
@ -51,6 +52,9 @@ public static class DependencyInjection
services.AddHostedService<RabbitMQInitializer>();
services.AddScoped<IPushQueueService, PushQueueService>();
// Push Senders
services.AddSingleton<IFcmSender, FcmSender>();
// Token Store & Email Service
services.AddMemoryCache();
services.AddSingleton<ITokenStore, InMemoryTokenStore>();

View File

@ -0,0 +1,206 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using FirebaseAdmin;
using FirebaseAdmin.Messaging;
using Google.Apis.Auth.OAuth2;
using Microsoft.Extensions.Logging;
using SPMS.Application.DTOs.Push;
using SPMS.Application.Interfaces;
namespace SPMS.Infrastructure.Push;
public class FcmSender : IFcmSender
{
private readonly ConcurrentDictionary<string, FirebaseApp> _apps = new();
private readonly ILogger<FcmSender> _logger;
public FcmSender(ILogger<FcmSender> logger)
{
_logger = logger;
}
public async Task<PushResultDto> SendAsync(
string fcmCredentialsJson,
string deviceToken,
string title,
string body,
string? imageUrl,
Dictionary<string, string>? data,
CancellationToken cancellationToken = default)
{
var app = GetOrCreateApp(fcmCredentialsJson);
var messaging = FirebaseMessaging.GetMessaging(app);
var message = BuildMessage(deviceToken, title, body, imageUrl, data);
try
{
await messaging.SendAsync(message, cancellationToken);
return new PushResultDto
{
DeviceToken = deviceToken,
IsSuccess = true
};
}
catch (FirebaseMessagingException ex)
{
return MapException(deviceToken, ex);
}
}
public async Task<List<PushResultDto>> SendBatchAsync(
string fcmCredentialsJson,
List<string> deviceTokens,
string title,
string body,
string? imageUrl,
Dictionary<string, string>? data,
CancellationToken cancellationToken = default)
{
var app = GetOrCreateApp(fcmCredentialsJson);
var messaging = FirebaseMessaging.GetMessaging(app);
var results = new List<PushResultDto>();
foreach (var chunk in deviceTokens.Chunk(500))
{
var multicastMessage = new MulticastMessage
{
Tokens = chunk.ToList(),
Notification = new Notification
{
Title = title,
Body = body,
ImageUrl = imageUrl
},
Data = data,
Android = new AndroidConfig
{
Priority = Priority.High
}
};
var response = await messaging.SendEachForMulticastAsync(
multicastMessage, cancellationToken);
for (var i = 0; i < chunk.Length; i++)
{
var sendResponse = response.Responses[i];
var token = chunk[i];
if (sendResponse.IsSuccess)
{
results.Add(new PushResultDto
{
DeviceToken = token,
IsSuccess = true
});
}
else
{
var result = sendResponse.Exception is FirebaseMessagingException fme
? MapException(token, fme)
: new PushResultDto
{
DeviceToken = token,
IsSuccess = false,
ErrorCode = "UNKNOWN",
ErrorMessage = sendResponse.Exception?.Message,
ShouldRetry = true
};
results.Add(result);
}
}
_logger.LogInformation(
"FCM 배치 발송 완료: {SuccessCount}/{Total} 성공",
response.SuccessCount, chunk.Length);
}
return results;
}
private FirebaseApp GetOrCreateApp(string fcmCredentialsJson)
{
var key = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(fcmCredentialsJson)));
return _apps.GetOrAdd(key, _ =>
{
var credential = GoogleCredential.FromJson(fcmCredentialsJson);
var app = FirebaseApp.Create(new AppOptions
{
Credential = credential
}, key);
_logger.LogInformation("Firebase App 인스턴스 생성: {AppName}", key[..8]);
return app;
});
}
private static Message BuildMessage(
string deviceToken, string title, string body,
string? imageUrl, Dictionary<string, string>? data)
{
return new Message
{
Token = deviceToken,
Notification = new Notification
{
Title = title,
Body = body,
ImageUrl = imageUrl
},
Data = data,
Android = new AndroidConfig
{
Priority = Priority.High
}
};
}
private PushResultDto MapException(string deviceToken, FirebaseMessagingException ex)
{
var errorCode = ex.MessagingErrorCode;
var result = new PushResultDto
{
DeviceToken = deviceToken,
IsSuccess = false,
ErrorCode = errorCode?.ToString() ?? "UNKNOWN",
ErrorMessage = ex.Message
};
switch (errorCode)
{
case MessagingErrorCode.Unregistered:
result.ShouldRemoveDevice = true;
_logger.LogWarning("FCM 토큰 만료/삭제: {Token}", deviceToken[..Math.Min(20, deviceToken.Length)]);
break;
case MessagingErrorCode.InvalidArgument:
case MessagingErrorCode.SenderIdMismatch:
result.ShouldRemoveDevice = true;
_logger.LogWarning("FCM 잘못된 토큰: {ErrorCode}, {Token}",
errorCode, deviceToken[..Math.Min(20, deviceToken.Length)]);
break;
case MessagingErrorCode.Unavailable:
case MessagingErrorCode.Internal:
result.ShouldRetry = true;
_logger.LogWarning("FCM 일시 오류 (재시도 필요): {ErrorCode}", errorCode);
break;
case MessagingErrorCode.QuotaExceeded:
result.ShouldRetry = true;
_logger.LogError("FCM 할당량 초과: {Message}", ex.Message);
break;
default:
_logger.LogError(ex, "FCM 알 수 없는 오류: {ErrorCode}", errorCode);
break;
}
return result;
}
}

View File

@ -12,6 +12,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FirebaseAdmin" Version="3.4.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />