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