using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.Payment; using Domain.Entities.Common.ValueObject; using Domain.Entities.Payments; using Domain.Entities.Payments.Danal; using Domain.Entities.Payments.Danal.ValueObject; using Domain.Entities.Payments.ValueObject; using Domain.Entities.Wallets; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Payment.Confirm; internal sealed class Handler( IAppDbContext db, IDanalPayService danalPay, IAppDbContextFactory dbFactory ) : ICommandHandler { public async Task 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 == 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 예외(승인금 유실)를 유발하므로 여기서 차단 (실패 주문은 새 주문으로 재시도). // NeedsReconciliation(승인 통신 불명)은 재확인(재승인) 경로로 허용한다. (Phase 1.5) if (order.Status is not PaymentStatus.Pending and not PaymentStatus.WaitingDeposit and not PaymentStatus.NeedsReconciliation) { return new Response(false, 0, "승인할 수 없는 주문 상태입니다.", null); } // 승인 요청 기록 저장 — DanalConfirm 은 TransactionID/OrderID unique 라 주문당 1행. // 재확인(NeedsReconciliation, 또는 저장 실패 후 재시도)이면 기존 행을 재사용해 인덱스 충돌을 피한다. (Phase 1.5) var confirm = await db.DanalConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct); if (confirm is null) { confirm = DanalConfirm.CreateRequest(r.MemberID, order.ID, order.PaymentMethod, r.TransactionID, r.OrderID, order.Amount, order.MerchantID); await db.DanalConfirm.AddAsync(confirm, ct); } else { confirm.PrepareRetry(r.TransactionID); } await db.SaveChangesAsync(ct); // 다날 승인 API 호출 DanalConfirmResult result; try { result = await danalPay.ConfirmAsync(r.Method, r.TransactionID, r.OrderID, order.Amount, ct); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { // 통신 불명(타임아웃 등) — 다날이 실제 승인했을 수 있어 Failed 로 단정하지 않고 대사 대상으로 표시. (Phase 1.5) // 호출측 취소와 무관하게 상태가 반드시 남아야 하므로 CancellationToken.None 으로 저장한다. confirm.SetResponse("EXCEPTION", ex.Message); order.MarkNeedsReconciliation("승인 통신 불명(타임아웃)"); await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Error, "EXCEPTION", ex.Message, r.TransactionID, r.OrderID), CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None); return new Response(false, 0, "결제 승인 확인이 지연되고 있습니다. 잠시 후 다시 시도해주세요.", null); } catch (Exception ex) { // 통신 이외의 예외(설정 오류 등)는 기존대로 Failed — Pending 방치를 막는다 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) { // PG 의 명시적 거절 — 불명이 아니므로 기존대로 Failed 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); } // 응답 저장 ApplyConfirmResponse(confirm, result); // 지갑에 포인트 충전 — 지갑이 없으면 생성해 승인 결과 유실을 막는다 (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); try { await db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException ex) { // Phase 1.5: WalletBalance rowversion 동시성 충돌 — 오염된 컨텍스트는 버리고(커밋된 것 없음) // 새 컨텍스트로 엔티티를 리로드해 도메인 연산을 1회 재적용한다. // 첫 시도의 원장 기록은 미영속이므로 재적용해도 이중적립이 없다. try { return await RetryConfirmPersistAsync(order.ID, confirm.ID, r.TransactionID, result, ct); } catch (Exception retryEx) { await TryWriteReconcileLogAsync(order.ID, r.TransactionID, order.PointAmount, $"CONFIRM_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})"); throw; } } catch (Exception ex) { // Phase 1.5: PG 승인은 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤 // 기존 실패 흐름(예외 전파)을 유지한다. await TryWriteReconcileLogAsync(order.ID, r.TransactionID, order.PointAmount, $"CONFIRM_PERSIST 실패: {ex.Message}"); throw; } return new Response(true, order.PointAmount, "충전이 완료되었습니다.", order.PaidAt); } /// 동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 승인 반영을 재적용한다. (Phase 1.5) private async Task RetryConfirmPersistAsync(int paymentOrderID, int confirmID, string transactionID, DanalConfirmResult result, CancellationToken ct) { await using var retryDb = dbFactory.CreateDbContext(); var order = await retryDb.PaymentOrder.FirstAsync(o => o.ID == paymentOrderID, ct); if (order.Status == PaymentStatus.Paid) { // 동시 요청이 이미 승인 반영을 끝낸 경우 — 이중적립 금지, idempotent 성공 응답 return new Response(true, order.PointAmount, "이미 충전이 완료된 주문입니다.", order.PaidAt); } var confirm = await retryDb.DanalConfirm.FirstAsync(c => c.ID == confirmID, ct); ApplyConfirmResponse(confirm, result); var wallet = await retryDb.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 retryDb.Wallet.AddAsync(wallet, ct); } wallet.CreditPgCharge(Money.KRW(order.PointAmount), $"포인트 충전 ({order.PaymentMethod})", order.OrderID); order.MarkPaid(transactionID); await retryDb.SaveChangesAsync(ct); return new Response(true, order.PointAmount, "충전이 완료되었습니다.", order.PaidAt); } /// 대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5) private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? transactionID, int amount, string reason) { try { // 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None await using var logDb = dbFactory.CreateDbContext(); await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.Confirm, paymentOrderID, transactionID, amount, reason), CancellationToken.None); await logDb.SaveChangesAsync(CancellationToken.None); } catch { // best-effort — 원래 예외를 유지한다 } } private static void ApplyConfirmResponse(DanalConfirm confirm, DanalConfirmResult result) { 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 ); } }