| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123 |
- using Application.Abstractions.Data;
- using Domain.Entities.Stocks;
- using Microsoft.EntityFrameworkCore;
- using Microsoft.Extensions.DependencyInjection;
- using Microsoft.Extensions.Logging;
- using Microsoft.Extensions.Options;
- using SharedKernel;
- namespace Infrastructure.StockData;
- /// <summary>
- /// 전자공시(DART) 공시 목록 수집 (OpenDART list.json) — 기본 08:30 KST 실행. 접수일 [today-BackfillDays, today] 범위를
- /// page_count=100 으로 페이징(total_page 만큼 순회)하며 수집한다. KRX 배치처럼 날짜별 반복이 아니라 DART 는 날짜범위 페이징이므로
- /// KrxBackfill 대신 자체 페이징 루프를 쓴다. 접수번호(rcept_no) 로 upsert 하되 이미 있으면 skip(idempotent) — 갱신 없이 신규만 삽입.
- /// status "013"(데이터 없음) 은 정상 종료, "000" 외 다른 status 는 오류로 보고 후 중단. ApiKey 미설정 시 로그만 남기고 skip.
- /// list.json 이 stock_code 를 포함하므로 corpCode.xml 매핑 불필요.
- /// </summary>
- internal sealed class DisclosureSyncService(
- IServiceScopeFactory scopeFactory,
- IHttpClientFactory httpClientFactory,
- IOptions<AppSettings> settings,
- ILogger<DisclosureSyncService> logger
- ) : DailyScheduledService(logger)
- {
- private const int PageCount = 100;
- private const int MaxPages = 500; // 안전 상한 (total_page 무한루프 방지)
- protected override string JobName => "DisclosureSync";
- protected override TimeOnly TargetTime => ParseTime(settings.Value.OpenDart.DisclosureSyncTime, new TimeOnly(8, 30));
- protected override int MaxRetryCount => 2;
- protected override TimeSpan RetryDelay => TimeSpan.FromHours(1);
- protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
- {
- var cfg = settings.Value.OpenDart;
- if (string.IsNullOrWhiteSpace(cfg.ApiKey))
- {
- Logger.LogWarning("[{Job}] OpenDart:ApiKey 미설정 — 수집 skip", JobName);
- return true;
- }
- using var scope = scopeFactory.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
- var client = httpClientFactory.CreateClient(OpenDartHttp.ClientName);
- var days = cfg.BackfillDays > 0 ? cfg.BackfillDays : 30;
- var beginDate = todayKst.AddDays(-days);
- var baseUrl = cfg.BaseUrl.TrimEnd('/');
- var inserted = 0;
- var skipped = 0;
- var page = 1;
- var totalPage = 1;
- while (page <= totalPage && page <= MaxPages)
- {
- ct.ThrowIfCancellationRequested();
- var url = $"{baseUrl}/api/list.json?crtfc_key={cfg.ApiKey}&bgn_de={beginDate:yyyyMMdd}&end_de={todayKst:yyyyMMdd}&page_no={page}&page_count={PageCount}";
- var json = await OpenDartHttp.GetStringWithRetryAsync(client, url, Logger, ct);
- var result = DartDisclosureParser.Parse(json);
- if (result.Status == "013")
- {
- Logger.LogInformation("[{Job}] 조회된 데이터 없음(013) — 종료. 범위=[{Begin}~{Today}]", JobName, beginDate, todayKst);
- break;
- }
- if (result.Status != "000")
- {
- Logger.LogError("[{Job}] OpenDART 오류 status={Status} message={Message} (page={Page})", JobName, result.Status, result.Message, page);
- return false;
- }
- totalPage = result.TotalPage > 0 ? result.TotalPage : 1;
- if (result.Rows.Count == 0)
- {
- break;
- }
- var rceptNos = result.Rows.Select(c => c.RceptNo).ToList();
- var existing = (await db.Disclosure.AsNoTracking()
- .Where(c => rceptNos.Contains(c.RceptNo))
- .Select(c => c.RceptNo)
- .ToListAsync(ct)).ToHashSet();
- foreach (var row in result.Rows)
- {
- if (existing.Contains(row.RceptNo))
- {
- skipped++;
- continue;
- }
- var created = Disclosure.Create(row.RceptNo, row.CorpCode, row.CorpName, row.StockCode, row.CorpCls, row.ReportNm, row.FlrNm, row.RceptDt, row.Rm);
- await db.Disclosure.AddAsync(created, ct);
- existing.Add(row.RceptNo); // 같은 실행 내 중복 접수번호 방어
- inserted++;
- }
- await db.SaveChangesAsync(ct);
- Logger.LogInformation("[{Job}] page {Page}/{TotalPage} 처리 — rows={Rows} (누적 inserted={Inserted}, skipped={Skipped})",
- JobName, page, totalPage, result.Rows.Count, inserted, skipped);
- page++;
- if (page <= totalPage)
- {
- await Task.Delay(300, ct); // quota 보호
- }
- }
- Logger.LogInformation("[{Job}] 완료 — 범위=[{Begin}~{Today}], inserted={Inserted}, skipped={Skipped}",
- JobName, beginDate, todayKst, inserted, skipped);
- return true;
- }
- }
|