207 lines
6.4 KiB
C#
207 lines
6.4 KiB
C#
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;
|
|
}
|
|
}
|