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
);
}
}