YouTubeChannelCacheRefreshService.cs 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.YouTube;
  3. using Domain.Entities.Members;
  4. using Microsoft.EntityFrameworkCore;
  5. using Microsoft.Extensions.DependencyInjection;
  6. using Microsoft.Extensions.Hosting;
  7. using Microsoft.Extensions.Logging;
  8. namespace Infrastructure.YouTube;
  9. internal sealed record ChannelSnapshot(int ID, string SID, string? Handle);
  10. /// <summary>
  11. /// 1시간마다 활성 채널의 YouTube 정보를 갱신하여 Redis 캐시 + DB Channel 양쪽에 저장
  12. /// channels.list는 id 파라미터로 최대 50개 채널을 한 번에 조회 가능 (1 unit/요청)
  13. /// 채널 50개 이하 → 1시간에 1 unit만 사용
  14. /// 동기화 대상: ThumbnailUrl, BannerUrl, SubscriberCount, VideoCount, ViewCount, Description, Email, PublishedAt, Handle, Title
  15. /// Handle 변경 감지 시 이전 값을 ChannelHandleHistory 에 저장
  16. /// </summary>
  17. internal sealed class YouTubeChannelCacheRefreshService(
  18. IServiceScopeFactory scopeFactory,
  19. IYouTubeApiService youTubeApi,
  20. IYouTubeChannelCache channelCache,
  21. ILogger<YouTubeChannelCacheRefreshService> logger
  22. ) : BackgroundService
  23. {
  24. private static readonly TimeSpan RefreshInterval = TimeSpan.FromHours(1);
  25. private static readonly TimeSpan InitialDelay = TimeSpan.FromSeconds(15);
  26. private const int BatchSize = 50; // YouTube API 최대 50개/요청
  27. protected override async Task ExecuteAsync(CancellationToken stoppingToken)
  28. {
  29. await Task.Delay(InitialDelay, stoppingToken);
  30. logger.LogInformation("[ChannelCache] 채널 캐시 갱신 서비스 시작 — 주기: {Interval}시간", RefreshInterval.TotalHours);
  31. while (!stoppingToken.IsCancellationRequested)
  32. {
  33. try
  34. {
  35. await RefreshAllChannelsAsync(stoppingToken);
  36. }
  37. catch (Exception ex)
  38. {
  39. logger.LogError(ex, "[ChannelCache] 채널 캐시 갱신 중 오류");
  40. }
  41. await Task.Delay(RefreshInterval, stoppingToken);
  42. }
  43. }
  44. private async Task RefreshAllChannelsAsync(CancellationToken ct)
  45. {
  46. // DB에서 활성 채널 (ID, SID, Handle) 목록 조회 (Scoped DbContext)
  47. List<ChannelSnapshot> channels;
  48. using (var scope = scopeFactory.CreateScope())
  49. {
  50. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  51. channels = await db.Channel.AsNoTracking()
  52. .Where(c => c.IsActive)
  53. .Select(c => new ChannelSnapshot(c.ID, c.SID, c.Handle))
  54. .ToListAsync(ct);
  55. }
  56. if (channels.Count == 0)
  57. {
  58. return;
  59. }
  60. // 50개씩 배치로 YouTube API 호출
  61. var refreshed = 0;
  62. for (var i = 0; i < channels.Count; i += BatchSize)
  63. {
  64. var batch = channels.Skip(i).Take(BatchSize).ToList();
  65. var channelIds = batch.Select(x => x.SID).ToList();
  66. var results = (await youTubeApi.GetChannelsByIdsAsync(channelIds, ct)).ToList();
  67. foreach (var info in results)
  68. {
  69. await channelCache.SetAsync(info);
  70. refreshed++;
  71. }
  72. // DB 동기화 (전체 YouTube 정보 + Handle 이력)
  73. await SyncDbChannelsAsync(batch, results, ct);
  74. // 배치 간 1초 대기 (API rate limit 방어)
  75. if (i + BatchSize < channels.Count)
  76. {
  77. await Task.Delay(TimeSpan.FromSeconds(1), ct);
  78. }
  79. }
  80. logger.LogInformation("[ChannelCache] {Refreshed}/{Total} 채널 캐시 갱신 완료", refreshed, channels.Count);
  81. }
  82. private async Task SyncDbChannelsAsync(
  83. List<ChannelSnapshot> batch,
  84. IReadOnlyList<YouTubeChannelInfo> results,
  85. CancellationToken ct
  86. ) {
  87. if (results.Count == 0)
  88. {
  89. return;
  90. }
  91. using var scope = scopeFactory.CreateScope();
  92. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  93. var batchIds = batch.Select(b => b.ID).ToList();
  94. var dbChannels = await db.Channel.Where(c => batchIds.Contains(c.ID)).ToListAsync(ct);
  95. var snapshotBySID = batch.ToDictionary(b => b.SID);
  96. foreach (var info in results)
  97. {
  98. if (!snapshotBySID.TryGetValue(info.ChannelID, out var snapshot))
  99. {
  100. continue;
  101. }
  102. var channel = dbChannels.FirstOrDefault(c => c.ID == snapshot.ID);
  103. if (channel is null)
  104. {
  105. continue;
  106. }
  107. // Handle 변경 감지 → 이력 저장 (YouTube API의 customUrl 은 '@foo' 형태이므로 normalize 후 비교)
  108. var newNormalized = NormalizeHandle(info.CustomUrl);
  109. if (!string.Equals(snapshot.Handle, newNormalized, StringComparison.Ordinal))
  110. {
  111. if (!string.IsNullOrEmpty(snapshot.Handle))
  112. {
  113. db.ChannelHandleHistory.Add(ChannelHandleHistory.Create(snapshot.ID, snapshot.Handle));
  114. }
  115. logger.LogInformation(
  116. "[ChannelCache] Handle 변경 감지: ChannelID={ChannelID}, {OldHandle} → {NewHandle}",
  117. snapshot.ID, snapshot.Handle, newNormalized
  118. );
  119. }
  120. // YouTube 전체 정보 DB 동기화 (Handle 도 내부 normalize 됨)
  121. channel.UpdateYouTubeInfo(
  122. info.ChannelID,
  123. info.Title,
  124. info.CustomUrl,
  125. info.Description,
  126. info.ThumbnailUrl,
  127. info.BannerUrl,
  128. info.SubscriberCount,
  129. info.VideoCount,
  130. info.ViewCount,
  131. info.Email,
  132. info.PublishedAt
  133. );
  134. }
  135. await db.SaveChangesAsync(ct);
  136. }
  137. private static string? NormalizeHandle(string? handle)
  138. {
  139. if (string.IsNullOrWhiteSpace(handle))
  140. {
  141. return null;
  142. }
  143. var trimmed = handle.TrimStart('@');
  144. return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
  145. }
  146. }