259 lines
9.2 KiB
C#
259 lines
9.2 KiB
C#
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.Hosting;
|
|
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 string SandboxHost = "https://api.sandbox.push.apple.com";
|
|
private const int MaxConcurrency = 50;
|
|
private const int TokenRefreshMinutes = 50;
|
|
|
|
private readonly HttpClient _httpClient;
|
|
private readonly ILogger<ApnsSender> _logger;
|
|
private readonly string _apnsHost;
|
|
private readonly ConcurrentDictionary<string, (string Token, DateTime ExpiresAt)> _tokenCache = new();
|
|
|
|
public ApnsSender(IHttpClientFactory httpClientFactory, IHostEnvironment hostEnvironment, ILogger<ApnsSender> logger)
|
|
{
|
|
_httpClient = httpClientFactory.CreateClient("ApnsSender");
|
|
_logger = logger;
|
|
_apnsHost = hostEnvironment.IsDevelopment() ? SandboxHost : ProductionHost;
|
|
_logger.LogInformation("APNs 환경: {Host}", _apnsHost);
|
|
}
|
|
|
|
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 = $"{_apnsHost}/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);
|
|
}
|
|
}
|