using System.Text; using Microsoft.Extensions.Logging; namespace Infrastructure.StockData; /// /// SEIBro OpenAPI(seibro.or.kr/OpenPlatform/callOpenAPI.jsp) 호출 공통 — query param key 인증 + 간단 3회 재시도 (2s/4s 백오프). /// GET {BaseUrl}?key={ApiKey}&apiId={apiId}&params={K:V,K2:V2} /// • params 는 콜론(:)/콤마(,) 구분 단일 문자열 — SEIBro 비표준 규격이라 구분자(콜론·콤마)는 raw 로 보내고, /// 키·값만 개별 Uri.EscapeDataString 처리한다. 근거: KRX(KrxCoKrHttp)는 값이 전부 코드/날짜라 raw 조립이지만 /// SEIBro 도 값이 코드/날짜/ISIN 이라 escape 는 사실상 no-op — 다만 향후 예약문자(& 공백 등)가 값에 들어와도 /// query 가 깨지지 않도록 방어한다. 구분자까지 escape 하면(%3A/%2C) SEIBro 가 params 를 못 읽으므로 절대 금지 (docs/SEIBro/readme.md §1). /// • https 연결 실패(재시도 소진) 시 http 로 1회 fallback — SEIBro 가이드 원문이 http 엔드포인트라 환경에 따라 https 미지원 가능성 대비. /// • 응답 Content-Type charset 감지 디코딩 — EUC-KR 가능성 대비 CodePagesEncodingProvider 등록 /// (.NET 에서 euc-kr 등 코드페이지 인코딩은 기본 미등록 — System.Text.Encoding.CodePages 는 shared framework 포함). /// • 로그에 인증 key 는 절대 남기지 않는다 — apiId·params 만 기록 (KrxCoKrHttp 의 key 비로깅 정책과 동일). /// internal static class SeibroHttp { public const string ClientName = "Seibro"; private const int MaxAttempts = 3; static SeibroHttp() { // euc-kr/ks_c_5601-1987 등 한국어 코드페이지 디코딩 지원 (idempotent) Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); } /// 호출 URL 조립 — key 는 마지막에 붙여 로그용 문자열과 분리하기 쉽게 한다. 값 단위 escape, 구분자 raw. public static string BuildParams(IReadOnlyList> parameters) { return string.Join(',', parameters.Select(c => $"{Uri.EscapeDataString(c.Key)}:{Uri.EscapeDataString(c.Value)}")); } public static async Task GetStringWithRetryAsync(HttpClient client, string baseUrl, string apiKey, string apiId, IReadOnlyList> parameters, ILogger logger, CancellationToken ct) { var paramString = BuildParams(parameters); var url = $"{baseUrl}?key={Uri.EscapeDataString(apiKey)}&apiId={Uri.EscapeDataString(apiId)}¶ms={paramString}"; try { return await GetWithRetryAsync(client, url, apiId, paramString, logger, ct); } catch (HttpRequestException ex) when (url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { // https 연결 불가 → http 로 1회 fallback (공개 데이터 + 재시도 소진 후 최후 수단) var httpUrl = string.Concat("http://", url.AsSpan("https://".Length)); logger.LogWarning(ex, "[Seibro] https 실패 — http fallback 1회 시도 (apiId={ApiId}, params={Params})", apiId, paramString); using var request = new HttpRequestMessage(HttpMethod.Get, httpUrl); using var response = await client.SendAsync(request, ct); response.EnsureSuccessStatusCode(); return await ReadContentAsync(response, logger, ct); } } private static async Task GetWithRetryAsync(HttpClient client, string url, string apiId, string paramString, ILogger logger, CancellationToken ct) { for (var attempt = 1; ; attempt++) { try { using var request = new HttpRequestMessage(HttpMethod.Get, url); using var response = await client.SendAsync(request, ct); response.EnsureSuccessStatusCode(); return await ReadContentAsync(response, logger, ct); } catch (Exception ex) when (ex is not OperationCanceledException && attempt < MaxAttempts) { // key 마스킹: URL 대신 apiId/params 만 로깅 logger.LogWarning(ex, "[Seibro] HTTP 실패 — 재시도 {Attempt}/{Max} (apiId={ApiId}, params={Params})", attempt, MaxAttempts, apiId, paramString); await Task.Delay(TimeSpan.FromSeconds(2 * attempt), ct); } } } /// Content-Type charset 감지 디코딩 — charset 미표기/미지원이면 UTF-8 (샘플 실응답 기준 기본값) private static async Task ReadContentAsync(HttpResponseMessage response, ILogger logger, CancellationToken ct) { var bytes = await response.Content.ReadAsByteArrayAsync(ct); var charset = response.Content.Headers.ContentType?.CharSet?.Trim('"', '\''); var encoding = Encoding.UTF8; if (!string.IsNullOrWhiteSpace(charset)) { try { encoding = Encoding.GetEncoding(charset); } catch (ArgumentException) { logger.LogWarning("[Seibro] 미지원 charset '{Charset}' — UTF-8 로 디코딩", charset); } } return encoding.GetString(bytes); } }