TossPaymentService.cs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. using System.Text;
  2. using System.Text.Json;
  3. using Application.Abstractions.Crypto;
  4. using Application.Abstractions.Data;
  5. using Application.Abstractions.Payment;
  6. using Microsoft.EntityFrameworkCore;
  7. using Microsoft.Extensions.Logging;
  8. namespace Infrastructure.Payment;
  9. /// <summary>
  10. /// 토스페이먼츠 V2 API 클라이언트.
  11. /// - 인증: Basic base64(secretKey + ":") — 콜론 1개 필수, Encoding.UTF8.GetBytes 는 BOM 미포함.
  12. /// - POST 에 Idempotency-Key(UUID v4) 부여 — 동일 키 재요청 시 첫 응답 반환(중복 승인 방지).
  13. /// - 통신 불명 예외(HttpRequestException/TaskCanceledException)는 전파 — 호출측이 NeedsReconciliation 분기. (Phase 1.5)
  14. /// - 명시적 거절(HTTP 4xx/5xx + {code, message} 본문)은 Success=false 로 반환.
  15. /// </summary>
  16. internal sealed class TossPaymentService(
  17. HttpClient httpClient,
  18. IAppDbContext db,
  19. IFieldEncryptor encryptor,
  20. ILogger<TossPaymentService> logger
  21. ) : ITossPaymentService
  22. {
  23. private const string BaseUrl = "https://api.tosspayments.com";
  24. public async Task<TossClientConfig> GetClientConfigAsync(CancellationToken ct)
  25. {
  26. var (clientKey, _) = await GetKeysAsync(ct);
  27. return new TossClientConfig(clientKey);
  28. }
  29. public async Task<TossPaymentResult> ConfirmAsync(string paymentKey, string orderID, int amount, CancellationToken ct)
  30. {
  31. var (_, secretKey) = await GetKeysAsync(ct);
  32. var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/payments/confirm");
  33. request.Headers.Add("Authorization", BuildBasicAuth(secretKey));
  34. request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
  35. var body = new
  36. {
  37. paymentKey,
  38. orderId = orderID,
  39. amount
  40. };
  41. request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
  42. var response = await httpClient.SendAsync(request, ct);
  43. var json = await response.Content.ReadAsStringAsync(ct);
  44. logger.LogInformation("[TossPay] Confirm response: {StatusCode} {Body}", response.StatusCode, json);
  45. return Parse(response.IsSuccessStatusCode, json);
  46. }
  47. public async Task<TossPaymentResult> CancelAsync(string paymentKey, string cancelReason, int? cancelAmount, CancellationToken ct)
  48. {
  49. var (_, secretKey) = await GetKeysAsync(ct);
  50. var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/v1/payments/{Uri.EscapeDataString(paymentKey)}/cancel");
  51. request.Headers.Add("Authorization", BuildBasicAuth(secretKey));
  52. request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
  53. // cancelAmount 미지정 = 전액 취소 (필드 자체를 보내지 않는다)
  54. var json = cancelAmount is null
  55. ? JsonSerializer.Serialize(new { cancelReason })
  56. : JsonSerializer.Serialize(new { cancelReason, cancelAmount });
  57. request.Content = new StringContent(json, Encoding.UTF8, "application/json");
  58. var response = await httpClient.SendAsync(request, ct);
  59. var responseJson = await response.Content.ReadAsStringAsync(ct);
  60. logger.LogInformation("[TossPay] Cancel response: {StatusCode} {Body}", response.StatusCode, responseJson);
  61. return Parse(response.IsSuccessStatusCode, responseJson);
  62. }
  63. public async Task<TossPaymentResult> GetPaymentAsync(string paymentKey, CancellationToken ct)
  64. {
  65. var (_, secretKey) = await GetKeysAsync(ct);
  66. // GET 은 Idempotency-Key 미적용 (무시됨)
  67. var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUrl}/v1/payments/{Uri.EscapeDataString(paymentKey)}");
  68. request.Headers.Add("Authorization", BuildBasicAuth(secretKey));
  69. var response = await httpClient.SendAsync(request, ct);
  70. var json = await response.Content.ReadAsStringAsync(ct);
  71. logger.LogInformation("[TossPay] GetPayment response: {StatusCode} paymentKey={PaymentKey}", response.StatusCode, paymentKey);
  72. return Parse(response.IsSuccessStatusCode, json);
  73. }
  74. // ── Private ──────────────────────────────────────────────────────
  75. private static TossPaymentResult Parse(bool httpSuccess, string json)
  76. {
  77. JsonElement doc;
  78. try
  79. {
  80. doc = JsonSerializer.Deserialize<JsonElement>(json);
  81. }
  82. catch (JsonException ex)
  83. {
  84. // 게이트웨이 HTML 오류 등 비정상 본문 — 실패 단정 금지, 통신 불명으로 전파해 대사 대상으로 처리
  85. throw new HttpRequestException($"토스 응답 본문 파싱 실패: {ex.Message}", ex);
  86. }
  87. string? Get(JsonElement el, string key) => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
  88. int? GetInt(JsonElement el, string key) => el.ValueKind == JsonValueKind.Object && el.TryGetProperty(key, out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : null;
  89. DateTime? GetDate(JsonElement el, string key)
  90. {
  91. var raw = Get(el, key);
  92. return DateTimeOffset.TryParse(raw, out var parsed) ? parsed.UtcDateTime : null;
  93. }
  94. if (!httpSuccess)
  95. {
  96. // 에러 응답 {code, message}
  97. return new TossPaymentResult(
  98. false,
  99. Get(doc, "code") ?? "UNKNOWN",
  100. Get(doc, "message") ?? "결제 요청에 실패했습니다.",
  101. Get(doc, "paymentKey"), Get(doc, "orderId"), null, null, null, null, null,
  102. null, null, null, null, [], json
  103. );
  104. }
  105. // 영수증
  106. string? receiptUrl = null;
  107. if (doc.TryGetProperty("receipt", out var receipt))
  108. {
  109. receiptUrl = Get(receipt, "url");
  110. }
  111. // 가상계좌
  112. string? vaNumber = null;
  113. string? vaBankCode = null;
  114. string? vaHolder = null;
  115. DateTime? vaDueDate = null;
  116. if (doc.TryGetProperty("virtualAccount", out var va) && va.ValueKind == JsonValueKind.Object)
  117. {
  118. vaNumber = Get(va, "accountNumber");
  119. vaBankCode = Get(va, "bankCode") ?? Get(va, "bank");
  120. vaHolder = Get(va, "customerName");
  121. vaDueDate = GetDate(va, "dueDate");
  122. }
  123. // 취소 이력
  124. var cancels = new List<TossCancelDetail>();
  125. if (doc.TryGetProperty("cancels", out var cancelArray) && cancelArray.ValueKind == JsonValueKind.Array)
  126. {
  127. foreach (var c in cancelArray.EnumerateArray())
  128. {
  129. cancels.Add(new TossCancelDetail(Get(c, "transactionKey"), GetInt(c, "cancelAmount") ?? 0, Get(c, "cancelReason"), GetDate(c, "canceledAt")));
  130. }
  131. }
  132. return new TossPaymentResult(
  133. true, null, null,
  134. Get(doc, "paymentKey"),
  135. Get(doc, "orderId"),
  136. Get(doc, "status"),
  137. GetInt(doc, "totalAmount"),
  138. Get(doc, "method"),
  139. GetDate(doc, "approvedAt"),
  140. receiptUrl,
  141. vaNumber, vaBankCode, vaHolder, vaDueDate,
  142. cancels, json
  143. );
  144. }
  145. private async Task<(string ClientKey, string SecretKey)> GetKeysAsync(CancellationToken ct)
  146. {
  147. var config = await db.Config.FirstOrDefaultAsync(ct);
  148. if (config?.External is null)
  149. {
  150. throw new InvalidOperationException("토스페이먼츠 결제 설정이 없습니다. 관리자 페이지에서 설정해주세요.");
  151. }
  152. var ext = config.External;
  153. var isLive = string.Equals(ext.TossPayMode, "live", StringComparison.OrdinalIgnoreCase);
  154. var clientKey = isLive ? ext.TossLiveClientKeyEnc : ext.TossTestClientKeyEnc;
  155. var secretKey = isLive ? ext.TossLiveSecretKeyEnc : ext.TossTestSecretKeyEnc;
  156. if (string.IsNullOrEmpty(clientKey) || string.IsNullOrEmpty(secretKey))
  157. {
  158. throw new InvalidOperationException($"토스페이먼츠 {(isLive ? "라이브" : "테스트")} 설정이 불완전합니다.");
  159. }
  160. // Admin 결제 설정 화면이 "enc:v{n}:" 포맷으로 암호화 저장 — 레거시 평문은 그대로 통과
  161. return (EncryptedConfigValue.Unprotect(encryptor, clientKey), EncryptedConfigValue.Unprotect(encryptor, secretKey));
  162. }
  163. private static string BuildBasicAuth(string secretKey)
  164. {
  165. // SecretKey + ":" → Base64 (콜론 1개 필수, BOM 없이 인코딩)
  166. var raw = $"{secretKey}:";
  167. var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(raw));
  168. return $"Basic {base64}";
  169. }
  170. }