ApiPurchaseConfirmService.cs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Notification;
  3. using Domain.Entities.Common.ValueObject;
  4. using Domain.Entities.Developers;
  5. using Domain.Entities.Developers.ValueObject;
  6. using Domain.Entities.Notifications.ValueObject;
  7. using Domain.Entities.Wallets;
  8. using Microsoft.EntityFrameworkCore;
  9. using Microsoft.Extensions.DependencyInjection;
  10. using Microsoft.Extensions.Hosting;
  11. using Microsoft.Extensions.Logging;
  12. namespace Infrastructure.Developers;
  13. /// <summary>
  14. /// API 결제 보고 수수료 확정 배치.
  15. /// 10분 주기로 ConfirmDueAt(등록 +14일) 도래한 Pending 건을 확정 —
  16. /// 채널주 지갑(StoreRevenue) 적립 + Status=Confirmed + 채널주 알림.
  17. /// 건별 SaveChanges 로 실패를 격리하되, 오류 발생 시 해당 주기를 중단해 DbContext 오염을 방지한다
  18. /// (실패 건은 Pending 유지 → 다음 주기 재시도).
  19. /// BackgroundJobs:ApiPurchaseConfirm 플래그로 토글 (Web.Api 전용 — Admin 중복 실행 금지).
  20. /// </summary>
  21. internal sealed class ApiPurchaseConfirmService(
  22. IServiceScopeFactory scopeFactory,
  23. IAdminAlertService adminAlertService,
  24. ILogger<ApiPurchaseConfirmService> logger
  25. ) : BackgroundService
  26. {
  27. private static readonly TimeSpan InitialDelay = TimeSpan.FromMinutes(1);
  28. private static readonly TimeSpan Interval = TimeSpan.FromMinutes(10);
  29. private const int BatchSize = 100;
  30. protected override async Task ExecuteAsync(CancellationToken stoppingToken)
  31. {
  32. try
  33. {
  34. await Task.Delay(InitialDelay, stoppingToken);
  35. }
  36. catch (TaskCanceledException)
  37. {
  38. return;
  39. }
  40. logger.LogInformation("[ApiPurchaseConfirm] 서비스 시작 — {Minutes}분 주기, 보류 {HoldDays}일", Interval.TotalMinutes, ApiPurchase.HoldDays);
  41. while (!stoppingToken.IsCancellationRequested)
  42. {
  43. try
  44. {
  45. await ConfirmDueOnceAsync(stoppingToken);
  46. }
  47. catch (Exception ex)
  48. {
  49. logger.LogError(ex, "[ApiPurchaseConfirm] 확정 배치 오류");
  50. }
  51. try
  52. {
  53. await Task.Delay(Interval, stoppingToken);
  54. }
  55. catch (TaskCanceledException)
  56. {
  57. break;
  58. }
  59. }
  60. }
  61. private async Task ConfirmDueOnceAsync(CancellationToken ct)
  62. {
  63. using var scope = scopeFactory.CreateScope();
  64. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  65. var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
  66. while (!ct.IsCancellationRequested)
  67. {
  68. var now = DateTime.UtcNow;
  69. var due = await db.ApiPurchase.Where(c => c.Status == ApiPurchaseStatus.Pending && c.ConfirmDueAt <= now).OrderBy(c => c.ConfirmDueAt).Take(BatchSize).ToListAsync(ct);
  70. if (due.Count == 0)
  71. {
  72. return;
  73. }
  74. var gameIDs = due.Select(c => c.GameID).Distinct().ToList();
  75. var gameNames = await db.Game.AsNoTracking().Where(c => gameIDs.Contains(c.ID)).ToDictionaryAsync(c => c.ID, c => c.KorName, ct);
  76. foreach (var purchase in due)
  77. {
  78. try
  79. {
  80. var wallet = await db.Wallet.Include(c => c.Balances).Include(c => c.Transactions).FirstOrDefaultAsync(c => c.MemberID == purchase.CreditedMemberID, ct);
  81. if (wallet is null)
  82. {
  83. wallet = Wallet.Create(purchase.CreditedMemberID);
  84. db.Wallet.Add(wallet);
  85. }
  86. var gameName = gameNames.GetValueOrDefault(purchase.GameID, "게임");
  87. wallet.CreditApiCommission(Money.KRW(purchase.CommissionAmount), reason: $"{gameName} 판매 수수료", refID: $"apipurchase:{purchase.ID}");
  88. purchase.Confirm();
  89. await db.SaveChangesAsync(ct);
  90. await notificationService.SendAsync(
  91. memberID: purchase.CreditedMemberID,
  92. type: NotificationType.ApiCommissionConfirmed,
  93. title: "판매 수수료 확정",
  94. message: $"「{gameName}」 판매 수수료 {purchase.CommissionAmount:N0}원이 확정되어 잔액에 입금되었습니다.",
  95. actionUrl: "/studio/wallet/revenue",
  96. relatedType: "ApiPurchase",
  97. relatedID: purchase.ID,
  98. imageUrl: null,
  99. ct: ct
  100. );
  101. }
  102. catch (OperationCanceledException)
  103. {
  104. return;
  105. }
  106. catch (Exception ex)
  107. {
  108. logger.LogError(ex, "[ApiPurchaseConfirm] 확정 실패 — PurchaseID {PurchaseID}, 다음 주기 재시도", purchase.ID);
  109. await adminAlertService.SendAlertAsync($"🔥 수수료 확정 배치 실패 — PurchaseID {purchase.ID}: {ex.Message} (다음 주기 재시도)", CancellationToken.None);
  110. return;
  111. }
  112. }
  113. if (due.Count < BatchSize)
  114. {
  115. return;
  116. }
  117. }
  118. }
  119. }