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;
}
}