diff --git a/SPMS.Infrastructure/DependencyInjection.cs b/SPMS.Infrastructure/DependencyInjection.cs index ab0ef29..76ab44c 100644 --- a/SPMS.Infrastructure/DependencyInjection.cs +++ b/SPMS.Infrastructure/DependencyInjection.cs @@ -84,6 +84,7 @@ public static class DependencyInjection services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); + services.AddHostedService(); // Token Store & Email Service services.AddMemoryCache(); diff --git a/SPMS.Infrastructure/Workers/DataRetentionWorker.cs b/SPMS.Infrastructure/Workers/DataRetentionWorker.cs new file mode 100644 index 0000000..f349af9 --- /dev/null +++ b/SPMS.Infrastructure/Workers/DataRetentionWorker.cs @@ -0,0 +1,124 @@ +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 DataRetentionWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + private static readonly TimeZoneInfo KstZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul"); + private const int BatchSize = 10000; + + private static readonly (string TableName, string DateColumn, int RetentionDays)[] RetentionPolicies = + { + ("PushSendLog", "SentAt", 90), + ("PushOpenLog", "OpenedAt", 90), + ("WebhookLog", "SentAt", 30), + ("SystemLog", "CreatedAt", 180) + }; + + public DataRetentionWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("DataRetentionWorker 시작"); + + while (!stoppingToken.IsCancellationRequested) + { + var delay = CalculateDelayUntilNextRun(); + _logger.LogInformation("DataRetentionWorker 다음 실행까지 {Delay} 대기", delay); + + try + { + await Task.Delay(delay, stoppingToken); + } + catch (TaskCanceledException) + { + break; + } + + try + { + await PurgeAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "DataRetentionWorker 정리 중 오류 발생"); + } + } + } + + private TimeSpan CalculateDelayUntilNextRun() + { + var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, KstZone); + var nextRun = nowKst.Date.AddHours(4); + + if (nowKst >= nextRun) + nextRun = nextRun.AddDays(1); + + return nextRun - nowKst; + } + + private async Task PurgeAsync(CancellationToken stoppingToken) + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + _logger.LogInformation("DataRetentionWorker 정리 시작"); + + var totalDeletedAll = 0; + + foreach (var (tableName, dateColumn, retentionDays) in RetentionPolicies) + { + if (stoppingToken.IsCancellationRequested) break; + + var cutoffUtc = DateTime.UtcNow.AddDays(-retentionDays); + var totalDeleted = 0; + int deletedInBatch; + + do + { + var sql = $"DELETE FROM `{tableName}` WHERE `{dateColumn}` < @p0 LIMIT {BatchSize}"; + deletedInBatch = await context.Database + .ExecuteSqlRawAsync(sql, new[] { cutoffUtc }, stoppingToken); + + totalDeleted += deletedInBatch; + + if (deletedInBatch > 0) + _logger.LogInformation( + "DataRetentionWorker [{Table}] 배치 삭제: {BatchCount}건 (누적: {TotalDeleted}건)", + tableName, deletedInBatch, totalDeleted); + } while (deletedInBatch > 0 && !stoppingToken.IsCancellationRequested); + + if (totalDeleted > 0) + _logger.LogInformation( + "DataRetentionWorker [{Table}] 정리 완료: {TotalDeleted}건 삭제 (보관 기간: {Days}일)", + tableName, totalDeleted, retentionDays); + + totalDeletedAll += totalDeleted; + } + + // SystemLog에 정리 완료 기록 + context.Set().Add(new SystemLog + { + Action = "DataRetention", + Details = $"데이터 보관 주기 정리 완료: 총 {totalDeletedAll}건 삭제", + CreatedAt = DateTime.UtcNow + }); + await context.SaveChangesAsync(stoppingToken); + + _logger.LogInformation("DataRetentionWorker 전체 정리 완료: 총 {TotalDeleted}건 삭제", totalDeletedAll); + } +}