using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using SPMS.Domain.Entities; using SPMS.Infrastructure.Persistence; namespace SPMS.Infrastructure.Workers; public class DeadTokenCleanupWorker : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private static readonly TimeZoneInfo KstZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul"); private const int BatchSize = 1000; private const double SafetyThreshold = 0.5; public DeadTokenCleanupWorker( IServiceScopeFactory scopeFactory, ILogger logger) { _scopeFactory = scopeFactory; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("DeadTokenCleanupWorker 시작"); while (!stoppingToken.IsCancellationRequested) { var delay = CalculateDelayUntilNextSunday(); _logger.LogInformation("DeadTokenCleanupWorker 다음 실행까지 {Delay} 대기", delay); try { await Task.Delay(delay, stoppingToken); } catch (TaskCanceledException) { break; } try { await CleanupAsync(stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "DeadTokenCleanupWorker 정리 중 오류 발생"); } } } private TimeSpan CalculateDelayUntilNextSunday() { var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone); var daysUntilSunday = ((int)DayOfWeek.Sunday - (int)nowKst.DayOfWeek + 7) % 7; var nextRun = nowKst.Date.AddDays(daysUntilSunday).AddHours(3); if (nextRun <= nowKst) nextRun = nextRun.AddDays(7); return nextRun - nowKst; } private async Task CleanupAsync(CancellationToken stoppingToken) { using var scope = _scopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var cutoffUtc = DateTime.UtcNow.AddDays(-7); var targetCount = await context.Set() .CountAsync(d => !d.IsActive && d.UpdatedAt < cutoffUtc, stoppingToken); var totalCount = await context.Set() .CountAsync(stoppingToken); _logger.LogInformation("DeadTokenCleanupWorker 삭제 대상: {TargetCount}개 / 전체: {TotalCount}개", targetCount, totalCount); if (targetCount == 0) { _logger.LogInformation("DeadTokenCleanupWorker 삭제 대상 없음"); await WriteSystemLogAsync(context, 0, stoppingToken); return; } // 안전장치: 삭제 대상이 전체의 50% 초과 시 중단 if (totalCount > 0 && (double)targetCount / totalCount > SafetyThreshold) { _logger.LogWarning( "DeadTokenCleanupWorker 안전 장치 발동! 삭제 대상({TargetCount})이 전체({TotalCount})의 50% 초과. 삭제를 중단합니다.", targetCount, totalCount); await WriteSystemLogAsync(context, 0, stoppingToken, $"안전 장치 발동: 삭제 대상 {targetCount}개 / 전체 {totalCount}개 (50% 초과)"); return; } // 배치 삭제 (1000건씩, 트랜잭션 없음) var totalDeleted = 0; int deletedInBatch; do { deletedInBatch = await context.Database.ExecuteSqlRawAsync( "DELETE FROM Device WHERE is_active = false AND updated_at < {0} LIMIT 1000", new object[] { cutoffUtc }, stoppingToken); totalDeleted += deletedInBatch; if (deletedInBatch > 0) _logger.LogInformation("DeadTokenCleanupWorker 배치 삭제: {BatchCount}건 (누적: {TotalDeleted}건)", deletedInBatch, totalDeleted); } while (deletedInBatch > 0 && !stoppingToken.IsCancellationRequested); await WriteSystemLogAsync(context, totalDeleted, stoppingToken); _logger.LogInformation("DeadTokenCleanupWorker 정리 완료: {TotalDeleted}건 삭제", totalDeleted); } private static async Task WriteSystemLogAsync( AppDbContext context, int deletedCount, CancellationToken stoppingToken, string? extraDetails = null) { var details = extraDetails ?? $"비활성 토큰 정리 완료: {deletedCount}건 삭제"; context.Set().Add(new SystemLog { Action = "DeadTokenCleanup", Details = details, CreatedAt = DateTime.UtcNow }); await context.SaveChangesAsync(stoppingToken); } }