diff --git a/SPMS.Application/Interfaces/IApnsSender.cs b/SPMS.Application/Interfaces/IApnsSender.cs new file mode 100644 index 0000000..03ceccf --- /dev/null +++ b/SPMS.Application/Interfaces/IApnsSender.cs @@ -0,0 +1,30 @@ +using SPMS.Application.DTOs.Push; + +namespace SPMS.Application.Interfaces; + +public interface IApnsSender +{ + Task SendAsync( + string privateKey, + string keyId, + string teamId, + string bundleId, + string deviceToken, + string title, + string body, + string? imageUrl, + Dictionary? data, + CancellationToken cancellationToken = default); + + Task> SendBatchAsync( + string privateKey, + string keyId, + string teamId, + string bundleId, + List deviceTokens, + string title, + string body, + string? imageUrl, + Dictionary? data, + CancellationToken cancellationToken = default); +} diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index 0406316..f455816 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -54,6 +54,12 @@ public static class DependencyInjection // Push Senders services.AddSingleton(); + services.AddHttpClient("ApnsSender") + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + EnableMultipleHttp2Connections = true + }); + services.AddSingleton(); // Token Store & Email Service services.AddMemoryCache(); diff --git a/SPMS.Infrastructure/Push/ApnsSender.cs b/SPMS.Infrastructure/Push/ApnsSender.cs new file mode 100644 index 0000000..be66593 --- /dev/null +++ b/SPMS.Infrastructure/Push/ApnsSender.cs @@ -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 _logger; + private readonly ConcurrentDictionary _tokenCache = new(); + + public ApnsSender(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClient = httpClientFactory.CreateClient("ApnsSender"); + _logger = logger; + } + + public async Task SendAsync( + string privateKey, string keyId, string teamId, string bundleId, + string deviceToken, string title, string body, string? imageUrl, + Dictionary? 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> SendBatchAsync( + string privateKey, string keyId, string teamId, string bundleId, + List deviceTokens, string title, string body, string? imageUrl, + Dictionary? data, CancellationToken cancellationToken = default) + { + var jwt = GetOrCreateToken(privateKey, keyId, teamId); + var payload = BuildPayload(title, body, imageUrl, data); + var results = new List(); + + 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 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 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? data) + { + var aps = new Dictionary + { + ["alert"] = new { title, body }, + ["sound"] = "default" + }; + + if (!string.IsNullOrEmpty(imageUrl)) + aps["mutable-content"] = 1; + + var payload = new Dictionary { ["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); + } +} diff --git a/SPMS.Infrastructure/SPMS.Infrastructure.csproj b/SPMS.Infrastructure/SPMS.Infrastructure.csproj index d8663d1..7a25b55 100644 --- a/SPMS.Infrastructure/SPMS.Infrastructure.csproj +++ b/SPMS.Infrastructure/SPMS.Infrastructure.csproj @@ -17,6 +17,7 @@ +