Handler.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Application.Abstractions.Payment;
  4. using Domain.Entities.Common.ValueObject;
  5. using Domain.Entities.Payments;
  6. using Domain.Entities.Payments.Toss;
  7. using Domain.Entities.Payments.Toss.ValueObject;
  8. using Domain.Entities.Payments.ValueObject;
  9. using Domain.Entities.Wallets;
  10. using Microsoft.EntityFrameworkCore;
  11. namespace Application.Features.Api.Payment.Toss.Confirm;
  12. internal sealed class Handler(
  13. IAppDbContext db,
  14. ITossPaymentService tossPay,
  15. IAppDbContextFactory dbFactory
  16. ) : ICommandHandler<Command, Response>
  17. {
  18. public async Task<Response> Handle(Command r, CancellationToken ct)
  19. {
  20. // 주문 조회
  21. var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.OrderID == r.OrderID, ct);
  22. if (order is null)
  23. {
  24. return new Response(false, "", 0, "주문을 찾을 수 없습니다.", null);
  25. }
  26. // 이미 결제 완료된 주문인 경우 중복 승인 방지 (idempotent 응답)
  27. if (order.Status == PaymentStatus.Paid && order.MemberID == r.MemberID)
  28. {
  29. return new Response(true, order.Status.ToString(), order.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt);
  30. }
  31. // 회원 검증 — 불일치 시 주문 상태를 건드리지 않는다 (남의 주문 오염 공격 차단)
  32. if (order.MemberID != r.MemberID)
  33. {
  34. return new Response(false, order.Status.ToString(), 0, "주문 정보가 일치하지 않습니다.", null);
  35. }
  36. // 금액 검증 — 클라이언트 전달 amount 와 서버 저장 주문 금액 불일치 시 즉시 실패 (변조 차단, PG 호출 전)
  37. if (r.Amount != order.Amount)
  38. {
  39. await db.TossLog.AddAsync(TossLog.Create(r.MemberID, TossLogType.Failed, "AMOUNT_MISMATCH", $"금액 불일치: 요청={r.Amount}, 주문={order.Amount}", r.PaymentKey, r.OrderID), ct);
  40. await db.SaveChangesAsync(ct);
  41. return new Response(false, order.Status.ToString(), 0, "결제 금액이 주문 금액과 일치하지 않습니다.", null);
  42. }
  43. // 승인 가능 상태 가드 — Failed/Cancelled 재진입 차단, NeedsReconciliation 은 재확인(재승인) 경로 허용 (Phase 1.5)
  44. if (order.Status is not PaymentStatus.Pending and not PaymentStatus.WaitingDeposit and not PaymentStatus.NeedsReconciliation)
  45. {
  46. return new Response(false, order.Status.ToString(), 0, "승인할 수 없는 주문 상태입니다.", null);
  47. }
  48. // 승인 요청 기록 저장 — TossConfirm 은 PaymentKey/OrderID unique 라 주문당 1행.
  49. // 재확인(NeedsReconciliation, 또는 저장 실패 후 재시도)이면 기존 행을 재사용해 인덱스 충돌을 피한다. (Phase 1.5)
  50. var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct);
  51. if (confirm is null)
  52. {
  53. confirm = TossConfirm.CreateRequest(r.MemberID, order.ID, r.PaymentKey, r.OrderID, order.Amount);
  54. await db.TossConfirm.AddAsync(confirm, ct);
  55. }
  56. else
  57. {
  58. confirm.PrepareRetry(r.PaymentKey);
  59. }
  60. await db.SaveChangesAsync(ct);
  61. // 토스 승인 API 호출 — 서버 저장 금액 사용 (클라이언트 amount 는 검증용으로만)
  62. TossPaymentResult result;
  63. try
  64. {
  65. result = await tossPay.ConfirmAsync(r.PaymentKey, r.OrderID, order.Amount, ct);
  66. }
  67. catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
  68. {
  69. // 통신 불명(타임아웃 등) — 토스가 실제 승인했을 수 있어 Failed 로 단정하지 않고 대사 대상으로 표시. (Phase 1.5)
  70. // 호출측 취소와 무관하게 상태가 반드시 남아야 하므로 CancellationToken.None 으로 저장한다.
  71. confirm.SetError("EXCEPTION", ex.Message);
  72. order.MarkNeedsReconciliation("승인 통신 불명(타임아웃)");
  73. await db.TossLog.AddAsync(TossLog.Create(r.MemberID, TossLogType.Error, "EXCEPTION", ex.Message, r.PaymentKey, r.OrderID), CancellationToken.None);
  74. await db.SaveChangesAsync(CancellationToken.None);
  75. // 자동 대사 배치가 참조할 수 있게 대사 로그 독립 커밋
  76. await TryWriteReconcileLogAsync(order.ID, r.PaymentKey, order.PointAmount, $"CONFIRM 통신 불명: {ex.Message}");
  77. return new Response(false, order.Status.ToString(), 0, "결제 승인 확인이 지연되고 있습니다. 잠시 후 다시 시도해주세요.", null);
  78. }
  79. catch (Exception ex)
  80. {
  81. // 통신 이외의 예외(설정 오류 등)는 Failed — Pending 방치를 막는다
  82. confirm.SetError("EXCEPTION", ex.Message);
  83. order.MarkFailed("승인 처리 오류");
  84. await db.TossLog.AddAsync(TossLog.Create(r.MemberID, TossLogType.Error, "EXCEPTION", ex.Message, r.PaymentKey, r.OrderID), ct);
  85. await db.SaveChangesAsync(ct);
  86. return new Response(false, order.Status.ToString(), 0, "결제 처리 중 오류가 발생했습니다.", null);
  87. }
  88. if (!result.Success)
  89. {
  90. // 토스의 명시적 거절 응답 {code, message} — 불명이 아니므로 Failed
  91. confirm.SetError(result.Code ?? "FAIL", result.Message ?? "승인 실패");
  92. order.MarkFailed(result.Message ?? "승인 실패");
  93. await db.TossLog.AddAsync(TossLog.Create(r.MemberID, TossLogType.Failed, result.Code ?? "FAIL", result.Message ?? "승인 실패", r.PaymentKey, r.OrderID), ct);
  94. await db.SaveChangesAsync(ct);
  95. return new Response(false, order.Status.ToString(), 0, result.Message ?? "결제 승인에 실패했습니다.", null);
  96. }
  97. // 가상계좌 — 승인 응답 status=WAITING_FOR_DEPOSIT 는 입금 대기. 이 시점에 지갑 적립 금지,
  98. // DEPOSIT_CALLBACK 웹훅 후 조회 API 로 DONE 확인 후 적립한다.
  99. if (result.Status == "WAITING_FOR_DEPOSIT")
  100. {
  101. ApplyConfirmResponse(confirm, result);
  102. order.MarkWaitingDeposit(result.VirtualAccountNumber ?? "", result.VirtualAccountBankCode ?? "", result.VirtualAccountHolder ?? "", result.VirtualAccountDueDate ?? DateTime.UtcNow.AddDays(7));
  103. await db.SaveChangesAsync(ct);
  104. return new Response(true, order.Status.ToString(), 0, "가상계좌가 발급되었습니다. 입금 확인 후 충전됩니다.", null);
  105. }
  106. if (result.Status != "DONE")
  107. {
  108. // HTTP 성공인데 DONE/WAITING_FOR_DEPOSIT 이 아닌 상태 — 단정하지 않고 대사 대상 (보수적)
  109. ApplyConfirmResponse(confirm, result);
  110. order.MarkNeedsReconciliation($"승인 응답 상태 불명: {result.Status}");
  111. await db.SaveChangesAsync(ct);
  112. await TryWriteReconcileLogAsync(order.ID, r.PaymentKey, order.PointAmount, $"CONFIRM 응답 상태 불명: {result.Status}");
  113. return new Response(false, order.Status.ToString(), 0, "결제 상태 확인이 필요합니다. 잠시 후 다시 확인해주세요.", null);
  114. }
  115. // 응답 저장
  116. ApplyConfirmResponse(confirm, result);
  117. // 지갑에 캐시 충전 — 지갑이 없으면 생성해 승인 결과 유실을 막는다 (PG 는 이미 승인됨)
  118. var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  119. if (wallet is null)
  120. {
  121. wallet = Wallet.Create(order.MemberID);
  122. await db.Wallet.AddAsync(wallet, ct);
  123. }
  124. wallet.CreditPgCharge(Money.KRW(order.PointAmount), "캐시 충전 (Toss)", order.OrderID);
  125. // 주문 상태 갱신 — 지갑 적립(CreditPgCharge)과 단일 SaveChanges 로 원자 커밋 (실패 시 부분 반영 없음)
  126. order.MarkPaid(r.PaymentKey);
  127. try
  128. {
  129. await db.SaveChangesAsync(ct);
  130. }
  131. catch (DbUpdateConcurrencyException ex)
  132. {
  133. // Phase 1.5: WalletBalance rowversion 동시성 충돌 — 오염된 컨텍스트는 버리고(커밋된 것 없음)
  134. // 새 컨텍스트로 엔티티를 리로드해 도메인 연산을 1회 재적용한다.
  135. // 첫 시도의 원장 기록은 미영속이므로 재적용해도 이중적립이 없다.
  136. try
  137. {
  138. return await RetryConfirmPersistAsync(order.ID, confirm.ID, r.PaymentKey, result, ct);
  139. }
  140. catch (Exception retryEx)
  141. {
  142. await TryWriteReconcileLogAsync(order.ID, r.PaymentKey, order.PointAmount, $"CONFIRM_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
  143. throw;
  144. }
  145. }
  146. catch (Exception ex)
  147. {
  148. // Phase 1.5: PG 승인은 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤
  149. // 기존 실패 흐름(예외 전파)을 유지한다.
  150. await TryWriteReconcileLogAsync(order.ID, r.PaymentKey, order.PointAmount, $"CONFIRM_PERSIST 실패: {ex.Message}");
  151. throw;
  152. }
  153. return new Response(true, order.Status.ToString(), order.PointAmount, "충전이 완료되었습니다.", order.PaidAt);
  154. }
  155. /// <summary>동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 승인 반영을 재적용한다. (Phase 1.5)</summary>
  156. private async Task<Response> RetryConfirmPersistAsync(int paymentOrderID, int confirmID, string paymentKey, TossPaymentResult result, CancellationToken ct)
  157. {
  158. await using var retryDb = dbFactory.CreateDbContext();
  159. var order = await retryDb.PaymentOrder.FirstAsync(c => c.ID == paymentOrderID, ct);
  160. if (order.Status == PaymentStatus.Paid)
  161. {
  162. // 동시 요청이 이미 승인 반영을 끝낸 경우 — 이중적립 금지, idempotent 성공 응답
  163. return new Response(true, order.Status.ToString(), order.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt);
  164. }
  165. var confirm = await retryDb.TossConfirm.FirstAsync(c => c.ID == confirmID, ct);
  166. ApplyConfirmResponse(confirm, result);
  167. var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  168. if (wallet is null)
  169. {
  170. wallet = Wallet.Create(order.MemberID);
  171. await retryDb.Wallet.AddAsync(wallet, ct);
  172. }
  173. wallet.CreditPgCharge(Money.KRW(order.PointAmount), "캐시 충전 (Toss)", order.OrderID);
  174. order.MarkPaid(paymentKey);
  175. await retryDb.SaveChangesAsync(ct);
  176. return new Response(true, order.Status.ToString(), order.PointAmount, "충전이 완료되었습니다.", order.PaidAt);
  177. }
  178. /// <summary>대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5)</summary>
  179. private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason)
  180. {
  181. try
  182. {
  183. // 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None
  184. await using var logDb = dbFactory.CreateDbContext();
  185. await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.Confirm, paymentOrderID, paymentKey, amount, reason), CancellationToken.None);
  186. await logDb.SaveChangesAsync(CancellationToken.None);
  187. }
  188. catch
  189. {
  190. // best-effort — 원래 예외를 유지한다
  191. }
  192. }
  193. private static void ApplyConfirmResponse(TossConfirm confirm, TossPaymentResult result)
  194. {
  195. confirm.SetResponse(result.Status ?? "", result.Method, result.TotalAmount, result.ApprovedAt, result.ReceiptUrl, result.RawJson);
  196. }
  197. }