| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141 |
- 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;
- /// <summary>
- /// 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 권한 철회 등으로 갱신 중단된 채널.
- /// </summary>
- internal sealed class YouTubeStaleDataPurgeService(
- IServiceScopeFactory scopeFactory,
- IYouTubeChannelCache channelCache,
- ILogger<YouTubeStaleDataPurgeService> 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<string>();
- int purged = 0;
- using (var scope = scopeFactory.CreateScope())
- {
- var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
- // 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
- );
- }
- }
|