KosisSyncService.cs 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. using Application.Abstractions.Data;
  2. using Microsoft.Extensions.DependencyInjection;
  3. using Microsoft.Extensions.Logging;
  4. using Microsoft.Extensions.Options;
  5. using SharedKernel;
  6. namespace Infrastructure.StockData;
  7. /// <summary>
  8. /// 거시경제지표 수집 (KOSIS 국가통계포털 statisticsData.do) — config Indicators 를 순회하며 각 지표의
  9. /// [today-BackfillYears, today] 시점 범위를 조회해 MacroIndicator upsert (UQ = Code+Period). 기본 07:50 KST 실행.
  10. /// 지표별 tblId/itmId/분류코드는 config(AppSettings.Kosis.Indicators)로 주도 — 코드 변경 없이 교정 가능하다.
  11. /// 콜 수가 적어(지표 6종 × 1회) quota 걱정은 낮지만 지표 사이 DelayMs 간격을 둔다. 빈 응답·err JSON·미고시 시점은
  12. /// 정상(0건)으로 취급해 그 지표만 skip 하고 다음으로 넘어간다. 예외 발생 시 경고 후 false 반환(다음 날/재시도).
  13. /// </summary>
  14. internal sealed class KosisSyncService(
  15. IServiceScopeFactory scopeFactory,
  16. IHttpClientFactory httpClientFactory,
  17. IOptions<AppSettings> settings,
  18. ILogger<KosisSyncService> logger
  19. ) : DailyScheduledService(logger)
  20. {
  21. protected override string JobName => "KosisSync";
  22. protected override TimeOnly TargetTime => ParseTime(settings.Value.Kosis.SyncTime, new TimeOnly(7, 50));
  23. protected override int MaxRetryCount => 2;
  24. protected override TimeSpan RetryDelay => TimeSpan.FromHours(1);
  25. protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
  26. {
  27. var cfg = settings.Value.Kosis;
  28. using var scope = scopeFactory.CreateScope();
  29. var collectorSettings = scope.ServiceProvider.GetRequiredService<ICollectorSettingsProvider>();
  30. if (!await collectorSettings.IsEnabledAsync(CollectorFlag.Kosis, ct))
  31. {
  32. return true;
  33. }
  34. cfg = cfg with { ApiKey = await collectorSettings.GetKeyAsync(CollectorKey.Kosis, ct) ?? cfg.ApiKey };
  35. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  36. if (string.IsNullOrWhiteSpace(cfg.ApiKey))
  37. {
  38. Logger.LogWarning("[{Job}] Kosis:ApiKey 미설정 — 수집 skip", JobName);
  39. return true;
  40. }
  41. if (cfg.Indicators.Count == 0)
  42. {
  43. Logger.LogWarning("[{Job}] Kosis:Indicators 비어 있음 — 수집 skip", JobName);
  44. return true;
  45. }
  46. var client = httpClientFactory.CreateClient(KosisHttp.ClientName);
  47. var baseUrl = cfg.BaseUrl.TrimEnd('/');
  48. var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 5;
  49. var startYear = todayKst.AddYears(-years);
  50. var ok = true;
  51. foreach (var indicator in cfg.Indicators)
  52. {
  53. ct.ThrowIfCancellationRequested();
  54. if (string.IsNullOrWhiteSpace(indicator.Code) || string.IsNullOrWhiteSpace(indicator.OrgId) || string.IsNullOrWhiteSpace(indicator.TblId))
  55. {
  56. Logger.LogWarning("[{Job}] 지표 정의 불완전 (Code/OrgId/TblId) — skip: Code={Code}", JobName, indicator.Code);
  57. continue;
  58. }
  59. try
  60. {
  61. var (inserted, updated) = await FetchAndUpsertAsync(db, client, baseUrl, cfg.ApiKey, indicator, startYear, todayKst, ct);
  62. Logger.LogInformation("[{Job}] {Code}({Tbl}) 완료 — inserted={Ins}, updated={Upd}", JobName, indicator.Code, indicator.TblId, inserted, updated);
  63. }
  64. catch (OperationCanceledException)
  65. {
  66. throw;
  67. }
  68. catch (Exception ex)
  69. {
  70. ok = false;
  71. Logger.LogWarning(ex, "[{Job}] {Code}({Tbl}) 수집 실패 — 다음 지표 계속", JobName, indicator.Code, indicator.TblId);
  72. }
  73. if (cfg.DelayMs > 0)
  74. {
  75. await Task.Delay(cfg.DelayMs, ct);
  76. }
  77. }
  78. return ok;
  79. }
  80. private async Task<(int Inserted, int Updated)> FetchAndUpsertAsync(
  81. IAppDbContext db,
  82. HttpClient client,
  83. string baseUrl,
  84. string apiKey,
  85. AppSettings.KosisSection.KosisIndicator indicator,
  86. DateOnly startDate,
  87. DateOnly today,
  88. CancellationToken ct
  89. ) {
  90. var cycle = string.IsNullOrWhiteSpace(indicator.Cycle) ? "M" : indicator.Cycle.Trim().ToUpperInvariant();
  91. var startPrd = FormatPeriod(startDate, cycle);
  92. var endPrd = FormatPeriod(today, cycle);
  93. var itmId = string.IsNullOrWhiteSpace(indicator.ItmId) ? "ALL" : indicator.ItmId.Trim();
  94. var objL1 = string.IsNullOrWhiteSpace(indicator.ObjL1) ? "ALL" : indicator.ObjL1.Trim();
  95. 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}";
  96. var json = await KosisHttp.GetStringWithRetryAsync(client, url, Logger, ct);
  97. var parsed = KosisParser.Parse(json);
  98. Logger.LogInformation("[{Job}] {Code} prdSe={Cycle} [{Start}~{End}] rows={Rows}", JobName, indicator.Code, cycle, startPrd, endPrd, parsed.Count);
  99. if (parsed.Count == 0)
  100. {
  101. return (0, 0);
  102. }
  103. return await KosisImport.UpsertAsync(db, indicator, parsed, ct);
  104. }
  105. /// <summary>KOSIS prdSe 별 시점 형식 — M=YYYYMM, Q=YYYYQ(분기 1~4), Y=YYYY.</summary>
  106. private static string FormatPeriod(DateOnly date, string cycle)
  107. {
  108. return cycle switch
  109. {
  110. "Y" => date.Year.ToString("D4"),
  111. "Q" => $"{date.Year:D4}{(date.Month - 1) / 3 + 1}",
  112. _ => $"{date.Year:D4}{date.Month:D2}"
  113. };
  114. }
  115. }