YouTubeStaleDataPurgeService.cs 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.YouTube;
  3. using Microsoft.EntityFrameworkCore;
  4. using Microsoft.Extensions.DependencyInjection;
  5. using Microsoft.Extensions.Hosting;
  6. using Microsoft.Extensions.Logging;
  7. namespace Infrastructure.YouTube;
  8. /// <summary>
  9. /// YouTube API Services Terms of Service 의 30일 데이터 갱신·삭제 정책 enforcement 서비스.
  10. ///
  11. /// 매일 KST 03:00 1회 실행하여 다음 조건의 채널을 식별·정리한다:
  12. /// - YouTubeLastSyncedAt &lt; UtcNow - 30d (마지막 동기화 후 30일 경과)
  13. /// - 또는 YouTubeLastSyncedAt IS NULL 이지만 YouTubeChannelID IS NOT NULL (legacy 데이터)
  14. ///
  15. /// 정리 절차:
  16. /// 1. 채널의 YouTube 출처 필드(ThumbnailUrl, BannerUrl, Description, Subscriber/Video/ViewCount,
  17. /// Email, PublishedAt, ChannelID, LastSyncedAt) 를 Channel.WipeYouTubeData() 로 null·0 처리
  18. /// 2. Redis 의 YouTubeChannel:{channelID} 캐시도 함께 제거
  19. /// 3. 작업 결과를 로그로 남겨 audit 추적 가능하게 한다
  20. ///
  21. /// 활성 채널은 YouTubeChannelCacheRefreshService 가 1시간 주기로 갱신하므로 30일 초과 불가능.
  22. /// → 실질 wipe 대상은 비활성 채널(IsActive=false) 또는 OAuth 권한 철회 등으로 갱신 중단된 채널.
  23. /// </summary>
  24. internal sealed class YouTubeStaleDataPurgeService(
  25. IServiceScopeFactory scopeFactory,
  26. IYouTubeChannelCache channelCache,
  27. ILogger<YouTubeStaleDataPurgeService> logger
  28. ) : BackgroundService
  29. {
  30. private static readonly TimeSpan InitialDelay = TimeSpan.FromMinutes(5);
  31. private static readonly TimeSpan StaleThreshold = TimeSpan.FromDays(30);
  32. // 한국 표준시(KST) 03:00 기준 — DailyAggregator(00:00) 와 시간 분산
  33. private static readonly TimeZoneInfo Kst = TimeZoneInfo.FindSystemTimeZoneById("Korea Standard Time");
  34. private static readonly TimeSpan RunAtKst = TimeSpan.FromHours(3);
  35. protected override async Task ExecuteAsync(CancellationToken stoppingToken)
  36. {
  37. await Task.Delay(InitialDelay, stoppingToken);
  38. logger.LogInformation("[YouTubeStaleDataPurge] 서비스 시작 — 매일 KST 03:00 실행, 30일 정책 enforcement");
  39. while (!stoppingToken.IsCancellationRequested)
  40. {
  41. var delay = CalculateDelayUntilNextRun();
  42. logger.LogInformation("[YouTubeStaleDataPurge] 다음 실행까지 대기: {Hours}시간 {Minutes}분", delay.Hours, delay.Minutes);
  43. try
  44. {
  45. await Task.Delay(delay, stoppingToken);
  46. }
  47. catch (TaskCanceledException)
  48. {
  49. break;
  50. }
  51. try
  52. {
  53. await PurgeOnceAsync(stoppingToken);
  54. }
  55. catch (Exception ex)
  56. {
  57. logger.LogError(ex, "[YouTubeStaleDataPurge] 정리 작업 중 오류");
  58. }
  59. }
  60. }
  61. private static TimeSpan CalculateDelayUntilNextRun()
  62. {
  63. var nowKst = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst);
  64. var todayRunKst = nowKst.Date + RunAtKst;
  65. var nextRunKst = nowKst >= todayRunKst ? todayRunKst.AddDays(1) : todayRunKst;
  66. var delay = nextRunKst - nowKst;
  67. if (delay <= TimeSpan.Zero)
  68. {
  69. delay = TimeSpan.FromMinutes(1);
  70. }
  71. return delay;
  72. }
  73. private async Task PurgeOnceAsync(CancellationToken ct)
  74. {
  75. var cutoff = DateTime.UtcNow - StaleThreshold;
  76. var purgedChannelIds = new List<string>();
  77. int purged = 0;
  78. using (var scope = scopeFactory.CreateScope())
  79. {
  80. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  81. // wipe 대상:
  82. // - YouTubeChannelID 가 not null 이어야 wipe 할 데이터가 있음
  83. // - LastSyncedAt 이 cutoff 이전 또는 NULL (legacy 데이터)
  84. var staleChannels = await db.Channel
  85. .Where(c => c.YouTubeChannelID != null
  86. && (c.YouTubeLastSyncedAt == null || c.YouTubeLastSyncedAt < cutoff))
  87. .ToListAsync(ct);
  88. if (staleChannels.Count == 0)
  89. {
  90. logger.LogInformation("[YouTubeStaleDataPurge] 30일 초과 채널 없음 — skip");
  91. return;
  92. }
  93. foreach (var channel in staleChannels)
  94. {
  95. var channelID = channel.YouTubeChannelID;
  96. if (channelID is not null)
  97. {
  98. purgedChannelIds.Add(channelID);
  99. }
  100. channel.WipeYouTubeData();
  101. purged++;
  102. }
  103. await db.SaveChangesAsync(ct);
  104. }
  105. // Redis 캐시 무효화 (DB 트랜잭션 완료 후)
  106. foreach (var channelID in purgedChannelIds)
  107. {
  108. try
  109. {
  110. await channelCache.RemoveAsync(channelID);
  111. }
  112. catch (Exception ex)
  113. {
  114. logger.LogWarning(ex, "[YouTubeStaleDataPurge] Redis 캐시 제거 실패: {ChannelID}", channelID);
  115. }
  116. }
  117. logger.LogInformation(
  118. "[YouTubeStaleDataPurge] {Purged} 채널의 YouTube 데이터 wipe 완료 (cutoff={Cutoff:yyyy-MM-dd HH:mm}Z)",
  119. purged, cutoff
  120. );
  121. }
  122. }