using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Domain.Entities.Payments.ValueObject; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Infrastructure.Payment; /// /// 결제 자동 대사 배치 (Toss). 10분 주기로 NeedsReconciliation 상태이고 마지막 갱신 후 15분 경과한 /// 토스 주문(TossConfirm 존재)을 조회 API 로 재확인한다 — 실제 반영은 Reconcile Command(Application) 가 수행. /// DONE = 멱등 적립, CANCELED/ABORTED/EXPIRED = Failed, 그 외 = PaymentReconcileLog 기록만 (보수적). /// BackgroundJobs:PaymentReconcile 플래그로 토글, Web.Api 전용. /// internal sealed class PaymentReconcileService( IServiceScopeFactory scopeFactory, ILogger logger ) : BackgroundService { private static readonly TimeSpan Interval = TimeSpan.FromMinutes(10); /// 마지막 갱신(승인 시도) 후 이 시간이 지나야 대사 대상 — 사용자 재승인 경로와의 경합 방지 private static readonly TimeSpan MinAge = TimeSpan.FromMinutes(15); private const int BatchSize = 20; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { logger.LogInformation("[PaymentReconcile] 시작 — 주기 {Interval}, 최소 경과 {MinAge}", Interval, MinAge); using var timer = new PeriodicTimer(Interval); try { // 기동 직후가 아닌 첫 주기부터 실행 (배포 직후 폭주 방지) while (await timer.WaitForNextTickAsync(stoppingToken)) { try { await RunOnceAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { logger.LogError(ex, "[PaymentReconcile] 주기 실행 실패 — 다음 주기에 재시도"); } } } catch (OperationCanceledException) { // 정상 종료 } logger.LogInformation("[PaymentReconcile] 종료"); } private async Task RunOnceAsync(CancellationToken ct) { using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var sender = scope.ServiceProvider.GetRequiredService(); var threshold = DateTime.UtcNow - MinAge; // TossConfirm 존재 = 토스 주문 (외부 PG 의 NeedsReconciliation 주문은 건드리지 않는다) var targets = await db.TossConfirm.Where(c => c.PaymentOrder!.Status == PaymentStatus.NeedsReconciliation && (c.UpdatedAt ?? c.CreatedAt) <= threshold).Select(c => c.PaymentOrderID).Distinct().Take(BatchSize).ToListAsync(ct); if (targets.Count == 0) { return; } logger.LogInformation("[PaymentReconcile] 대사 대상 {Count}건", targets.Count); foreach (var paymentOrderID in targets) { try { var result = await sender.Send(new Application.Features.Api.Payment.Toss.Reconcile.Command(paymentOrderID), ct); if (result.IsFailure) { logger.LogWarning("[PaymentReconcile] 대사 실패 — PaymentOrderID={PaymentOrderID}, {Code}: {Description}", paymentOrderID, result.Error.Code, result.Error.Description); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (Exception ex) { // 개별 실패가 배치 전체를 멈추지 않는다 — 다음 주기에 재시도 logger.LogError(ex, "[PaymentReconcile] 대사 예외 — PaymentOrderID={PaymentOrderID}", paymentOrderID); } } } }