KosisSyncService.cs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  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. if (string.IsNullOrWhiteSpace(cfg.ApiKey))
  29. {
  30. Logger.LogWarning("[{Job}] Kosis:ApiKey 미설정 — 수집 skip", JobName);
  31. return true;
  32. }
  33. if (cfg.Indicators.Count == 0)
  34. {
  35. Logger.LogWarning("[{Job}] Kosis:Indicators 비어 있음 — 수집 skip", JobName);
  36. return true;
  37. }
  38. using var scope = scopeFactory.CreateScope();
  39. var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
  40. var client = httpClientFactory.CreateClient(KosisHttp.ClientName);
  41. var baseUrl = cfg.BaseUrl.TrimEnd('/');
  42. var years = cfg.BackfillYears > 0 ? cfg.BackfillYears : 5;
  43. var startYear = todayKst.AddYears(-years);
  44. var ok = true;
  45. foreach (var indicator in cfg.Indicators)
  46. {
  47. ct.ThrowIfCancellationRequested();
  48. if (string.IsNullOrWhiteSpace(indicator.Code) || string.IsNullOrWhiteSpace(indicator.OrgId) || string.IsNullOrWhiteSpace(indicator.TblId))
  49. {
  50. Logger.LogWarning("[{Job}] 지표 정의 불완전 (Code/OrgId/TblId) — skip: Code={Code}", JobName, indicator.Code);
  51. continue;
  52. }
  53. try
  54. {
  55. var (inserted, updated) = await FetchAndUpsertAsync(db, client, baseUrl, cfg.ApiKey, indicator, startYear, todayKst, ct);
  56. Logger.LogInformation("[{Job}] {Code}({Tbl}) 완료 — inserted={Ins}, updated={Upd}", JobName, indicator.Code, indicator.TblId, inserted, updated);
  57. }
  58. catch (OperationCanceledException)
  59. {
  60. throw;
  61. }
  62. catch (Exception ex)
  63. {
  64. ok = false;
  65. Logger.LogWarning(ex, "[{Job}] {Code}({Tbl}) 수집 실패 — 다음 지표 계속", JobName, indicator.Code, indicator.TblId);
  66. }
  67. if (cfg.DelayMs > 0)
  68. {
  69. await Task.Delay(cfg.DelayMs, ct);
  70. }
  71. }
  72. return ok;
  73. }
  74. private async Task<(int Inserted, int Updated)> FetchAndUpsertAsync(
  75. IAppDbContext db,
  76. HttpClient client,
  77. string baseUrl,
  78. string apiKey,
  79. AppSettings.KosisSection.KosisIndicator indicator,
  80. DateOnly startDate,
  81. DateOnly today,
  82. CancellationToken ct
  83. ) {
  84. var cycle = string.IsNullOrWhiteSpace(indicator.Cycle) ? "M" : indicator.Cycle.Trim().ToUpperInvariant();
  85. var startPrd = FormatPeriod(startDate, cycle);
  86. var endPrd = FormatPeriod(today, cycle);
  87. var itmId = string.IsNullOrWhiteSpace(indicator.ItmId) ? "ALL" : indicator.ItmId.Trim();
  88. var objL1 = string.IsNullOrWhiteSpace(indicator.ObjL1) ? "ALL" : indicator.ObjL1.Trim();
  89. 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}";
  90. var json = await KosisHttp.GetStringWithRetryAsync(client, url, Logger, ct);
  91. var parsed = KosisParser.Parse(json);
  92. Logger.LogInformation("[{Job}] {Code} prdSe={Cycle} [{Start}~{End}] rows={Rows}", JobName, indicator.Code, cycle, startPrd, endPrd, parsed.Count);
  93. if (parsed.Count == 0)
  94. {
  95. return (0, 0);
  96. }
  97. return await KosisImport.UpsertAsync(db, indicator, parsed, ct);
  98. }
  99. /// <summary>KOSIS prdSe 별 시점 형식 — M=YYYYMM, Q=YYYYQ(분기 1~4), Y=YYYY.</summary>
  100. private static string FormatPeriod(DateOnly date, string cycle)
  101. {
  102. return cycle switch
  103. {
  104. "Y" => date.Year.ToString("D4"),
  105. "Q" => $"{date.Year:D4}{(date.Month - 1) / 3 + 1}",
  106. _ => $"{date.Year:D4}{date.Month:D2}"
  107. };
  108. }
  109. }