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}¶ms={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);
}
}