feat: APNs 발송 모듈 구현 (#106) #107

Merged
seonkyu.kim merged 1 commits from feature/#106-apns-sender into develop 2026-02-10 07:00:06 +00:00
4 changed files with 290 additions and 0 deletions

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

View File

@ -54,6 +54,12 @@ public static class DependencyInjection
// Push Senders // Push Senders
services.AddSingleton<IFcmSender, FcmSender>(); services.AddSingleton<IFcmSender, FcmSender>();
services.AddHttpClient("ApnsSender")
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true
});
services.AddSingleton<IApnsSender, ApnsSender>();
// Token Store & Email Service // Token Store & Email Service
services.AddMemoryCache(); services.AddMemoryCache();

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

View File

@ -17,6 +17,7 @@
<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" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" 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="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" /> <PackageReference Include="RabbitMQ.Client" Version="7.2.0" />