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); } }