| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
- using Application.Abstractions.Data;
- using Application.Helpers;
- using Microsoft.EntityFrameworkCore;
- using Microsoft.Extensions.DependencyInjection;
- using Microsoft.Extensions.Logging;
- using Microsoft.Extensions.Options;
- using SharedKernel;
- namespace Infrastructure.StockData;
- /// <summary>
- /// SEIBro 발행회사번호(ISSUCO_CUSTNO) 조인키 부트스트랩 + 종목 보강 (Wave 0) — 기본 06:30 KST, `Seibro:IssuerSync` 게이트.
- /// a) getShotnByMart × MART_TPCD {11,12,13,14} (주식 예산 4콜) → SeibroIssuerCode upsert(UQ Market+ShortCode)
- /// → Stock.IssucoCustno 스탬핑 (Code=단축코드 조인, 이미 같은 값이면 무변경)
- /// b) getStkStatInfo per-키 rolling — IssucoCustno 는 있는데 FaceValue 가 null 인 활성 Stock 우선(미보강 우선),
- /// SeibroRollingSweep + SeibroQuota(Stock 카테고리) 상한 내 SHOTN_ISIN 단건 호출 → Stock.EnrichFromSeibro.
- /// resultCount=0 은 정상 빈 결과(해당 종목만 미보강 유지 — 다음 실행에서 재시도), 종목별 SaveChanges 로 중단 시에도 진행분 보존.
- /// 실패 처리: HTTP·파싱 오류는 경고 로그 + 해당 run 중단(false 반환) → DailyScheduledService 가 RetryDelay 후 재시도.
- /// quota 소진은 정상 종료(true) — 일일 예산은 KST 자정 롤오버로만 회복되므로 재시도가 무의미하다.
- /// ApiKey 미설정 시 로그만 남기고 skip (다른 수집 배치와 동일 정책).
- /// </summary>
- internal sealed class SeibroIssuerSyncService(
- IServiceScopeFactory scopeFactory,
- IHttpClientFactory httpClientFactory,
- SeibroQuota quota,
- IOptions<AppSettings> settings,
- ILogger<SeibroIssuerSyncService> logger
- ) : DailyScheduledService(logger)
- {
- // 시장구분(MART_TPCD) — [11]유가 [12]코스닥 [13]K-OTC [14]코넥스. K-OTC 는 KRX Stock 미커버 시장의 유일 마스터.
- private static readonly string[] Markets = ["11", "12", "13", "14"];
- protected override string JobName => "SeibroIssuerSync";
- protected override TimeOnly TargetTime => ParseTime(settings.Value.Seibro.IssuerSyncTime, new TimeOnly(6, 30));
- protected override int MaxRetryCount => 2;
- protected override TimeSpan RetryDelay => TimeSpan.FromHours(2);
- protected override async Task<bool> RunOnceAsync(DateOnly todayKst, CancellationToken ct)
- {
- var cfg = settings.Value.Seibro;
- if (string.IsNullOrWhiteSpace(cfg.ApiKey))
- {
- Logger.LogWarning("[{Job}] Seibro:ApiKey 미설정 — 수집 skip", JobName);
- return true;
- }
- using var scope = scopeFactory.CreateScope();
- var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
- var client = httpClientFactory.CreateClient(SeibroHttp.ClientName);
- try
- {
- if (!await SyncIssuerCodesAsync(db, client, cfg, ct))
- {
- return true; // quota 소진 — 오늘은 더 진행 불가 (정상 종료)
- }
- await EnrichStocksAsync(db, client, cfg, ct);
- return true;
- }
- catch (Exception ex) when (ex is HttpRequestException or System.Xml.XmlException or FormatException)
- {
- Logger.LogWarning(ex, "[{Job}] SEIBro 호출/파싱 실패 — run 중단, {Delay} 후 재시도 (최대 {Max}회)", JobName, RetryDelay, MaxRetryCount);
- return false;
- }
- }
- /// <summary>a) 시장별 단축코드 스냅샷 4콜 → SeibroIssuerCode upsert + Stock.IssucoCustno 스탬핑. quota 소진 시 false.</summary>
- private async Task<bool> SyncIssuerCodesAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
- {
- var custnoByShortCode = new Dictionary<string, int>();
- var first = true;
- foreach (var market in Markets)
- {
- if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
- {
- Logger.LogWarning("[{Job}] 주식 카테고리 일일 예산({Budget}) 소진 — getShotnByMart {Market} 부터 중단", JobName, cfg.StockBudget, market);
- return false;
- }
- if (!first && cfg.DelayMs > 0)
- {
- await Task.Delay(cfg.DelayMs, ct);
- }
- first = false;
- var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getShotnByMart", [new("MART_TPCD", market)], Logger, ct);
- var envelope = SeibroXml.Parse(xml);
- var rows = SeibroShotnByMartParser.Parse(envelope);
- var (inserted, updated) = await SeibroIssuerImport.UpsertIssuerCodesAsync(db, market, rows, ct);
- Logger.LogInformation("[{Job}] getShotnByMart MART_TPCD={Market} rows={Rows} (result={Result}) — inserted={Inserted}, updated={Updated}",
- JobName, market, rows.Count, envelope.ResultCount, inserted, updated);
- foreach (var row in rows)
- {
- custnoByShortCode[row.ShortCode] = row.IssucoCustno;
- }
- }
- var stamped = await SeibroIssuerImport.StampStockIssucoCustnoAsync(db, custnoByShortCode, ct);
- Logger.LogInformation("[{Job}] Stock.IssucoCustno 스탬핑 — {Stamped}건 (스냅샷 {Snapshot}코드)", JobName, stamped, custnoByShortCode.Count);
- return true;
- }
- // getStkStatInfo rolling 마커 job 키 (리뷰 결함 #1 — FaceValue null 판정은 정상 0행 종목을 매 실행 재조회)
- private const string EnrichJobKey = "stock-enrich";
- /// <summary>
- /// b) getStkStatInfo rolling — 미보강(FaceValue null) 활성 종목부터 quota 잔여분 내에서 보강.
- /// 대상 정렬은 "FaceValue null" 이 아닌 SeibroFetchMarker(조회 시도 시각) 기준 — 정상 0행 종목이 매 실행 최우선 재조회되어
- /// 나머지가 굶던 결함(#1) 해소. 조회 시도(0행 포함)마다 마커 Touch → 다음 실행에서 후순위로 밀려 나머지가 진행된다.
- /// </summary>
- private async Task EnrichStocksAsync(IAppDbContext db, HttpClient client, AppSettings.SeibroSection cfg, CancellationToken ct)
- {
- var targets = await db.Stock.Where(c => c.IsActive && c.IssucoCustno != null && c.FaceValue == null).OrderBy(c => c.ID).ToListAsync(ct);
- if (targets.Count == 0)
- {
- Logger.LogInformation("[{Job}] getStkStatInfo 보강 대상 없음", JobName);
- return;
- }
- var markers = await SeibroMarkers.LoadFetchMarkersAsync(db, EnrichJobKey, ct);
- var ordered = targets.Select(c => (Key: c, LastUpdatedAt: markers.TryGetValue(c.Code, out var last) ? (DateTime?)last : null)).ToList();
- var maxPerRun = quota.Remaining(SeibroCategory.Stock, cfg.StockBudget);
- var enriched = 0;
- var processed = await SeibroRollingSweep.RunAsync(
- targets: ordered,
- fetchAndUpsert: async (stock, token) => {
- if (!quota.TryConsume(SeibroCategory.Stock, 1, cfg.StockBudget))
- {
- return; // 예산 경합 방어 — maxPerRun 산정 이후 다른 배치가 소비한 경우 skip
- }
- var xml = await SeibroHttp.GetStringWithRetryAsync(client, cfg.BaseUrl, cfg.ApiKey, "getStkStatInfo", [new("SHOTN_ISIN", stock.Code)], Logger, token);
- var rows = SeibroStkStatParser.Parse(SeibroXml.Parse(xml));
- if (rows.Count == 0)
- {
- // 0행이어도 조회 시도 마커를 남겨 다음 실행에서 후순위로 밀린다 (starvation 방지)
- await SeibroMarkers.TouchFetchMarkerAsync(db, EnrichJobKey, stock.Code, token);
- Logger.LogInformation("[{Job}] getStkStatInfo SHOTN_ISIN={Code} 0행 — 미보강 유지, 마커 갱신 (다음 실행 후순위)", JobName, stock.Code);
- return;
- }
- var row = rows[0];
- stock.EnrichFromSeibro(row.IssucoCustno, row.FaceValue, row.VotingRights, row.TotalIssuedShares, row.IsElectronicSecurity);
- await db.SaveChangesAsync(token);
- await SeibroMarkers.TouchFetchMarkerAsync(db, EnrichJobKey, stock.Code, token);
- enriched++;
- },
- maxPerRun: maxPerRun,
- delayMs: cfg.DelayMs,
- ct: ct);
- Logger.LogInformation("[{Job}] getStkStatInfo rolling 완료 — 대상={Targets}, 호출={Processed} (maxPerRun={Max}), 보강={Enriched}",
- JobName, targets.Count, processed, maxPerRun, enriched);
- }
- }
|