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