| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117 |
- using Application.Abstractions.Data;
- using Application.Abstractions.Messaging;
- using Application.Abstractions.Payment;
- using Domain.Entities.Common.ValueObject;
- using Domain.Entities.Payments.Danal;
- using Domain.Entities.Payments.Danal.ValueObject;
- using Domain.Entities.Wallets;
- using Microsoft.EntityFrameworkCore;
- namespace Application.Features.Api.Payment.Confirm;
- internal sealed class Handler(
- IAppDbContext db,
- IDanalPayService danalPay
- ) : ICommandHandler<Command, Response>
- {
- public async Task<Response> Handle(Command r, CancellationToken ct)
- {
- // 주문 조회
- var order = await db.PaymentOrder.FirstOrDefaultAsync(o => o.OrderID == r.OrderID, ct);
- if (order is null)
- {
- return new Response(false, 0, "주문을 찾을 수 없습니다.", null);
- }
- // 이미 결제 완료된 주문인 경우 중복 승인 방지 (회원 검증 이후로 미룰 수 없음 — idempotent 응답)
- if (order.Status == Domain.Entities.Payments.ValueObject.PaymentStatus.Paid && order.MemberID == r.MemberID)
- {
- return new Response(true, order.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt);
- }
- // 회원 검증 — 불일치 시 주문 상태를 건드리지 않는다.
- // (MarkFailed 로 남의 Pending 주문을 오염시켜 이후 정상 승인을 MarkPaid 크래시로 유도하는 공격 차단)
- if (order.MemberID != r.MemberID)
- {
- return new Response(false, 0, "주문 정보가 일치하지 않습니다.", null);
- }
- // 승인 가능 상태 가드 — Pending/WaitingDeposit 만 승인 진행.
- // Failed/Cancelled 재진입은 PG 재호출 후 MarkPaid 예외(승인금 유실)를 유발하므로 여기서 차단.
- // 실패한 주문은 새 주문(OrderID)으로 재시도해야 한다.
- if (order.Status is not Domain.Entities.Payments.ValueObject.PaymentStatus.Pending
- and not Domain.Entities.Payments.ValueObject.PaymentStatus.WaitingDeposit)
- {
- return new Response(false, 0, "승인할 수 없는 주문 상태입니다.", null);
- }
- // 승인 요청 기록 저장
- var confirm = DanalConfirm.CreateRequest(r.MemberID, order.ID, order.PaymentMethod, r.TransactionID, r.OrderID, order.Amount, order.MerchantID);
- await db.DanalConfirm.AddAsync(confirm, ct);
- await db.SaveChangesAsync(ct);
- // 다날 승인 API 호출 — 통신 예외는 주문 Failed 로 기록해 Pending 방치를 막는다
- DanalConfirmResult result;
- try
- {
- result = await danalPay.ConfirmAsync(r.Method, r.TransactionID, r.OrderID, order.Amount, ct);
- }
- catch (Exception ex)
- {
- confirm.SetResponse("EXCEPTION", ex.Message);
- order.MarkFailed("승인 통신 오류");
- await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Error, "EXCEPTION", ex.Message, r.TransactionID, r.OrderID), ct);
- await db.SaveChangesAsync(ct);
- return new Response(false, 0, "결제 처리 중 오류가 발생했습니다.", null);
- }
- if (!result.Success)
- {
- confirm.SetResponse(result.Code ?? "", result.Message ?? "승인 실패");
- order.MarkFailed(result.Message ?? "승인 실패");
- await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Failed, result.Code ?? "FAIL", result.Message ?? "승인 실패", r.TransactionID, r.OrderID), ct);
- await db.SaveChangesAsync(ct);
- return new Response(false, 0, result.Message ?? "결제 승인에 실패했습니다.", null);
- }
- // 응답 저장
- confirm.SetResponse(
- result.Code ?? "", result.Message ?? "",
- result.OrderName, result.TotalAmount, result.DiscountAmount, result.UserName,
- result.TransDate, result.TransTime,
- result.CardCode, result.CardName, result.CardNo,
- result.InstallmentMonths, result.ApproveNo,
- result.ApprovalDateTime, result.AuthKey,
- result.AccountNumber, result.BankCode, result.UserId, result.UserEmail,
- result.BankName, result.ExpireDate, result.ExpireTime,
- result.VirtualAccountNumber, result.UseCashReceipt
- );
- // 지갑에 포인트 충전 — 지갑이 없으면 생성해 승인 결과 유실을 막는다 (PG 는 이미 승인됨)
- var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
- if (wallet is null)
- {
- wallet = Wallet.Create(order.MemberID);
- await db.Wallet.AddAsync(wallet, ct);
- }
- wallet.CreditPgCharge(Money.KRW(order.PointAmount), $"포인트 충전 ({order.PaymentMethod})", order.OrderID);
- // 주문 상태 갱신 — 지갑 적립(CreditPgCharge)과 단일 SaveChanges 로 원자 커밋.
- order.MarkPaid(r.TransactionID);
- // NOTE(Phase 1.5): PG 승인 성공 후 이 저장이 실패(예: WalletBalance rowversion 동시성 충돌)하면
- // 예외가 전파되어 500 이 나가고 충전/Paid 가 미영속된다. PG 는 이미 승인된 상태이므로
- // 대사(reconciliation)가 필요하나, 신뢰할 수 있는 out-of-band 기록에는 별도 DbContext(Factory)가
- // 필요하다. 여기서 같은 컨텍스트로 재시도/로그하면 충돌이 반복돼 로그까지 유실되므로 하지 않는다.
- // PG 호출 전 저장된 DanalConfirm 요청 행이 시도 흔적으로 남는다.
- await db.SaveChangesAsync(ct);
- return new Response(true, order.PointAmount, "충전이 완료되었습니다.", order.PaidAt);
- }
- }
|