159 lines
5.7 KiB
C#
159 lines
5.7 KiB
C#
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<DeadTokenCleanupWorker> _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<DeadTokenCleanupWorker> 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<AppDbContext>();
|
|
|
|
var cutoffUtc = DateTime.UtcNow.AddDays(-7);
|
|
|
|
var targetCount = await context.Set<Device>()
|
|
.CountAsync(d => !d.IsActive && d.UpdatedAt < cutoffUtc, stoppingToken);
|
|
|
|
var totalCount = await context.Set<Device>()
|
|
.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<Device>()
|
|
.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<SystemLog>().Add(new SystemLog
|
|
{
|
|
Action = "DeadTokenCleanup",
|
|
Details = details,
|
|
CreatedAt = DateTime.UtcNow
|
|
});
|
|
await context.SaveChangesAsync(stoppingToken);
|
|
}
|
|
}
|