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; /// /// 전자공시(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 매핑 불필요. /// internal sealed class DisclosureSyncService( IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, IOptions settings, ILogger 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 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(); 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; } }