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; /// /// API 결제 보고 수수료 확정 배치. /// 10분 주기로 ConfirmDueAt(등록 +14일) 도래한 Pending 건을 확정 — /// 채널주 지갑(StoreRevenue) 적립 + Status=Confirmed + 채널주 알림. /// 건별 SaveChanges 로 실패를 격리하되, 오류 발생 시 해당 주기를 중단해 DbContext 오염을 방지한다 /// (실패 건은 Pending 유지 → 다음 주기 재시도). /// BackgroundJobs:ApiPurchaseConfirm 플래그로 토글 (Web.Api 전용 — Admin 중복 실행 금지). /// internal sealed class ApiPurchaseConfirmService( IServiceScopeFactory scopeFactory, IAdminAlertService adminAlertService, ILogger 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(); var notificationService = scope.ServiceProvider.GetRequiredService(); 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; } } } }