using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace Infrastructure.StockData; /// /// KST 기준 매일 지정 시각에 1회 실행되는 배치 베이스. /// RunOnceAsync 가 false 를 반환하면 (예: T+1 데이터 미반영) RetryDelay 간격으로 MaxRetryCount 회 재시도 후 다음 날로 넘어간다. /// internal abstract class DailyScheduledService(ILogger logger) : BackgroundService { protected static readonly TimeZoneInfo Kst = ResolveKst(); protected ILogger Logger { get; } = logger; /// 로그 프리픽스용 배치 이름 protected abstract string JobName { get; } /// KST 실행 시각 protected abstract TimeOnly TargetTime { get; } /// 실패(false) 시 재시도 횟수 (기본 0 = 재시도 없음) protected virtual int MaxRetryCount => 0; /// 재시도 간격 protected virtual TimeSpan RetryDelay => TimeSpan.FromHours(2); /// 1회 실행. true = 완료, false = 데이터 미반영 등으로 재시도 필요. protected abstract Task RunOnceAsync(DateOnly todayKst, CancellationToken ct); protected static DateTime NowKst() => TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst); protected static TimeOnly ParseTime(string value, TimeOnly fallback) { return TimeOnly.TryParse(value, out var parsed) ? parsed : fallback; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { Logger.LogInformation("[{Job}] 서비스 시작 — 실행 시각(KST): {Time}", JobName, TargetTime); while (!stoppingToken.IsCancellationRequested) { var delay = NextDelay(); Logger.LogInformation("[{Job}] 다음 실행까지 대기: {Delay}", JobName, delay); try { await Task.Delay(delay, stoppingToken); } catch (OperationCanceledException) { break; } for (var attempt = 0; attempt <= MaxRetryCount; attempt++) { if (stoppingToken.IsCancellationRequested) { return; } var done = true; try { done = await RunOnceAsync(DateOnly.FromDateTime(NowKst()), stoppingToken); } catch (OperationCanceledException) { return; } catch (Exception ex) { Logger.LogError(ex, "[{Job}] 실행 예외 (attempt {Attempt})", JobName, attempt + 1); } if (done || attempt == MaxRetryCount) { break; } Logger.LogInformation("[{Job}] 데이터 미반영 — {Delay} 후 재시도 ({Attempt}/{Max})", JobName, RetryDelay, attempt + 1, MaxRetryCount); try { await Task.Delay(RetryDelay, stoppingToken); } catch (OperationCanceledException) { return; } } } } private TimeSpan NextDelay() { var nowKst = NowKst(); var target = nowKst.Date.Add(TargetTime.ToTimeSpan()); if (target <= nowKst) { target = target.AddDays(1); } return target - nowKst; } private static TimeZoneInfo ResolveKst() { if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz)) { return tz; } if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz)) { return tz; } // KST 는 DST 없음 — 고정 +9 fallback return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST"); } }