From 7a250847f476ca56e6865d55c4c6d240dd367e89 Mon Sep 17 00:00:00 2001 From: SEAN Date: Tue, 10 Feb 2026 15:44:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20FCM=20=EB=B0=9C=EC=86=A1=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EA=B5=AC=ED=98=84=20(#104)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SPMS.Application/DTOs/Push/PushResultDto.cs | 11 + SPMS.Application/Interfaces/IFcmSender.cs | 24 ++ SPMS.Infrastructure/DependencyInjection.cs | 4 + SPMS.Infrastructure/Push/FcmSender.cs | 206 ++++++++++++++++++ .../SPMS.Infrastructure.csproj | 1 + 5 files changed, 246 insertions(+) create mode 100644 SPMS.Application/DTOs/Push/PushResultDto.cs create mode 100644 SPMS.Application/Interfaces/IFcmSender.cs create mode 100644 SPMS.Infrastructure/Push/FcmSender.cs diff --git a/SPMS.Application/DTOs/Push/PushResultDto.cs b/SPMS.Application/DTOs/Push/PushResultDto.cs new file mode 100644 index 0000000..03dfa0a --- /dev/null +++ b/SPMS.Application/DTOs/Push/PushResultDto.cs @@ -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; } +} diff --git a/SPMS.Application/Interfaces/IFcmSender.cs b/SPMS.Application/Interfaces/IFcmSender.cs new file mode 100644 index 0000000..aa4cf59 --- /dev/null +++ b/SPMS.Application/Interfaces/IFcmSender.cs @@ -0,0 +1,24 @@ +using SPMS.Application.DTOs.Push; + +namespace SPMS.Application.Interfaces; + +public interface IFcmSender +{ + Task SendAsync( + string fcmCredentialsJson, + string deviceToken, + string title, + string body, + string? imageUrl, + Dictionary? data, + CancellationToken cancellationToken = default); + + Task> SendBatchAsync( + string fcmCredentialsJson, + List deviceTokens, + string title, + string body, + string? imageUrl, + Dictionary? data, + CancellationToken cancellationToken = default); +} diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index b877f58..0406316 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -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(); services.AddScoped(); + // Push Senders + services.AddSingleton(); + // Token Store & Email Service services.AddMemoryCache(); services.AddSingleton(); diff --git a/SPMS.Infrastructure/Push/FcmSender.cs b/SPMS.Infrastructure/Push/FcmSender.cs new file mode 100644 index 0000000..8fb7fd0 --- /dev/null +++ b/SPMS.Infrastructure/Push/FcmSender.cs @@ -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 _apps = new(); + private readonly ILogger _logger; + + public FcmSender(ILogger logger) + { + _logger = logger; + } + + public async Task SendAsync( + string fcmCredentialsJson, + string deviceToken, + string title, + string body, + string? imageUrl, + Dictionary? 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> SendBatchAsync( + string fcmCredentialsJson, + List deviceTokens, + string title, + string body, + string? imageUrl, + Dictionary? data, + CancellationToken cancellationToken = default) + { + var app = GetOrCreateApp(fcmCredentialsJson); + var messaging = FirebaseMessaging.GetMessaging(app); + var results = new List(); + + 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? 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; + } +} diff --git a/SPMS.Infrastructure/SPMS.Infrastructure.csproj b/SPMS.Infrastructure/SPMS.Infrastructure.csproj index 46c0ff7..d8663d1 100644 --- a/SPMS.Infrastructure/SPMS.Infrastructure.csproj +++ b/SPMS.Infrastructure/SPMS.Infrastructure.csproj @@ -12,6 +12,7 @@ + -- 2.45.1