namespace Application.Helpers;
/// SEIBro 일일 호출 한도의 관리 단위 — API 카테고리 (docs/SEIBro/readme.md §1, getXrcStk* 는 Stock 소속)
public enum SeibroCategory
{
Corp = 1,
Stock = 2,
Bond = 3,
Deriv = 4,
Foreign = 5
}
///
/// SEIBro 카테고리별 일일 호출 예산 트래커 (DI 싱글턴) — 같은 카테고리에 속한 여러 수집 배치가 하나의 카운터를 공유한다.
/// KST 날짜가 바뀌면 카운터가 자동 리셋된다 (SEIBro 한도가 일 단위이므로).
/// 예산 상한(dailyBudget)은 호출자가 설정(Seibro:*Budget)에서 주입한다 — 트래커는 소비량만 기억한다.
/// ⚠️ in-memory 카운터라 프로세스 재시작 시 리셋되는 한계가 있다 — 예산이 실한도의 80% 라 재시작 후 재소비분은
/// 20% 헤드룸이 흡수한다 (허용된 설계 트레이드오프, DB 카운터로의 승격은 운영 관찰 후 결정).
///
public sealed class SeibroQuota
{
private readonly Lock _lock = new();
private readonly Func _todayKst;
private readonly Dictionary _used = [];
private DateOnly _date;
public SeibroQuota() : this(TodayKst) { }
/// 테스트용 — KST 오늘 날짜 공급자 주입 (날짜 롤오버 검증)
public SeibroQuota(Func todayKstProvider)
{
_todayKst = todayKstProvider;
_date = _todayKst();
}
/// count 만큼 소비 시도 — 예산 내면 소비 후 true, 초과면 소비 없이 false. count 가 0 이하면 항상 true.
public bool TryConsume(SeibroCategory category, int count, int dailyBudget)
{
if (count <= 0)
{
return true;
}
lock (_lock)
{
RolloverIfNeeded();
var used = _used.GetValueOrDefault(category);
if (used + count > dailyBudget)
{
return false;
}
_used[category] = used + count;
return true;
}
}
/// 오늘 남은 예산 (음수 없음) — rolling sweep 의 maxPerRun 산정용
public int Remaining(SeibroCategory category, int dailyBudget)
{
lock (_lock)
{
RolloverIfNeeded();
return Math.Max(0, dailyBudget - _used.GetValueOrDefault(category));
}
}
/// 오늘 소비량 (로그·모니터링용)
public int Used(SeibroCategory category)
{
lock (_lock)
{
RolloverIfNeeded();
return _used.GetValueOrDefault(category);
}
}
private void RolloverIfNeeded()
{
var today = _todayKst();
if (today == _date)
{
return;
}
_date = today;
_used.Clear();
}
private static DateOnly TodayKst()
{
return DateOnly.FromDateTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst));
}
// KST 는 DST 없음 — 시스템에 타임존 데이터가 없으면 고정 +9 fallback (DailyScheduledService 와 동일 규칙)
private static readonly TimeZoneInfo Kst = ResolveKst();
private static TimeZoneInfo ResolveKst()
{
if (TimeZoneInfo.TryFindSystemTimeZoneById("Asia/Seoul", out var tz))
{
return tz;
}
if (TimeZoneInfo.TryFindSystemTimeZoneById("Korea Standard Time", out tz))
{
return tz;
}
return TimeZoneInfo.CreateCustomTimeZone("KST", TimeSpan.FromHours(9), "KST", "KST");
}
}