| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261 |
- 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.Toss;
- using Domain.Entities.Payments.Toss.ValueObject;
- using Domain.Entities.Payments.ValueObject;
- using Domain.Entities.Wallets;
- using Domain.Entities.Wallets.ValueObject;
- using Microsoft.EntityFrameworkCore;
- using SharedKernel.Results;
- namespace Application.Features.Api.Payment.Toss.Webhook;
- internal sealed class Handler(
- IAppDbContext db,
- ITossPaymentService tossPay,
- IAppDbContextFactory dbFactory
- ) : ICommandHandler<Command, Result>
- {
- public async Task<Result> Handle(Command r, CancellationToken ct)
- {
- // paymentKey 확보 — DEPOSIT_CALLBACK 은 본문에 paymentKey 가 없어 orderId 로 TossConfirm 에서 보강
- var paymentKey = r.PaymentKey;
- if (string.IsNullOrEmpty(paymentKey) && !string.IsNullOrEmpty(r.OrderID))
- {
- var confirmRow = await db.TossConfirm.FirstOrDefaultAsync(c => c.OrderID == r.OrderID, ct);
- paymentKey = confirmRow?.PaymentKey;
- }
- if (string.IsNullOrEmpty(paymentKey))
- {
- // 판별 불가 — 로그만 남기고 정상 응답 (재시도 무의미)
- await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, "IGNORED", $"paymentKey 판별 불가 (eventType={r.EventType})", null, r.OrderID), ct);
- await db.SaveChangesAsync(ct);
- return Result.Success();
- }
- // 웹훅 본문 신뢰 금지 — 조회 API 재조회 결과만 반영
- TossPaymentResult payment;
- try
- {
- payment = await tossPay.GetPaymentAsync(paymentKey, ct);
- }
- catch (Exception ex)
- {
- // 조회 실패 — 실패 응답으로 토스 재시도 유도
- await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Error, "EXCEPTION", $"웹훅 재조회 실패: {ex.Message}", paymentKey, r.OrderID), CancellationToken.None);
- await db.SaveChangesAsync(CancellationToken.None);
- return Result.Failure(Error.Problem("Payment.WebhookRequeryFailed", "결제 재조회에 실패했습니다."));
- }
- if (!payment.Success || string.IsNullOrEmpty(payment.OrderID))
- {
- await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, payment.Code ?? "IGNORED", $"재조회 결과 처리 불가 (eventType={r.EventType}): {payment.Message}", paymentKey, r.OrderID), ct);
- await db.SaveChangesAsync(ct);
- return Result.Success();
- }
- // 조회 결과의 orderId 기준으로 주문 확정 (본문 orderId 미신뢰)
- var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.OrderID == payment.OrderID, ct);
- if (order is null)
- {
- await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, "ORDER_NOT_FOUND", $"주문 없음 (eventType={r.EventType})", paymentKey, payment.OrderID), ct);
- await db.SaveChangesAsync(ct);
- return Result.Success();
- }
- return payment.Status switch
- {
- "DONE" => await HandleDoneAsync(order, paymentKey, payment, r.EventType, ct),
- "CANCELED" or "PARTIAL_CANCELED" => await HandleCanceledAsync(order, paymentKey, payment, r.EventType, ct),
- "ABORTED" or "EXPIRED" => await HandleAbortedAsync(order, paymentKey, payment, ct),
- _ => await HandleUnknownAsync(order, paymentKey, payment, r.EventType, ct)
- };
- }
- /// <summary>DONE — 승인/입금 완료. 미적립이면 멱등 적립 (가상계좌 DEPOSIT_CALLBACK 포함)</summary>
- private async Task<Result> HandleDoneAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct)
- {
- if (order.Status == PaymentStatus.Paid)
- {
- // 이미 적립 완료 — 멱등, 중복 적립 금지
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "ALREADY_PAID", $"이미 처리된 주문 (eventType={eventType})", paymentKey, order.OrderID), ct);
- await db.SaveChangesAsync(ct);
- return Result.Success();
- }
- if (order.Status is not PaymentStatus.Pending and not PaymentStatus.WaitingDeposit and not PaymentStatus.NeedsReconciliation)
- {
- // Failed/Cancelled 주문에 DONE 웹훅 — 자동 반영하지 않고 대사 대상 기록 (보수적)
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "STATE_MISMATCH", $"주문 상태 {order.Status} 에 DONE 웹훅", paymentKey, order.OrderID), ct);
- await db.SaveChangesAsync(ct);
- await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 DONE — 주문 상태 {order.Status} 불일치, 수동 확인 필요");
- return Result.Success();
- }
- // 금액 대조 — 조회 결과 totalAmount 와 서버 저장 주문 금액 불일치 시 적립하지 않는다
- if (payment.TotalAmount != order.Amount)
- {
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "AMOUNT_MISMATCH", $"금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}", paymentKey, order.OrderID), ct);
- await db.SaveChangesAsync(ct);
- await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 DONE 금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}");
- return Result.Success();
- }
- // 승인 기록 갱신 — TossConfirm 행이 없으면 생성 (unique 충돌 방지 위해 기존 행 재사용)
- var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct);
- if (confirm is null)
- {
- confirm = TossConfirm.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount);
- await db.TossConfirm.AddAsync(confirm, ct);
- }
- confirm.SetResponse(payment.Status ?? "", payment.Method, payment.TotalAmount, payment.ApprovedAt, payment.ReceiptUrl, payment.RawJson);
- // 지갑 적립 (멱등 — 위 Paid 가드로 중복 차단) + 주문 완료 단일 커밋
- 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), "캐시 충전 (Toss)", order.OrderID);
- order.MarkPaid(paymentKey);
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "SUCCESS", $"입금/승인 완료 반영 (eventType={eventType})", paymentKey, order.OrderID), ct);
- try
- {
- await db.SaveChangesAsync(ct);
- }
- catch (Exception ex)
- {
- // PG 는 이미 DONE — 저장 실패는 대사 로그 독립 커밋 후 실패 응답 (토스 재시도로 재처리)
- await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"WEBHOOK_PERSIST 실패: {ex.Message}");
- return Result.Failure(Error.Problem("Payment.WebhookPersistFailed", "웹훅 반영 저장에 실패했습니다."));
- }
- return Result.Success();
- }
- /// <summary>CANCELED — 토스 측 취소 (관리자콘솔 취소 등). Paid 주문이면 지갑 회수 반영</summary>
- private async Task<Result> HandleCanceledAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct)
- {
- if (order.Status == PaymentStatus.Cancelled)
- {
- // 이미 취소 반영 완료 — 멱등
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "ALREADY_CANCELLED", $"이미 취소된 주문 (eventType={eventType})", paymentKey, order.OrderID), ct);
- await db.SaveChangesAsync(ct);
- return Result.Success();
- }
- // 미적립 주문(Pending/WaitingDeposit/NeedsReconciliation)의 취소 — 적립된 것이 없으므로 Failed 처리로 종결
- if (order.Status != PaymentStatus.Paid)
- {
- order.MarkFailed($"토스 취소 웹훅 (status={payment.Status})");
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "CANCELED_BEFORE_PAID", $"미적립 주문 취소 종결 (eventType={eventType})", paymentKey, order.OrderID), ct);
- await db.SaveChangesAsync(ct);
- return Result.Success();
- }
- // 부분취소는 자동 반영하지 않는다 — 수동 대사 대상 (보수적)
- if (payment.Status == "PARTIAL_CANCELED")
- {
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "PARTIAL_CANCELED", "부분취소 웹훅 — 자동 반영 안 함", paymentKey, order.OrderID), ct);
- await db.SaveChangesAsync(ct);
- await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, "웹훅 PARTIAL_CANCELED — 수동 확인 필요");
- return Result.Success();
- }
- // 회수 가능성 검증 — 이미 사용한 캐시가 있으면 자동 회수 불가, 수동 대사 대상
- var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
- var pgCharged = wallet?.Balances.Where(b => b.Type == WalletBalanceType.PgCharged).Sum(b => b.Amount.Value) ?? 0;
- if (wallet is null || pgCharged < order.PointAmount)
- {
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "REFUND_BLOCKED", $"잔액 부족으로 자동 회수 불가 (PgCharged={pgCharged}, 필요={order.PointAmount})", paymentKey, order.OrderID), ct);
- await db.SaveChangesAsync(ct);
- await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 CANCELED — PG 취소 완료됐으나 잔액 부족으로 자동 회수 불가 (PgCharged={pgCharged})");
- return Result.Success();
- }
- // 취소 기록 (webhook 발) + 주문 취소 + 지갑 회수 단일 커밋
- var last = payment.Cancels.Count > 0 ? payment.Cancels[^1] : null;
- var cancel = TossCancel.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount, last?.CancelReason ?? "토스 측 취소(웹훅)", "webhook");
- cancel.SetResponse(payment.Status ?? "", last?.TransactionKey, last?.CanceledAt, payment.RawJson);
- await db.TossCancel.AddAsync(cancel, ct);
- order.MarkCancelled();
- wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불 (Toss 웹훅)", order.OrderID);
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "SUCCESS", $"취소 반영 (eventType={eventType})", paymentKey, order.OrderID), ct);
- try
- {
- await db.SaveChangesAsync(ct);
- }
- catch (Exception ex)
- {
- await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"WEBHOOK_CANCEL_PERSIST 실패: {ex.Message}");
- return Result.Failure(Error.Problem("Payment.WebhookPersistFailed", "웹훅 취소 반영 저장에 실패했습니다."));
- }
- return Result.Success();
- }
- /// <summary>ABORTED/EXPIRED — 승인 실패/가상계좌 만료. 미적립 주문만 Failed 로 종결</summary>
- private async Task<Result> HandleAbortedAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, CancellationToken ct)
- {
- if (order.Status is PaymentStatus.Pending or PaymentStatus.WaitingDeposit or PaymentStatus.NeedsReconciliation)
- {
- order.MarkFailed($"토스 웹훅 (status={payment.Status})");
- }
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, payment.Status ?? "ABORTED", $"주문 종결 처리 (order.Status={order.Status})", paymentKey, order.OrderID), ct);
- await db.SaveChangesAsync(ct);
- return Result.Success();
- }
- /// <summary>판별 불가 상태 — 아무것도 반영하지 않고 로그만</summary>
- private async Task<Result> HandleUnknownAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct)
- {
- await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "IGNORED", $"미처리 상태 (status={payment.Status}, eventType={eventType})", paymentKey, order.OrderID), ct);
- await db.SaveChangesAsync(ct);
- return Result.Success();
- }
- /// <summary>대사 로그 독립 커밋 — best-effort. (Phase 1.5)</summary>
- private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason)
- {
- try
- {
- await using var logDb = dbFactory.CreateDbContext();
- await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.Confirm, paymentOrderID, paymentKey, amount, reason), CancellationToken.None);
- await logDb.SaveChangesAsync(CancellationToken.None);
- }
- catch
- {
- // best-effort
- }
- }
- }
|