Handler.cs 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  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.Danal;
  6. using Domain.Entities.Payments.Danal.ValueObject;
  7. using Domain.Entities.Wallets;
  8. using Microsoft.EntityFrameworkCore;
  9. namespace Application.Features.Api.Payment.Confirm;
  10. internal sealed class Handler(
  11. IAppDbContext db,
  12. IDanalPayService danalPay
  13. ) : ICommandHandler<Command, Response>
  14. {
  15. public async Task<Response> Handle(Command r, CancellationToken ct)
  16. {
  17. // 주문 조회
  18. var order = await db.PaymentOrder.FirstOrDefaultAsync(o => o.OrderID == r.OrderID, ct);
  19. if (order is null)
  20. {
  21. return new Response(false, 0, "주문을 찾을 수 없습니다.", null);
  22. }
  23. // 이미 결제 완료된 주문인 경우 중복 승인 방지 (회원 검증 이후로 미룰 수 없음 — idempotent 응답)
  24. if (order.Status == Domain.Entities.Payments.ValueObject.PaymentStatus.Paid && order.MemberID == r.MemberID)
  25. {
  26. return new Response(true, order.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt);
  27. }
  28. // 회원 검증 — 불일치 시 주문 상태를 건드리지 않는다.
  29. // (MarkFailed 로 남의 Pending 주문을 오염시켜 이후 정상 승인을 MarkPaid 크래시로 유도하는 공격 차단)
  30. if (order.MemberID != r.MemberID)
  31. {
  32. return new Response(false, 0, "주문 정보가 일치하지 않습니다.", null);
  33. }
  34. // 승인 가능 상태 가드 — Pending/WaitingDeposit 만 승인 진행.
  35. // Failed/Cancelled 재진입은 PG 재호출 후 MarkPaid 예외(승인금 유실)를 유발하므로 여기서 차단.
  36. // 실패한 주문은 새 주문(OrderID)으로 재시도해야 한다.
  37. if (order.Status is not Domain.Entities.Payments.ValueObject.PaymentStatus.Pending
  38. and not Domain.Entities.Payments.ValueObject.PaymentStatus.WaitingDeposit)
  39. {
  40. return new Response(false, 0, "승인할 수 없는 주문 상태입니다.", null);
  41. }
  42. // 승인 요청 기록 저장
  43. var confirm = DanalConfirm.CreateRequest(r.MemberID, order.ID, order.PaymentMethod, r.TransactionID, r.OrderID, order.Amount, order.MerchantID);
  44. await db.DanalConfirm.AddAsync(confirm, ct);
  45. await db.SaveChangesAsync(ct);
  46. // 다날 승인 API 호출 — 통신 예외는 주문 Failed 로 기록해 Pending 방치를 막는다
  47. DanalConfirmResult result;
  48. try
  49. {
  50. result = await danalPay.ConfirmAsync(r.Method, r.TransactionID, r.OrderID, order.Amount, ct);
  51. }
  52. catch (Exception ex)
  53. {
  54. confirm.SetResponse("EXCEPTION", ex.Message);
  55. order.MarkFailed("승인 통신 오류");
  56. await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Error, "EXCEPTION", ex.Message, r.TransactionID, r.OrderID), ct);
  57. await db.SaveChangesAsync(ct);
  58. return new Response(false, 0, "결제 처리 중 오류가 발생했습니다.", null);
  59. }
  60. if (!result.Success)
  61. {
  62. confirm.SetResponse(result.Code ?? "", result.Message ?? "승인 실패");
  63. order.MarkFailed(result.Message ?? "승인 실패");
  64. await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Failed, result.Code ?? "FAIL", result.Message ?? "승인 실패", r.TransactionID, r.OrderID), ct);
  65. await db.SaveChangesAsync(ct);
  66. return new Response(false, 0, result.Message ?? "결제 승인에 실패했습니다.", null);
  67. }
  68. // 응답 저장
  69. confirm.SetResponse(
  70. result.Code ?? "", result.Message ?? "",
  71. result.OrderName, result.TotalAmount, result.DiscountAmount, result.UserName,
  72. result.TransDate, result.TransTime,
  73. result.CardCode, result.CardName, result.CardNo,
  74. result.InstallmentMonths, result.ApproveNo,
  75. result.ApprovalDateTime, result.AuthKey,
  76. result.AccountNumber, result.BankCode, result.UserId, result.UserEmail,
  77. result.BankName, result.ExpireDate, result.ExpireTime,
  78. result.VirtualAccountNumber, result.UseCashReceipt
  79. );
  80. // 지갑에 포인트 충전 — 지갑이 없으면 생성해 승인 결과 유실을 막는다 (PG 는 이미 승인됨)
  81. var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  82. if (wallet is null)
  83. {
  84. wallet = Wallet.Create(order.MemberID);
  85. await db.Wallet.AddAsync(wallet, ct);
  86. }
  87. wallet.CreditPgCharge(Money.KRW(order.PointAmount), $"포인트 충전 ({order.PaymentMethod})", order.OrderID);
  88. // 주문 상태 갱신 — 지갑 적립(CreditPgCharge)과 단일 SaveChanges 로 원자 커밋.
  89. order.MarkPaid(r.TransactionID);
  90. // NOTE(Phase 1.5): PG 승인 성공 후 이 저장이 실패(예: WalletBalance rowversion 동시성 충돌)하면
  91. // 예외가 전파되어 500 이 나가고 충전/Paid 가 미영속된다. PG 는 이미 승인된 상태이므로
  92. // 대사(reconciliation)가 필요하나, 신뢰할 수 있는 out-of-band 기록에는 별도 DbContext(Factory)가
  93. // 필요하다. 여기서 같은 컨텍스트로 재시도/로그하면 충돌이 반복돼 로그까지 유실되므로 하지 않는다.
  94. // PG 호출 전 저장된 DanalConfirm 요청 행이 시도 흔적으로 남는다.
  95. await db.SaveChangesAsync(ct);
  96. return new Response(true, order.PointAmount, "충전이 완료되었습니다.", order.PaidAt);
  97. }
  98. }