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);
}
}
}
}