using Application.Abstractions.Data; using Application.Abstractions.YouTube; using Domain.Entities.Members; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Infrastructure.YouTube; internal sealed record ChannelSnapshot(int ID, string SID, string? Handle); /// /// 1시간마다 활성 채널의 YouTube 정보를 갱신하여 Redis 캐시 + DB Channel 양쪽에 저장 /// channels.list는 id 파라미터로 최대 50개 채널을 한 번에 조회 가능 (1 unit/요청) /// 채널 50개 이하 → 1시간에 1 unit만 사용 /// 동기화 대상: ThumbnailUrl, BannerUrl, SubscriberCount, VideoCount, ViewCount, Description, Email, PublishedAt, Handle, Title /// Handle 변경 감지 시 이전 값을 ChannelHandleHistory 에 저장 /// internal sealed class YouTubeChannelCacheRefreshService( IServiceScopeFactory scopeFactory, IYouTubeApiService youTubeApi, IYouTubeChannelCache channelCache, ILogger logger ) : BackgroundService { private static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1); private static readonly TimeSpan InitialDelay = TimeSpan.FromSeconds(15); private const int BatchSize = 50; // YouTube API 최대 50개/요청 protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Delay(InitialDelay, stoppingToken); logger.LogInformation("[ChannelCache] 채널 캐시 갱신 서비스 시작 — 주기: {Interval}시간", RefreshInterval.TotalHours); while (!stoppingToken.IsCancellationRequested) { try { await RefreshAllChannelsAsync(stoppingToken); } catch (Exception ex) { logger.LogError(ex, "[ChannelCache] 채널 캐시 갱신 중 오류"); } await Task.Delay(RefreshInterval, stoppingToken); } } private async Task RefreshAllChannelsAsync(CancellationToken ct) { // DB에서 활성 채널 (ID, SID, Handle) 목록 조회 (Scoped DbContext) List channels; using (var scope = scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); channels = await db.Channel.AsNoTracking() .Where(c => c.IsActive) .Select(c => new ChannelSnapshot(c.ID, c.SID, c.Handle)) .ToListAsync(ct); } if (channels.Count == 0) { return; } // 50개씩 배치로 YouTube API 호출 var refreshed = 0; for (var i = 0; i < channels.Count; i += BatchSize) { var batch = channels.Skip(i).Take(BatchSize).ToList(); var channelIds = batch.Select(x => x.SID).ToList(); var results = (await youTubeApi.GetChannelsByIdsAsync(channelIds, ct)).ToList(); foreach (var info in results) { await channelCache.SetAsync(info); refreshed++; } // DB 동기화 (전체 YouTube 정보 + Handle 이력) await SyncDbChannelsAsync(batch, results, ct); // 배치 간 1초 대기 (API rate limit 방어) if (i + BatchSize < channels.Count) { await Task.Delay(TimeSpan.FromSeconds(1), ct); } } logger.LogInformation("[ChannelCache] {Refreshed}/{Total} 채널 캐시 갱신 완료", refreshed, channels.Count); } private async Task SyncDbChannelsAsync( List batch, IReadOnlyList results, CancellationToken ct ) { if (results.Count == 0) { return; } using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var batchIds = batch.Select(b => b.ID).ToList(); var dbChannels = await db.Channel.Where(c => batchIds.Contains(c.ID)).ToListAsync(ct); var snapshotBySID = batch.ToDictionary(b => b.SID); foreach (var info in results) { if (!snapshotBySID.TryGetValue(info.ChannelID, out var snapshot)) { continue; } var channel = dbChannels.FirstOrDefault(c => c.ID == snapshot.ID); if (channel is null) { continue; } // Handle 변경 감지 → 이력 저장 (YouTube API의 customUrl 은 '@foo' 형태이므로 normalize 후 비교) var newNormalized = NormalizeHandle(info.CustomUrl); if (!string.Equals(snapshot.Handle, newNormalized, StringComparison.Ordinal)) { if (!string.IsNullOrEmpty(snapshot.Handle)) { db.ChannelHandleHistory.Add(ChannelHandleHistory.Create(snapshot.ID, snapshot.Handle)); } logger.LogInformation( "[ChannelCache] Handle 변경 감지: ChannelID={ChannelID}, {OldHandle} → {NewHandle}", snapshot.ID, snapshot.Handle, newNormalized ); } // YouTube 전체 정보 DB 동기화 (Handle 도 내부 normalize 됨) channel.UpdateYouTubeInfo( info.ChannelID, info.Title, info.CustomUrl, info.Description, info.ThumbnailUrl, info.BannerUrl, info.SubscriberCount, info.VideoCount, info.ViewCount, info.Email, info.PublishedAt ); } await db.SaveChangesAsync(ct); } private static string? NormalizeHandle(string? handle) { if (string.IsNullOrWhiteSpace(handle)) { return null; } var trimmed = handle.TrimStart('@'); return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; } }