using Application.Abstractions.Data; using Application.Abstractions.YouTube; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Infrastructure.YouTube; /// /// YouTube API Services Terms of Service 의 30일 데이터 갱신·삭제 정책 enforcement 서비스. /// /// 매일 KST 03:00 1회 실행하여 다음 조건의 채널을 식별·정리한다: /// - YouTubeLastSyncedAt < UtcNow - 30d (마지막 동기화 후 30일 경과) /// - 또는 YouTubeLastSyncedAt IS NULL 이지만 YouTubeChannelID IS NOT NULL (legacy 데이터) /// /// 정리 절차: /// 1. 채널의 YouTube 출처 필드(ThumbnailUrl, BannerUrl, Description, Subscriber/Video/ViewCount, /// Email, PublishedAt, ChannelID, LastSyncedAt) 를 Channel.WipeYouTubeData() 로 null·0 처리 /// 2. Redis 의 YouTubeChannel:{channelID} 캐시도 함께 제거 /// 3. 작업 결과를 로그로 남겨 audit 추적 가능하게 한다 /// /// 활성 채널은 YouTubeChannelCacheRefreshService 가 1시간 주기로 갱신하므로 30일 초과 불가능. /// → 실질 wipe 대상은 비활성 채널(IsActive=false) 또는 OAuth 권한 철회 등으로 갱신 중단된 채널. /// internal sealed class YouTubeStaleDataPurgeService( IServiceScopeFactory scopeFactory, IYouTubeChannelCache channelCache, ILogger logger ) : BackgroundService { private static readonly TimeSpan InitialDelay = TimeSpan.FromMinutes(5); private static readonly TimeSpan StaleThreshold = TimeSpan.FromDays(30); // 한국 표준시(KST) 03:00 기준 — DailyAggregator(00:00) 와 시간 분산 private static readonly TimeZoneInfo Kst = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time"); private static readonly TimeSpan RunAtKst = TimeSpan.FromHours(3); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Delay(InitialDelay, stoppingToken); logger.LogInformation("[YouTubeStaleDataPurge] 서비스 시작 — 매일 KST 03:00 실행, 30일 정책 enforcement"); while (!stoppingToken.IsCancellationRequested) { var delay = CalculateDelayUntilNextRun(); logger.LogInformation("[YouTubeStaleDataPurge] 다음 실행까지 대기: {Hours}시간 {Minutes}분", delay.Hours, delay.Minutes); try { await Task.Delay(delay, stoppingToken); } catch (TaskCanceledException) { break; } try { await PurgeOnceAsync(stoppingToken); } catch (Exception ex) { logger.LogError(ex, "[YouTubeStaleDataPurge] 정리 작업 중 오류"); } } } private static TimeSpan CalculateDelayUntilNextRun() { var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst); var todayRunKst = nowKst.Date + RunAtKst; var nextRunKst = nowKst >= todayRunKst ? todayRunKst.AddDays(1) : todayRunKst; var delay = nextRunKst - nowKst; if (delay <= TimeSpan.Zero) { delay = TimeSpan.FromMinutes(1); } return delay; } private async Task PurgeOnceAsync(CancellationToken ct) { var cutoff = DateTime.UtcNow - StaleThreshold; var purgedChannelIds = new List(); int purged = 0; using (var scope = scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); // wipe 대상: // - YouTubeChannelID 가 not null 이어야 wipe 할 데이터가 있음 // - LastSyncedAt 이 cutoff 이전 또는 NULL (legacy 데이터) var staleChannels = await db.Channel .Where(c => c.YouTubeChannelID != null && (c.YouTubeLastSyncedAt == null || c.YouTubeLastSyncedAt < cutoff)) .ToListAsync(ct); if (staleChannels.Count == 0) { logger.LogInformation("[YouTubeStaleDataPurge] 30일 초과 채널 없음 — skip"); return; } foreach (var channel in staleChannels) { var channelID = channel.YouTubeChannelID; if (channelID is not null) { purgedChannelIds.Add(channelID); } channel.WipeYouTubeData(); purged++; } await db.SaveChangesAsync(ct); } // Redis 캐시 무효화 (DB 트랜잭션 완료 후) foreach (var channelID in purgedChannelIds) { try { await channelCache.RemoveAsync(channelID); } catch (Exception ex) { logger.LogWarning(ex, "[YouTubeStaleDataPurge] Redis 캐시 제거 실패: {ChannelID}", channelID); } } logger.LogInformation( "[YouTubeStaleDataPurge] {Purged} 채널의 YouTube 데이터 wipe 완료 (cutoff={Cutoff:yyyy-MM-dd HH:mm}Z)", purged, cutoff ); } }