Handler.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  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.Danal;
  7. using Domain.Entities.Payments.Danal.ValueObject;
  8. using Domain.Entities.Payments.ValueObject;
  9. using Domain.Entities.Wallets;
  10. using Microsoft.EntityFrameworkCore;
  11. namespace Application.Features.Api.Payment.Confirm;
  12. internal sealed class Handler(
  13. IAppDbContext db,
  14. IDanalPayService danalPay,
  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(o => o.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.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt);
  30. }
  31. // 회원 검증 — 불일치 시 주문 상태를 건드리지 않는다.
  32. // (MarkFailed 로 남의 Pending 주문을 오염시켜 이후 정상 승인을 MarkPaid 크래시로 유도하는 공격 차단)
  33. if (order.MemberID != r.MemberID)
  34. {
  35. return new Response(false, 0, "주문 정보가 일치하지 않습니다.", null);
  36. }
  37. // 승인 가능 상태 가드 — Pending/WaitingDeposit 승인 진행. Failed/Cancelled 재진입은 PG 재호출 후
  38. // MarkPaid 예외(승인금 유실)를 유발하므로 여기서 차단 (실패 주문은 새 주문으로 재시도).
  39. // NeedsReconciliation(승인 통신 불명)은 재확인(재승인) 경로로 허용한다. (Phase 1.5)
  40. if (order.Status is not PaymentStatus.Pending and not PaymentStatus.WaitingDeposit and not PaymentStatus.NeedsReconciliation)
  41. {
  42. return new Response(false, 0, "승인할 수 없는 주문 상태입니다.", null);
  43. }
  44. // 승인 요청 기록 저장 — DanalConfirm 은 TransactionID/OrderID unique 라 주문당 1행.
  45. // 재확인(NeedsReconciliation, 또는 저장 실패 후 재시도)이면 기존 행을 재사용해 인덱스 충돌을 피한다. (Phase 1.5)
  46. var confirm = await db.DanalConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct);
  47. if (confirm is null)
  48. {
  49. confirm = DanalConfirm.CreateRequest(r.MemberID, order.ID, order.PaymentMethod, r.TransactionID, r.OrderID, order.Amount, order.MerchantID);
  50. await db.DanalConfirm.AddAsync(confirm, ct);
  51. }
  52. else
  53. {
  54. confirm.PrepareRetry(r.TransactionID);
  55. }
  56. await db.SaveChangesAsync(ct);
  57. // 다날 승인 API 호출
  58. DanalConfirmResult result;
  59. try
  60. {
  61. result = await danalPay.ConfirmAsync(r.Method, r.TransactionID, r.OrderID, order.Amount, ct);
  62. }
  63. catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
  64. {
  65. // 통신 불명(타임아웃 등) — 다날이 실제 승인했을 수 있어 Failed 로 단정하지 않고 대사 대상으로 표시. (Phase 1.5)
  66. // 호출측 취소와 무관하게 상태가 반드시 남아야 하므로 CancellationToken.None 으로 저장한다.
  67. confirm.SetResponse("EXCEPTION", ex.Message);
  68. order.MarkNeedsReconciliation("승인 통신 불명(타임아웃)");
  69. await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Error, "EXCEPTION", ex.Message, r.TransactionID, r.OrderID), CancellationToken.None);
  70. await db.SaveChangesAsync(CancellationToken.None);
  71. return new Response(false, 0, "결제 승인 확인이 지연되고 있습니다. 잠시 후 다시 시도해주세요.", null);
  72. }
  73. catch (Exception ex)
  74. {
  75. // 통신 이외의 예외(설정 오류 등)는 기존대로 Failed — Pending 방치를 막는다
  76. confirm.SetResponse("EXCEPTION", ex.Message);
  77. order.MarkFailed("승인 통신 오류");
  78. await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Error, "EXCEPTION", ex.Message, r.TransactionID, r.OrderID), ct);
  79. await db.SaveChangesAsync(ct);
  80. return new Response(false, 0, "결제 처리 중 오류가 발생했습니다.", null);
  81. }
  82. if (!result.Success)
  83. {
  84. // PG 의 명시적 거절 — 불명이 아니므로 기존대로 Failed
  85. confirm.SetResponse(result.Code ?? "", result.Message ?? "승인 실패");
  86. order.MarkFailed(result.Message ?? "승인 실패");
  87. await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Failed, result.Code ?? "FAIL", result.Message ?? "승인 실패", r.TransactionID, r.OrderID), ct);
  88. await db.SaveChangesAsync(ct);
  89. return new Response(false, 0, result.Message ?? "결제 승인에 실패했습니다.", null);
  90. }
  91. // 응답 저장
  92. ApplyConfirmResponse(confirm, result);
  93. // 지갑에 포인트 충전 — 지갑이 없으면 생성해 승인 결과 유실을 막는다 (PG 는 이미 승인됨)
  94. var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  95. if (wallet is null)
  96. {
  97. wallet = Wallet.Create(order.MemberID);
  98. await db.Wallet.AddAsync(wallet, ct);
  99. }
  100. wallet.CreditPgCharge(Money.KRW(order.PointAmount), $"포인트 충전 ({order.PaymentMethod})", order.OrderID);
  101. // 주문 상태 갱신 — 지갑 적립(CreditPgCharge)과 단일 SaveChanges 로 원자 커밋 (실패 시 부분 반영 없음)
  102. order.MarkPaid(r.TransactionID);
  103. try
  104. {
  105. await db.SaveChangesAsync(ct);
  106. }
  107. catch (DbUpdateConcurrencyException ex)
  108. {
  109. // Phase 1.5: WalletBalance rowversion 동시성 충돌 — 오염된 컨텍스트는 버리고(커밋된 것 없음)
  110. // 새 컨텍스트로 엔티티를 리로드해 도메인 연산을 1회 재적용한다.
  111. // 첫 시도의 원장 기록은 미영속이므로 재적용해도 이중적립이 없다.
  112. try
  113. {
  114. return await RetryConfirmPersistAsync(order.ID, confirm.ID, r.TransactionID, result, ct);
  115. }
  116. catch (Exception retryEx)
  117. {
  118. await TryWriteReconcileLogAsync(order.ID, r.TransactionID, order.PointAmount, $"CONFIRM_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
  119. throw;
  120. }
  121. }
  122. catch (Exception ex)
  123. {
  124. // Phase 1.5: PG 승인은 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤
  125. // 기존 실패 흐름(예외 전파)을 유지한다.
  126. await TryWriteReconcileLogAsync(order.ID, r.TransactionID, order.PointAmount, $"CONFIRM_PERSIST 실패: {ex.Message}");
  127. throw;
  128. }
  129. return new Response(true, order.PointAmount, "충전이 완료되었습니다.", order.PaidAt);
  130. }
  131. /// <summary>동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 승인 반영을 재적용한다. (Phase 1.5)</summary>
  132. private async Task<Response> RetryConfirmPersistAsync(int paymentOrderID, int confirmID, string transactionID, DanalConfirmResult result, CancellationToken ct)
  133. {
  134. await using var retryDb = dbFactory.CreateDbContext();
  135. var order = await retryDb.PaymentOrder.FirstAsync(o => o.ID == paymentOrderID, ct);
  136. if (order.Status == PaymentStatus.Paid)
  137. {
  138. // 동시 요청이 이미 승인 반영을 끝낸 경우 — 이중적립 금지, idempotent 성공 응답
  139. return new Response(true, order.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt);
  140. }
  141. var confirm = await retryDb.DanalConfirm.FirstAsync(c => c.ID == confirmID, ct);
  142. ApplyConfirmResponse(confirm, result);
  143. var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  144. if (wallet is null)
  145. {
  146. wallet = Wallet.Create(order.MemberID);
  147. await retryDb.Wallet.AddAsync(wallet, ct);
  148. }
  149. wallet.CreditPgCharge(Money.KRW(order.PointAmount), $"포인트 충전 ({order.PaymentMethod})", order.OrderID);
  150. order.MarkPaid(transactionID);
  151. await retryDb.SaveChangesAsync(ct);
  152. return new Response(true, order.PointAmount, "충전이 완료되었습니다.", order.PaidAt);
  153. }
  154. /// <summary>대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5)</summary>
  155. private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? transactionID, int amount, string reason)
  156. {
  157. try
  158. {
  159. // 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None
  160. await using var logDb = dbFactory.CreateDbContext();
  161. await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.Confirm, paymentOrderID, transactionID, amount, reason), CancellationToken.None);
  162. await logDb.SaveChangesAsync(CancellationToken.None);
  163. }
  164. catch
  165. {
  166. // best-effort — 원래 예외를 유지한다
  167. }
  168. }
  169. private static void ApplyConfirmResponse(DanalConfirm confirm, DanalConfirmResult result)
  170. {
  171. confirm.SetResponse(
  172. result.Code ?? "", result.Message ?? "",
  173. result.OrderName, result.TotalAmount, result.DiscountAmount, result.UserName,
  174. result.TransDate, result.TransTime,
  175. result.CardCode, result.CardName, result.CardNo,
  176. result.InstallmentMonths, result.ApproveNo,
  177. result.ApprovalDateTime, result.AuthKey,
  178. result.AccountNumber, result.BankCode, result.UserId, result.UserEmail,
  179. result.BankName, result.ExpireDate, result.ExpireTime,
  180. result.VirtualAccountNumber, result.UseCashReceipt
  181. );
  182. }
  183. }