| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- using System.Text;
- using System.Text.Json;
- using Application.Abstractions.Crypto;
- using Application.Abstractions.Data;
- using Application.Abstractions.Payment;
- using Microsoft.EntityFrameworkCore;
- using Microsoft.Extensions.Logging;
- namespace Infrastructure.Payment;
- /// <summary>
- /// 토스페이먼츠 V2 API 클라이언트.
- /// - 인증: Basic base64(secretKey + ":") — 콜론 1개 필수, Encoding.UTF8.GetBytes 는 BOM 미포함.
- /// - POST 에 Idempotency-Key(UUID v4) 부여 — 동일 키 재요청 시 첫 응답 반환(중복 승인 방지).
- /// - 통신 불명 예외(HttpRequestException/TaskCanceledException)는 전파 — 호출측이 NeedsReconciliation 분기. (Phase 1.5)
- /// - 명시적 거절(HTTP 4xx/5xx + {code, message} 본문)은 Success=false 로 반환.
- /// </summary>
- internal sealed class TossPaymentService(
- HttpClient httpClient,
- IAppDbContext db,
- IFieldEncryptor encryptor,
- ILogger<TossPaymentService> logger
- ) : ITossPaymentService
- {
- private const string BaseUrl = "https://api.tosspayments.com";
- public async Task<TossClientConfig> GetClientConfigAsync(CancellationToken ct)
- {
- var (clientKey, _) = await GetKeysAsync(ct);
- return new TossClientConfig(clientKey);
- }
- public async Task<TossPaymentResult> ConfirmAsync(string paymentKey, string orderID, int amount, CancellationToken ct)
- {
- var (_, secretKey) = await GetKeysAsync(ct);
- var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/payments/confirm");
- request.Headers.Add("Authorization", BuildBasicAuth(secretKey));
- request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
- var body = new
- {
- paymentKey,
- orderId = orderID,
- amount
- };
- request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
- var response = await httpClient.SendAsync(request, ct);
- var json = await response.Content.ReadAsStringAsync(ct);
- logger.LogInformation("[TossPay] Confirm response: {StatusCode} {Body}", response.StatusCode, json);
- return Parse(response.IsSuccessStatusCode, json);
- }
- public async Task<TossPaymentResult> CancelAsync(string paymentKey, string cancelReason, int? cancelAmount, CancellationToken ct)
- {
- var (_, secretKey) = await GetKeysAsync(ct);
- var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/payments/{Uri.EscapeDataString(paymentKey)}/cancel");
- request.Headers.Add("Authorization", BuildBasicAuth(secretKey));
- request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
- // cancelAmount 미지정 = 전액 취소 (필드 자체를 보내지 않는다)
- var json = cancelAmount is null
- ? JsonSerializer.Serialize(new { cancelReason })
- : JsonSerializer.Serialize(new { cancelReason, cancelAmount });
- request.Content = new StringContent(json, Encoding.UTF8, "application/json");
- var response = await httpClient.SendAsync(request, ct);
- var responseJson = await response.Content.ReadAsStringAsync(ct);
- logger.LogInformation("[TossPay] Cancel response: {StatusCode} {Body}", response.StatusCode, responseJson);
- return Parse(response.IsSuccessStatusCode, responseJson);
- }
- public async Task<TossPaymentResult> GetPaymentAsync(string paymentKey, CancellationToken ct)
- {
- var (_, secretKey) = await GetKeysAsync(ct);
- // GET 은 Idempotency-Key 미적용 (무시됨)
- var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/payments/{Uri.EscapeDataString(paymentKey)}");
- request.Headers.Add("Authorization", BuildBasicAuth(secretKey));
- var response = await httpClient.SendAsync(request, ct);
- var json = await response.Content.ReadAsStringAsync(ct);
- logger.LogInformation("[TossPay] GetPayment response: {StatusCode} paymentKey={PaymentKey}", response.StatusCode, paymentKey);
- return Parse(response.IsSuccessStatusCode, json);
- }
- // ── Private ──────────────────────────────────────────────────────
- private static TossPaymentResult Parse(bool httpSuccess, string json)
- {
- JsonElement doc;
- try
- {
- doc = JsonSerializer.Deserialize<JsonElement>(json);
- }
- catch (JsonException ex)
- {
- // 게이트웨이 HTML 오류 등 비정상 본문 — 실패 단정 금지, 통신 불명으로 전파해 대사 대상으로 처리
- throw new HttpRequestException($"토스 응답 본문 파싱 실패: {ex.Message}", ex);
- }
- string? Get(JsonElement el, string key) => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
- int? GetInt(JsonElement el, string key) => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : null;
- DateTime? GetDate(JsonElement el, string key)
- {
- var raw = Get(el, key);
- return DateTimeOffset.TryParse(raw, out var parsed) ? parsed.UtcDateTime : null;
- }
- if (!httpSuccess)
- {
- // 에러 응답 {code, message}
- return new TossPaymentResult(
- false,
- Get(doc, "code") ?? "UNKNOWN",
- Get(doc, "message") ?? "결제 요청에 실패했습니다.",
- Get(doc, "paymentKey"), Get(doc, "orderId"), null, null, null, null, null,
- null, null, null, null, [], json
- );
- }
- // 영수증
- string? receiptUrl = null;
- if (doc.TryGetProperty("receipt", out var receipt))
- {
- receiptUrl = Get(receipt, "url");
- }
- // 가상계좌
- string? vaNumber = null;
- string? vaBankCode = null;
- string? vaHolder = null;
- DateTime? vaDueDate = null;
- if (doc.TryGetProperty("virtualAccount", out var va) && va.ValueKind == JsonValueKind.Object)
- {
- vaNumber = Get(va, "accountNumber");
- vaBankCode = Get(va, "bankCode") ?? Get(va, "bank");
- vaHolder = Get(va, "customerName");
- vaDueDate = GetDate(va, "dueDate");
- }
- // 취소 이력
- var cancels = new List<TossCancelDetail>();
- if (doc.TryGetProperty("cancels", out var cancelArray) && cancelArray.ValueKind == JsonValueKind.Array)
- {
- foreach (var c in cancelArray.EnumerateArray())
- {
- cancels.Add(new TossCancelDetail(Get(c, "transactionKey"), GetInt(c, "cancelAmount") ?? 0, Get(c, "cancelReason"), GetDate(c, "canceledAt")));
- }
- }
- return new TossPaymentResult(
- true, null, null,
- Get(doc, "paymentKey"),
- Get(doc, "orderId"),
- Get(doc, "status"),
- GetInt(doc, "totalAmount"),
- Get(doc, "method"),
- GetDate(doc, "approvedAt"),
- receiptUrl,
- vaNumber, vaBankCode, vaHolder, vaDueDate,
- cancels, json
- );
- }
- private async Task<(string ClientKey, string SecretKey)> GetKeysAsync(CancellationToken ct)
- {
- var config = await db.Config.FirstOrDefaultAsync(ct);
- if (config?.External is null)
- {
- throw new InvalidOperationException("토스페이먼츠 결제 설정이 없습니다. 관리자 페이지에서 설정해주세요.");
- }
- var ext = config.External;
- var isLive = string.Equals(ext.TossPayMode, "live", StringComparison.OrdinalIgnoreCase);
- var clientKey = isLive ? ext.TossLiveClientKeyEnc : ext.TossTestClientKeyEnc;
- var secretKey = isLive ? ext.TossLiveSecretKeyEnc : ext.TossTestSecretKeyEnc;
- if (string.IsNullOrEmpty(clientKey) || string.IsNullOrEmpty(secretKey))
- {
- throw new InvalidOperationException($"토스페이먼츠 {(isLive ? "라이브" : "테스트")} 설정이 불완전합니다.");
- }
- // Admin 결제 설정 화면이 "enc:v{n}:" 포맷으로 암호화 저장 — 레거시 평문은 그대로 통과
- return (EncryptedConfigValue.Unprotect(encryptor, clientKey), EncryptedConfigValue.Unprotect(encryptor, secretKey));
- }
- private static string BuildBasicAuth(string secretKey)
- {
- // SecretKey + ":" → Base64 (콜론 1개 필수, BOM 없이 인코딩)
- var raw = $"{secretKey}:";
- var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(raw));
- return $"Basic {base64}";
- }
- }
|