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; } }