| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100 |
- 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;
- /// <summary>
- /// 결제 자동 대사 배치 (Toss). 10분 주기로 NeedsReconciliation 상태이고 마지막 갱신 후 15분 경과한
- /// 토스 주문(TossConfirm 존재)을 조회 API 로 재확인한다 — 실제 반영은 Reconcile Command(Application) 가 수행.
- /// DONE = 멱등 적립, CANCELED/ABORTED/EXPIRED = Failed, 그 외 = PaymentReconcileLog 기록만 (보수적).
- /// BackgroundJobs:PaymentReconcile 플래그로 토글, Web.Api 전용.
- /// </summary>
- internal sealed class PaymentReconcileService(
- IServiceScopeFactory scopeFactory,
- ILogger<PaymentReconcileService> logger
- ) : BackgroundService
- {
- private static readonly TimeSpan Interval = TimeSpan.FromMinutes(10);
- /// <summary>마지막 갱신(승인 시도) 후 이 시간이 지나야 대사 대상 — 사용자 재승인 경로와의 경합 방지</summary>
- 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<IAppDbContext>();
- var sender = scope.ServiceProvider.GetRequiredService<ISender>();
- 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);
- }
- }
- }
- }
|