feat: FCM 발송 모듈 구현 (#104)
This commit is contained in:
parent
08aa74138f
commit
7a250847f4
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.Auth;
|
||||||
using SPMS.Infrastructure.Messaging;
|
using SPMS.Infrastructure.Messaging;
|
||||||
using SPMS.Infrastructure.Persistence;
|
using SPMS.Infrastructure.Persistence;
|
||||||
|
using SPMS.Infrastructure.Push;
|
||||||
using SPMS.Infrastructure.Persistence.Repositories;
|
using SPMS.Infrastructure.Persistence.Repositories;
|
||||||
using SPMS.Infrastructure.Security;
|
using SPMS.Infrastructure.Security;
|
||||||
using SPMS.Infrastructure.Services;
|
using SPMS.Infrastructure.Services;
|
||||||
|
|
@ -51,6 +52,9 @@ public static class DependencyInjection
|
||||||
services.AddHostedService<RabbitMQInitializer>();
|
services.AddHostedService<RabbitMQInitializer>();
|
||||||
services.AddScoped<IPushQueueService, PushQueueService>();
|
services.AddScoped<IPushQueueService, PushQueueService>();
|
||||||
|
|
||||||
|
// Push Senders
|
||||||
|
services.AddSingleton<IFcmSender, FcmSender>();
|
||||||
|
|
||||||
// Token Store & Email Service
|
// Token Store & Email Service
|
||||||
services.AddMemoryCache();
|
services.AddMemoryCache();
|
||||||
services.AddSingleton<ITokenStore, InMemoryTokenStore>();
|
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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FirebaseAdmin" Version="3.4.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user