feat: FCM 발송 모듈 구현 (#104) #105
11
SPMS.Application/DTOs/Push/PushResultDto.cs
Normal file
11
SPMS.Application/DTOs/Push/PushResultDto.cs
Normal 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; }
|
||||
}
|
||||
24
SPMS.Application/Interfaces/IFcmSender.cs
Normal file
24
SPMS.Application/Interfaces/IFcmSender.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
206
SPMS.Infrastructure/Push/FcmSender.cs
Normal file
206
SPMS.Infrastructure/Push/FcmSender.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user