PaymentReconcileService.cs 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Domain.Entities.Payments.ValueObject;
  4. using Microsoft.EntityFrameworkCore;
  5. using Microsoft.Extensions.DependencyInjection;
  6. using Microsoft.Extensions.Hosting;
  7. using Microsoft.Extensions.Logging;
  8. namespace Infrastructure.Payment;
  9. /// <summary>
  10. /// 결제 자동 대사 배치 (Toss). 10분 주기로 NeedsReconciliation 상태이고 마지막 갱신 후 15분 경과한
  11. /// 토스 주문(TossConfirm 존재)을 조회 API 로 재확인한다 — 실제 반영은 Reconcile Command(Application) 가 수행.
  12. /// DONE = 멱등 적립, CANCELED/ABORTED/EXPIRED = Failed, 그 외 = PaymentReconcileLog 기록만 (보수적).
  13. /// BackgroundJobs:PaymentReconcile 플래그로 토글, Web.Api 전용.
  14. /// </summary>
  15. internal sealed class PaymentReconcileService(
  16. IServiceScopeFactory scopeFactory,
  17. ILogger<PaymentReconcileService> logger
  18. ) : BackgroundService
  19. {
  20. private static readonly TimeSpan Interval = TimeSpan.FromMinutes(10);
  21. /// <summary>마지막 갱신(승인 시도) 후 이 시간이 지나야 대사 대상 — 사용자 재승인 경로와의 경합 방지</summary>
  22. private static readonly TimeSpan MinAge = TimeSpan.FromMinutes(15);
  23. private const int BatchSize = 20;
  24. protected override async Task ExecuteAsync(CancellationToken stoppingToken)
  25. {
  26. logger.LogInformation("[PaymentReconcile] 시작 — 주기 {Interval}, 최소 경과 {MinAge}", Interval, MinAge);
  27. using var timer = new PeriodicTimer(Interval);
  28. try
  29. {
  30. // 기동 직후가 아닌 첫 주기부터 실행 (배포 직후 폭주 방지)
  31. while (await timer.WaitForNextTickAsync(stoppingToken))
  32. {
  33. try
  34. {
  35. await RunOnceAsync(stoppingToken);
  36. }
  37. catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
  38. {
  39. break;
  40. }
  41. catch (Exception ex)
  42. {
  43. logger.LogError(ex, "[PaymentReconcile] 주기 실행 실패 — 다음 주기에 재시도");
  44. }
  45. }
  46. }
  47. catch (OperationCanceledException)
  48. {
  49. // 정상 종료
  50. }
  51. logger.LogInformation("[PaymentReconcile] 종료");
  52. }
  53. private async Task RunOnceAsync(CancellationToken ct)
  54. {
  55. using var scope = scopeFactory.CreateScope();
  56. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  57. var sender = scope.ServiceProvider.GetRequiredService<ISender>();
  58. var threshold = DateTime.UtcNow - MinAge;
  59. // TossConfirm 존재 = 토스 주문 (외부 PG 의 NeedsReconciliation 주문은 건드리지 않는다)
  60. 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);
  61. if (targets.Count == 0)
  62. {
  63. return;
  64. }
  65. logger.LogInformation("[PaymentReconcile] 대사 대상 {Count}건", targets.Count);
  66. foreach (var paymentOrderID in targets)
  67. {
  68. try
  69. {
  70. var result = await sender.Send(new Application.Features.Api.Payment.Toss.Reconcile.Command(paymentOrderID), ct);
  71. if (result.IsFailure)
  72. {
  73. logger.LogWarning("[PaymentReconcile] 대사 실패 — PaymentOrderID={PaymentOrderID}, {Code}: {Description}", paymentOrderID, result.Error.Code, result.Error.Description);
  74. }
  75. }
  76. catch (OperationCanceledException) when (ct.IsCancellationRequested)
  77. {
  78. throw;
  79. }
  80. catch (Exception ex)
  81. {
  82. // 개별 실패가 배치 전체를 멈추지 않는다 — 다음 주기에 재시도
  83. logger.LogError(ex, "[PaymentReconcile] 대사 예외 — PaymentOrderID={PaymentOrderID}", paymentOrderID);
  84. }
  85. }
  86. }
  87. }