| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170 |
- 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);
- /// <summary>
- /// 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 에 저장
- /// </summary>
- internal sealed class YouTubeChannelCacheRefreshService(
- IServiceScopeFactory scopeFactory,
- IYouTubeApiService youTubeApi,
- IYouTubeChannelCache channelCache,
- ILogger<YouTubeChannelCacheRefreshService> 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<ChannelSnapshot> channels;
- using (var scope = scopeFactory.CreateScope())
- {
- var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
- 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<ChannelSnapshot> batch,
- IReadOnlyList<YouTubeChannelInfo> results,
- CancellationToken ct
- ) {
- if (results.Count == 0)
- {
- return;
- }
- using var scope = scopeFactory.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
- 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;
- }
- }
|