feat: APNs 발송 모듈 구현 (#106) #107
30
SPMS.Application/Interfaces/IApnsSender.cs
Normal file
30
SPMS.Application/Interfaces/IApnsSender.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
using SPMS.Application.DTOs.Push;
|
||||
|
||||
namespace SPMS.Application.Interfaces;
|
||||
|
||||
public interface IApnsSender
|
||||
{
|
||||
Task<PushResultDto> SendAsync(
|
||||
string privateKey,
|
||||
string keyId,
|
||||
string teamId,
|
||||
string bundleId,
|
||||
string deviceToken,
|
||||
string title,
|
||||
string body,
|
||||
string? imageUrl,
|
||||
Dictionary<string, string>? data,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<PushResultDto>> SendBatchAsync(
|
||||
string privateKey,
|
||||
string keyId,
|
||||
string teamId,
|
||||
string bundleId,
|
||||
List<string> deviceTokens,
|
||||
string title,
|
||||
string body,
|
||||
string? imageUrl,
|
||||
Dictionary<string, string>? data,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -54,6 +54,12 @@ public static class DependencyInjection
|
|||
|
||||
// Push Senders
|
||||
services.AddSingleton<IFcmSender, FcmSender>();
|
||||
services.AddHttpClient("ApnsSender")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
|
||||
{
|
||||
EnableMultipleHttp2Connections = true
|
||||
});
|
||||
services.AddSingleton<IApnsSender, ApnsSender>();
|
||||
|
||||
// Token Store & Email Service
|
||||
services.AddMemoryCache();
|
||||
|
|
|
|||
253
SPMS.Infrastructure/Push/ApnsSender.cs
Normal file
253
SPMS.Infrastructure/Push/ApnsSender.cs
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SPMS.Application.DTOs.Push;
|
||||
using SPMS.Application.Interfaces;
|
||||
|
||||
namespace SPMS.Infrastructure.Push;
|
||||
|
||||
public class ApnsSender : IApnsSender
|
||||
{
|
||||
private const string ProductionHost = "https://api.push.apple.com";
|
||||
private const int MaxConcurrency = 50;
|
||||
private const int TokenRefreshMinutes = 50;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<ApnsSender> _logger;
|
||||
private readonly ConcurrentDictionary<string, (string Token, DateTime ExpiresAt)> _tokenCache = new();
|
||||
|
||||
public ApnsSender(IHttpClientFactory httpClientFactory, ILogger<ApnsSender> logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient("ApnsSender");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<PushResultDto> SendAsync(
|
||||
string privateKey, string keyId, string teamId, string bundleId,
|
||||
string deviceToken, string title, string body, string? imageUrl,
|
||||
Dictionary<string, string>? data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jwt = GetOrCreateToken(privateKey, keyId, teamId);
|
||||
var payload = BuildPayload(title, body, imageUrl, data);
|
||||
|
||||
return await SendRequestAsync(jwt, bundleId, deviceToken, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<PushResultDto>> SendBatchAsync(
|
||||
string privateKey, string keyId, string teamId, string bundleId,
|
||||
List<string> deviceTokens, string title, string body, string? imageUrl,
|
||||
Dictionary<string, string>? data, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var jwt = GetOrCreateToken(privateKey, keyId, teamId);
|
||||
var payload = BuildPayload(title, body, imageUrl, data);
|
||||
var results = new List<PushResultDto>();
|
||||
|
||||
using var semaphore = new SemaphoreSlim(MaxConcurrency);
|
||||
|
||||
foreach (var chunk in deviceTokens.Chunk(500))
|
||||
{
|
||||
var tasks = chunk.Select(async token =>
|
||||
{
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
return await SendRequestAsync(jwt, bundleId, token, payload, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
});
|
||||
|
||||
var chunkResults = await Task.WhenAll(tasks);
|
||||
results.AddRange(chunkResults);
|
||||
|
||||
var successCount = chunkResults.Count(r => r.IsSuccess);
|
||||
_logger.LogInformation("APNs 배치 발송 완료: {SuccessCount}/{Total} 성공",
|
||||
successCount, chunk.Length);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<PushResultDto> SendRequestAsync(
|
||||
string jwt, string bundleId, string deviceToken,
|
||||
string payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{ProductionHost}/3/device/{deviceToken}";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Version = new Version(2, 0),
|
||||
Content = new StringContent(payload, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("bearer", jwt);
|
||||
request.Headers.TryAddWithoutValidation("apns-topic", bundleId);
|
||||
request.Headers.TryAddWithoutValidation("apns-push-type", "alert");
|
||||
request.Headers.TryAddWithoutValidation("apns-priority", "10");
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return new PushResultDto
|
||||
{
|
||||
DeviceToken = deviceToken,
|
||||
IsSuccess = true
|
||||
};
|
||||
}
|
||||
|
||||
return await MapErrorResponseAsync(deviceToken, response, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "APNs 요청 실패: {Token}", deviceToken[..Math.Min(20, deviceToken.Length)]);
|
||||
return new PushResultDto
|
||||
{
|
||||
DeviceToken = deviceToken,
|
||||
IsSuccess = false,
|
||||
ErrorCode = "REQUEST_FAILED",
|
||||
ErrorMessage = ex.Message,
|
||||
ShouldRetry = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PushResultDto> MapErrorResponseAsync(
|
||||
string deviceToken, HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var reason = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
reason = doc.RootElement.TryGetProperty("reason", out var r) ? r.GetString() ?? string.Empty : string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
reason = responseBody;
|
||||
}
|
||||
|
||||
var result = new PushResultDto
|
||||
{
|
||||
DeviceToken = deviceToken,
|
||||
IsSuccess = false,
|
||||
ErrorCode = reason,
|
||||
ErrorMessage = $"HTTP {(int)response.StatusCode}: {reason}"
|
||||
};
|
||||
|
||||
switch (response.StatusCode)
|
||||
{
|
||||
case HttpStatusCode.Gone: // 410 Unregistered
|
||||
result.ShouldRemoveDevice = true;
|
||||
_logger.LogWarning("APNs 토큰 만료(410): {Token}", deviceToken[..Math.Min(20, deviceToken.Length)]);
|
||||
break;
|
||||
|
||||
case HttpStatusCode.BadRequest: // 400
|
||||
if (reason is "BadDeviceToken" or "DeviceTokenNotForTopic")
|
||||
{
|
||||
result.ShouldRemoveDevice = true;
|
||||
_logger.LogWarning("APNs 잘못된 토큰(400): {Reason}, {Token}",
|
||||
reason, deviceToken[..Math.Min(20, deviceToken.Length)]);
|
||||
}
|
||||
break;
|
||||
|
||||
case HttpStatusCode.ServiceUnavailable: // 503
|
||||
case HttpStatusCode.TooManyRequests: // 429
|
||||
result.ShouldRetry = true;
|
||||
_logger.LogWarning("APNs 일시 오류 (재시도 필요): {StatusCode}", response.StatusCode);
|
||||
break;
|
||||
|
||||
case HttpStatusCode.Forbidden: // 403
|
||||
_logger.LogError("APNs 인증 오류(403): {Reason}", reason);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogError("APNs 알 수 없는 오류: {StatusCode}, {Reason}", response.StatusCode, reason);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string GetOrCreateToken(string privateKey, string keyId, string teamId)
|
||||
{
|
||||
var cacheKey = $"{keyId}:{teamId}";
|
||||
|
||||
if (_tokenCache.TryGetValue(cacheKey, out var cached) && cached.ExpiresAt > DateTime.UtcNow)
|
||||
return cached.Token;
|
||||
|
||||
var token = GenerateJwt(privateKey, keyId, teamId);
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(TokenRefreshMinutes);
|
||||
_tokenCache[cacheKey] = (token, expiresAt);
|
||||
|
||||
_logger.LogInformation("APNs JWT 토큰 생성: keyId={KeyId}", keyId);
|
||||
return token;
|
||||
}
|
||||
|
||||
private static string GenerateJwt(string privateKey, string keyId, string teamId)
|
||||
{
|
||||
var cleanKey = privateKey
|
||||
.Replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.Replace("-----END PRIVATE KEY-----", "")
|
||||
.Replace("\n", "")
|
||||
.Replace("\r", "")
|
||||
.Trim();
|
||||
|
||||
var keyBytes = Convert.FromBase64String(cleanKey);
|
||||
var ecdsa = ECDsa.Create();
|
||||
ecdsa.ImportPkcs8PrivateKey(keyBytes, out _);
|
||||
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
||||
var header = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(new { alg = "ES256", kid = keyId }));
|
||||
var payload = Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(new { iss = teamId, iat = now }));
|
||||
|
||||
var dataToSign = Encoding.UTF8.GetBytes($"{header}.{payload}");
|
||||
var signature = Base64UrlEncode(ecdsa.SignData(dataToSign, HashAlgorithmName.SHA256));
|
||||
|
||||
return $"{header}.{payload}.{signature}";
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] data)
|
||||
{
|
||||
return Convert.ToBase64String(data)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static string BuildPayload(string title, string body, string? imageUrl, Dictionary<string, string>? data)
|
||||
{
|
||||
var aps = new Dictionary<string, object>
|
||||
{
|
||||
["alert"] = new { title, body },
|
||||
["sound"] = "default"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(imageUrl))
|
||||
aps["mutable-content"] = 1;
|
||||
|
||||
var payload = new Dictionary<string, object> { ["aps"] = aps };
|
||||
|
||||
if (!string.IsNullOrEmpty(imageUrl))
|
||||
payload["image_url"] = imageUrl;
|
||||
|
||||
if (data != null)
|
||||
{
|
||||
foreach (var kv in data)
|
||||
payload[kv.Key] = kv.Value;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(payload);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.2" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user