| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137 |
- using Application.Abstractions.Data;
- using Application.Abstractions.Notification;
- using Domain.Entities.Common.ValueObject;
- using Domain.Entities.Developers;
- using Domain.Entities.Developers.ValueObject;
- using Domain.Entities.Notifications.ValueObject;
- using Domain.Entities.Wallets;
- using Microsoft.EntityFrameworkCore;
- using Microsoft.Extensions.DependencyInjection;
- using Microsoft.Extensions.Hosting;
- using Microsoft.Extensions.Logging;
- namespace Infrastructure.Developers;
- /// <summary>
- /// API 결제 보고 수수료 확정 배치.
- /// 10분 주기로 ConfirmDueAt(등록 +14일) 도래한 Pending 건을 확정 —
- /// 채널주 지갑(StoreRevenue) 적립 + Status=Confirmed + 채널주 알림.
- /// 건별 SaveChanges 로 실패를 격리하되, 오류 발생 시 해당 주기를 중단해 DbContext 오염을 방지한다
- /// (실패 건은 Pending 유지 → 다음 주기 재시도).
- /// BackgroundJobs:ApiPurchaseConfirm 플래그로 토글 (Web.Api 전용 — Admin 중복 실행 금지).
- /// </summary>
- internal sealed class ApiPurchaseConfirmService(
- IServiceScopeFactory scopeFactory,
- IAdminAlertService adminAlertService,
- ILogger<ApiPurchaseConfirmService> logger
- ) : BackgroundService
- {
- private static readonly TimeSpan InitialDelay = TimeSpan.FromMinutes(1);
- private static readonly TimeSpan Interval = TimeSpan.FromMinutes(10);
- private const int BatchSize = 100;
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- try
- {
- await Task.Delay(InitialDelay, stoppingToken);
- }
- catch (TaskCanceledException)
- {
- return;
- }
- logger.LogInformation("[ApiPurchaseConfirm] 서비스 시작 — {Minutes}분 주기, 보류 {HoldDays}일", Interval.TotalMinutes, ApiPurchase.HoldDays);
- while (!stoppingToken.IsCancellationRequested)
- {
- try
- {
- await ConfirmDueOnceAsync(stoppingToken);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[ApiPurchaseConfirm] 확정 배치 오류");
- }
- try
- {
- await Task.Delay(Interval, stoppingToken);
- }
- catch (TaskCanceledException)
- {
- break;
- }
- }
- }
- private async Task ConfirmDueOnceAsync(CancellationToken ct)
- {
- using var scope = scopeFactory.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
- var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
- while (!ct.IsCancellationRequested)
- {
- var now = DateTime.UtcNow;
- var due = await db.ApiPurchase.Where(c => c.Status == ApiPurchaseStatus.Pending && c.ConfirmDueAt <= now).OrderBy(c => c.ConfirmDueAt).Take(BatchSize).ToListAsync(ct);
- if (due.Count == 0)
- {
- return;
- }
- var gameIDs = due.Select(c => c.GameID).Distinct().ToList();
- var gameNames = await db.Game.AsNoTracking().Where(c => gameIDs.Contains(c.ID)).ToDictionaryAsync(c => c.ID, c => c.KorName, ct);
- foreach (var purchase in due)
- {
- try
- {
- var wallet = await db.Wallet.Include(c => c.Balances).Include(c => c.Transactions).FirstOrDefaultAsync(c => c.MemberID == purchase.CreditedMemberID, ct);
- if (wallet is null)
- {
- wallet = Wallet.Create(purchase.CreditedMemberID);
- db.Wallet.Add(wallet);
- }
- var gameName = gameNames.GetValueOrDefault(purchase.GameID, "게임");
- wallet.CreditApiCommission(Money.KRW(purchase.CommissionAmount), reason: $"{gameName} 판매 수수료", refID: $"apipurchase:{purchase.ID}");
- purchase.Confirm();
- await db.SaveChangesAsync(ct);
- await notificationService.SendAsync(
- memberID: purchase.CreditedMemberID,
- type: NotificationType.ApiCommissionConfirmed,
- title: "판매 수수료 확정",
- message: $"「{gameName}」 판매 수수료 {purchase.CommissionAmount:N0}원이 확정되어 잔액에 입금되었습니다.",
- actionUrl: "/studio/wallet/revenue",
- relatedType: "ApiPurchase",
- relatedID: purchase.ID,
- imageUrl: null,
- ct: ct
- );
- }
- catch (OperationCanceledException)
- {
- return;
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "[ApiPurchaseConfirm] 확정 실패 — PurchaseID {PurchaseID}, 다음 주기 재시도", purchase.ID);
- await adminAlertService.SendAlertAsync($"🔥 수수료 확정 배치 실패 — PurchaseID {purchase.ID}: {ex.Message} (다음 주기 재시도)", CancellationToken.None);
- return;
- }
- }
- if (due.Count < BatchSize)
- {
- return;
- }
- }
- }
- }
|