using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using SPMS.Application.Interfaces; using SPMS.Domain.Entities; using SPMS.Infrastructure.Persistence; namespace SPMS.Infrastructure.Workers; public class DeadTokenCleanupWorker : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ITokenCacheService _tokenCache; 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, ITokenCacheService tokenCache, ILogger logger) { _scopeFactory = scopeFactory; _tokenCache = tokenCache; _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건씩, 트랜잭션 없음) + Redis 캐시 무효화 var totalDeleted = 0; while (!stoppingToken.IsCancellationRequested) { var batch = await context.Set() .Where(d => !d.IsActive && d.UpdatedAt < cutoffUtc) .Select(d => new { d.Id, d.ServiceId, d.ExternalDeviceId }) .Take(BatchSize) .ToListAsync(stoppingToken); if (batch.Count == 0) break; var ids = batch.Select(b => b.Id).ToList(); var idList = string.Join(",", ids); #pragma warning disable EF1002 // ID는 내부 DB 쿼리에서 추출한 long 값 — SQL injection 위험 없음 var deletedInBatch = await context.Database.ExecuteSqlRawAsync( $"DELETE FROM Device WHERE id IN ({idList})", stoppingToken); #pragma warning restore EF1002 totalDeleted += deletedInBatch; // 삭제된 디바이스의 Redis 캐시 무효화 foreach (var item in batch) await _tokenCache.InvalidateAsync(item.ServiceId, item.ExternalDeviceId); _logger.LogInformation("DeadTokenCleanupWorker 배치 삭제: {BatchCount}건 (누적: {TotalDeleted}건), 캐시 무효화 완료", deletedInBatch, totalDeleted); } 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); } }