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