using Application.Abstractions.Data; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using SharedKernel; namespace Infrastructure.StockData; /// /// 거시경제지표 수집 (KOSIS 국가통계포털 statisticsData.do) — config Indicators 를 순회하며 각 지표의 /// [today-BackfillYears, today] 시점 범위를 조회해 MacroIndicator upsert (UQ = Code+Period). 기본 07:50 KST 실행. /// 지표별 tblId/itmId/분류코드는 config(AppSettings.Kosis.Indicators)로 주도 — 코드 변경 없이 교정 가능하다. /// 콜 수가 적어(지표 6종 × 1회) quota 걱정은 낮지만 지표 사이 DelayMs 간격을 둔다. 빈 응답·err JSON·미고시 시점은 /// 정상(0건)으로 취급해 그 지표만 skip 하고 다음으로 넘어간다. 예외 발생 시 경고 후 false 반환(다음 날/재시도). /// internal sealed class KosisSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, IOptions settings, ILogger logger ) : DailyScheduledService(logger) { protected override string JobName => "KosisSync"; protected override TimeOnly TargetTime => ParseTime(settings.Value.Kosis.SyncTime, new TimeOnly(7, 50)); protected override int MaxRetryCount => 2; protected override TimeSpan RetryDelay => TimeSpan.FromHours(1); protected override async Task RunOnceAsync(DateOnly todayKst, CancellationToken ct) { var cfg = settings.Value.Kosis; if (string.IsNullOrWhiteSpace(cfg.ApiKey)) { Logger.LogWarning("[{Job}] Kosis:ApiKey 미설정 — 수집 skip", JobName); return true; } if (cfg.Indicators.Count == 0) { Logger.LogWarning("[{Job}] Kosis:Indicators 비어 있음 — 수집 skip", JobName); return true; } using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var client = httpClientFactory.CreateClient(KosisHttp.ClientName); var baseUrl = cfg.BaseUrl.TrimEnd('/'); var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 5; var startYear = todayKst.AddYears(-years); var ok = true; foreach (var indicator in cfg.Indicators) { ct.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(indicator.Code) || string.IsNullOrWhiteSpace(indicator.OrgId) || string.IsNullOrWhiteSpace(indicator.TblId)) { Logger.LogWarning("[{Job}] 지표 정의 불완전 (Code/OrgId/TblId) — skip: Code={Code}", JobName, indicator.Code); continue; } try { var (inserted, updated) = await FetchAndUpsertAsync(db, client, baseUrl, cfg.ApiKey, indicator, startYear, todayKst, ct); Logger.LogInformation("[{Job}] {Code}({Tbl}) 완료 — inserted={Ins}, updated={Upd}", JobName, indicator.Code, indicator.TblId, inserted, updated); } catch (OperationCanceledException) { throw; } catch (Exception ex) { ok = false; Logger.LogWarning(ex, "[{Job}] {Code}({Tbl}) 수집 실패 — 다음 지표 계속", JobName, indicator.Code, indicator.TblId); } if (cfg.DelayMs > 0) { await Task.Delay(cfg.DelayMs, ct); } } return ok; } private async Task<(int Inserted, int Updated)> FetchAndUpsertAsync( IAppDbContext db, HttpClient client, string baseUrl, string apiKey, AppSettings.KosisSection.KosisIndicator indicator, DateOnly startDate, DateOnly today, CancellationToken ct ) { var cycle = string.IsNullOrWhiteSpace(indicator.Cycle) ? "M" : indicator.Cycle.Trim().ToUpperInvariant(); var startPrd = FormatPeriod(startDate, cycle); var endPrd = FormatPeriod(today, cycle); var itmId = string.IsNullOrWhiteSpace(indicator.ItmId) ? "ALL" : indicator.ItmId.Trim(); var objL1 = string.IsNullOrWhiteSpace(indicator.ObjL1) ? "ALL" : indicator.ObjL1.Trim(); var url = $"{baseUrl}?method=getList&apiKey={apiKey}&format=json&jsonVD=Y&orgId={indicator.OrgId}&tblId={indicator.TblId}&itmId={itmId}&objL1={objL1}&prdSe={cycle}&startPrdDe={startPrd}&endPrdDe={endPrd}"; var json = await KosisHttp.GetStringWithRetryAsync(client, url, Logger, ct); var parsed = KosisParser.Parse(json); Logger.LogInformation("[{Job}] {Code} prdSe={Cycle} [{Start}~{End}] rows={Rows}", JobName, indicator.Code, cycle, startPrd, endPrd, parsed.Count); if (parsed.Count == 0) { return (0, 0); } return await KosisImport.UpsertAsync(db, indicator, parsed, ct); } /// KOSIS prdSe 별 시점 형식 — M=YYYYMM, Q=YYYYQ(분기 1~4), Y=YYYY. private static string FormatPeriod(DateOnly date, string cycle) { return cycle switch { "Y" => date.Year.ToString("D4"), "Q" => $"{date.Year:D4}{(date.Month - 1) / 3 + 1}", _ => $"{date.Year:D4}{date.Month:D2}" }; } }