V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,8 @@
// generated from contracts/providers/kbx.providers.json; do not edit.
namespace Kbx.Shared.Providers.Generated;
public sealed record KbxExternalProviderDefinition(string Id,string Title,string OwnerModule,string Purpose,bool MutationAllowed,IReadOnlyList<string> OfficialSources);
public static class KbxExternalProviderCatalog { public const string SourceSha256="5515e5e002767ed895ea0442003829b0bad8fa9c7a0693ce6cdcbd65969b830f"; public static readonly IReadOnlyDictionary<string,KbxExternalProviderDefinition> All=new Dictionary<string,KbxExternalProviderDefinition>(StringComparer.Ordinal) {
["provider.krx.openapi"] = new("provider.krx.openapi", "KRX Data Marketplace OPEN API", "COMMON", "market-data-readonly", false, new[] { "https://openapi.krx.co.kr/contents/OPP/INFO/OPPINFO003.jsp", "https://openapi.krx.co.kr/contents/OPP/INFO/service/OPPINFO004.cmd" }),
["provider.opendart"] = new("provider.opendart", "금융감독원 OPENDART OpenAPI", "COMMON", "disclosure-data-readonly", false, new[] { "https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019001", "https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019002", "https://opendart.fss.or.kr/guide/detail.do?apiGrpCd=DS001&apiId=2019018" }),
["provider.kis.market-data"] = new("provider.kis.market-data", "한국투자증권 KIS Open API — 국내주식 시세", "COMMON", "market-data-readonly", false, new[] { "https://apiportal.koreainvestment.com/apiservice-apiservice", "https://apiportal.koreainvestment.com/community/10000000-0000-0011-0000-000000000001/post/d0d1a83f-6f8d-4437-9700-6d26702fd989", "https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/domestic_stock/inquire_price/inquire_price.py", "https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/kis_auth.py" })
}; }
@@ -0,0 +1,21 @@
namespace Kbx.Shared.Providers;
public enum KbxProviderResultKind { Success, NoData, TransientFailure, PermanentFailure }
public sealed record KbxProviderResult<T>(KbxProviderResultKind Kind,T? Value=null,string? Code=null,string? Message=null,int? HttpStatus=null)
{
public bool IsSuccess => Kind is KbxProviderResultKind.Success or KbxProviderResultKind.NoData;
}
public interface IKbxProviderSecretStore
{
ValueTask<string> GetRequiredAsync(string configurationKey, CancellationToken cancellationToken);
}
public interface IKbxExternalProviderAdapter
{
string ProviderId { get; }
}
public sealed record KrxApprovedServiceRequest(HttpMethod Method, Uri ServiceUri, IReadOnlyDictionary<string,string?> Query, string? Body = null);
public sealed record KisCurrentPriceRequest(string MarketDivisionCode,string StockCode,bool Sandbox=false);
@@ -0,0 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
namespace Kbx.Shared.Providers;
public static class KbxProviderRegistration
{
public static IServiceCollection AddKbxExternalProviders(this IServiceCollection services)
{
services.AddSingleton<KisRequestPacer>();
services.AddSingleton<IKisAccessTokenProvider, KisAccessTokenProvider>();
services.AddHttpClient<KrxOpenApiAdapter>();
services.AddHttpClient<OpenDartAdapter>();
services.AddHttpClient<KisAccessTokenProvider>();
services.AddHttpClient<KisMarketDataAdapter>();
return services;
}
}
@@ -0,0 +1,39 @@
using System.Text;
using System.Text.Json;
namespace Kbx.Shared.Providers;
public interface IKisAccessTokenProvider { ValueTask<string> GetAsync(bool sandbox, CancellationToken cancellationToken); }
public sealed class KisAccessTokenProvider(HttpClient http, IKbxProviderSecretStore secrets) : IKisAccessTokenProvider
{
private readonly SemaphoreSlim _gate = new(1,1);
private string? _token;
private DateTimeOffset _expiresAt;
private bool _sandbox;
public async ValueTask<string> GetAsync(bool sandbox, CancellationToken ct)
{
if (_token is not null && _sandbox == sandbox && DateTimeOffset.UtcNow < _expiresAt.AddMinutes(-5)) return _token;
await _gate.WaitAsync(ct);
try
{
if (_token is not null && _sandbox == sandbox && DateTimeOffset.UtcNow < _expiresAt.AddMinutes(-5)) return _token;
var appKey = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppKey", ct);
var appSecret = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppSecret", ct);
var baseUri = sandbox ? "https://openapivts.koreainvestment.com:29443" : "https://openapi.koreainvestment.com:9443";
var payload = JsonSerializer.Serialize(new { grant_type="client_credentials", appkey=appKey, appsecret=appSecret });
using var req = new HttpRequestMessage(HttpMethod.Post, baseUri + "/oauth2/tokenP") { Content = new StringContent(payload, Encoding.UTF8, "application/json") };
using var res = await http.SendAsync(req, ct);
res.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
_token = doc.RootElement.GetProperty("access_token").GetString() ?? throw new InvalidOperationException("KIS access_token missing");
_sandbox = sandbox;
if (doc.RootElement.TryGetProperty("access_token_token_expired", out var exp) && DateTimeOffset.TryParse(exp.GetString(), out var parsed)) _expiresAt = parsed;
else if (doc.RootElement.TryGetProperty("expires_in", out var sec) && sec.TryGetInt32(out var seconds)) _expiresAt = DateTimeOffset.UtcNow.AddSeconds(seconds);
else _expiresAt = DateTimeOffset.UtcNow.AddHours(24);
return _token;
}
finally { _gate.Release(); }
}
}
@@ -0,0 +1,38 @@
using System.Text.Json;
namespace Kbx.Shared.Providers;
public sealed class KisMarketDataAdapter(HttpClient http, IKbxProviderSecretStore secrets, IKisAccessTokenProvider tokens, KisRequestPacer pacer) : IKbxExternalProviderAdapter
{
public string ProviderId => "provider.kis.market-data";
public async Task<KbxProviderResult<JsonDocument>> GetDomesticStockCurrentPriceAsync(KisCurrentPriceRequest input, CancellationToken ct)
{
await pacer.WaitAsync(input.Sandbox, ct);
var appKey = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppKey", ct);
var appSecret = await secrets.GetRequiredAsync("ExternalProviders:Kis:AppSecret", ct);
var token = await tokens.GetAsync(input.Sandbox, ct);
var baseUri = input.Sandbox ? "https://openapivts.koreainvestment.com:29443" : "https://openapi.koreainvestment.com:9443";
var uri = baseUri + "/uapi/domestic-stock/v1/quotations/inquire-price" +
$"?FID_COND_MRKT_DIV_CODE={Uri.EscapeDataString(input.MarketDivisionCode)}&FID_INPUT_ISCD={Uri.EscapeDataString(input.StockCode)}";
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
req.Headers.TryAddWithoutValidation("authorization", "Bearer " + token);
req.Headers.TryAddWithoutValidation("appkey", appKey);
req.Headers.TryAddWithoutValidation("appsecret", appSecret);
req.Headers.TryAddWithoutValidation("tr_id", "FHKST01010100");
req.Headers.TryAddWithoutValidation("custtype", "P");
req.Headers.TryAddWithoutValidation("tr_cont", "");
using var res = await http.SendAsync(req, ct);
var text = await res.Content.ReadAsStringAsync(ct);
if (!res.IsSuccessStatusCode)
return new((int)res.StatusCode >= 500 || (int)res.StatusCode is 408 or 429 ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)res.StatusCode}", Message:"KIS 시세 HTTP 호출 실패", HttpStatus:(int)res.StatusCode);
JsonDocument doc;
try { doc = JsonDocument.Parse(text); } catch { return new(KbxProviderResultKind.PermanentFailure, Code:"KIS_INVALID_JSON", Message:"KIS 응답 JSON을 해석할 수 없습니다."); }
var root=doc.RootElement;
var rt=root.TryGetProperty("rt_cd",out var r)?r.GetString():null;
if(rt=="0") return new(KbxProviderResultKind.Success,doc);
var code=root.TryGetProperty("msg_cd",out var c)?c.GetString():"KIS_PROVIDER_ERROR";
var msg=root.TryGetProperty("msg1",out var m)?m.GetString():"KIS 시세 조회 실패";
return new(KbxProviderResultKind.PermanentFailure,doc,code,msg);
}
}
@@ -0,0 +1,19 @@
namespace Kbx.Shared.Providers;
public sealed class KisRequestPacer
{
private readonly SemaphoreSlim _gate = new(1,1);
private DateTimeOffset _last = DateTimeOffset.MinValue;
public async ValueTask WaitAsync(bool sandbox, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
var spacing = TimeSpan.FromMilliseconds(sandbox ? 1000 : 100); // KBX conservative default; official production max is higher.
var remaining = spacing - (DateTimeOffset.UtcNow - _last);
if (remaining > TimeSpan.Zero) await Task.Delay(remaining, ct);
_last = DateTimeOffset.UtcNow;
}
finally { _gate.Release(); }
}
}
@@ -0,0 +1,37 @@
using System.Net;
using System.Net.Http.Headers;
namespace Kbx.Shared.Providers;
public sealed class KrxOpenApiAdapter(HttpClient http, IKbxProviderSecretStore secrets) : IKbxExternalProviderAdapter
{
public string ProviderId => "provider.krx.openapi";
public async Task<KbxProviderResult<string>> InvokeApprovedServiceAsync(KrxApprovedServiceRequest request, CancellationToken ct)
{
if (!request.ServiceUri.IsAbsoluteUri || request.ServiceUri.Scheme != Uri.UriSchemeHttps || !request.ServiceUri.Host.EndsWith(".krx.co.kr", StringComparison.OrdinalIgnoreCase))
return new(KbxProviderResultKind.PermanentFailure, Code:"KRX_SERVICE_URL_NOT_APPROVED", Message:"KRX 승인 서비스의 HTTPS URL만 사용할 수 있습니다.");
if (request.Method != HttpMethod.Get && request.Method != HttpMethod.Post)
return new(KbxProviderResultKind.PermanentFailure, Code:"KRX_METHOD_NOT_ALLOWED", Message:"조회성 GET/POST만 허용됩니다.");
var key = await secrets.GetRequiredAsync("ExternalProviders:Krx:AuthKey", ct);
var uri = AppendQuery(request.ServiceUri, request.Query);
using var message = new HttpRequestMessage(request.Method, uri);
message.Headers.TryAddWithoutValidation("AUTH_KEY", key);
if (request.Method == HttpMethod.Post && request.Body is not null)
message.Content = new StringContent(request.Body, System.Text.Encoding.UTF8, "application/json");
using var response = await http.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (response.IsSuccessStatusCode) return new(KbxProviderResultKind.Success, body, HttpStatus:(int)response.StatusCode);
var transient = response.StatusCode is HttpStatusCode.RequestTimeout or (HttpStatusCode)429 || (int)response.StatusCode >= 500;
return new(transient ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)response.StatusCode}", Message:"KRX OPEN API 호출이 실패했습니다.", HttpStatus:(int)response.StatusCode);
}
private static Uri AppendQuery(Uri uri, IReadOnlyDictionary<string,string?> query)
{
if (query.Count == 0) return uri;
var parts = query.Where(x => x.Value is not null).Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}");
var builder = new UriBuilder(uri) { Query = string.Join("&", parts) };
return builder.Uri;
}
}
@@ -0,0 +1,48 @@
using System.Text.Json;
namespace Kbx.Shared.Providers;
public sealed class OpenDartAdapter(HttpClient http, IKbxProviderSecretStore secrets) : IKbxExternalProviderAdapter
{
private static readonly Uri BaseUri = new("https://opendart.fss.or.kr/api/");
public string ProviderId => "provider.opendart";
public Task<KbxProviderResult<JsonDocument>> GetDisclosuresAsync(IReadOnlyDictionary<string,string?> query, CancellationToken ct)
=> GetJsonAsync("list.json", query, ct);
public Task<KbxProviderResult<JsonDocument>> GetCompanyAsync(string corpCode, CancellationToken ct)
=> GetJsonAsync("company.json", new Dictionary<string,string?> { ["corp_code"] = corpCode }, ct);
public async Task<KbxProviderResult<byte[]>> DownloadCorpCodeAsync(CancellationToken ct)
{
var key = await secrets.GetRequiredAsync("ExternalProviders:OpenDart:ApiKey", ct);
var uri = new Uri(BaseUri, $"corpCode.xml?crtfc_key={Uri.EscapeDataString(key)}");
using var response = await http.GetAsync(uri, ct);
var bytes = await response.Content.ReadAsByteArrayAsync(ct);
if (response.IsSuccessStatusCode) return new(KbxProviderResultKind.Success, bytes, HttpStatus:(int)response.StatusCode);
return new((int)response.StatusCode >= 500 ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)response.StatusCode}", Message:"OPENDART 고유번호 파일 호출 실패", HttpStatus:(int)response.StatusCode);
}
private async Task<KbxProviderResult<JsonDocument>> GetJsonAsync(string path, IReadOnlyDictionary<string,string?> query, CancellationToken ct)
{
var key = await secrets.GetRequiredAsync("ExternalProviders:OpenDart:ApiKey", ct);
var pairs = new List<string> { $"crtfc_key={Uri.EscapeDataString(key)}" };
pairs.AddRange(query.Where(x=>x.Value is not null).Select(x=>$"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}"));
var uri = new Uri(BaseUri, path + "?" + string.Join("&", pairs));
using var response = await http.GetAsync(uri, ct);
var text = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
return new((int)response.StatusCode >= 500 || (int)response.StatusCode == 429 ? KbxProviderResultKind.TransientFailure : KbxProviderResultKind.PermanentFailure, Code:$"HTTP_{(int)response.StatusCode}", Message:"OPENDART HTTP 호출 실패", HttpStatus:(int)response.StatusCode);
JsonDocument doc;
try { doc = JsonDocument.Parse(text); } catch { return new(KbxProviderResultKind.PermanentFailure, Code:"DART_INVALID_JSON", Message:"OPENDART 응답 JSON을 해석할 수 없습니다."); }
var root = doc.RootElement;
var status = root.TryGetProperty("status", out var s) ? s.GetString() : null;
var message = root.TryGetProperty("message", out var m) ? m.GetString() : null;
return status switch
{
"000" or null => new(KbxProviderResultKind.Success, doc),
"013" => new(KbxProviderResultKind.NoData, doc, status, message),
"020" or "800" or "900" => new(KbxProviderResultKind.TransientFailure, doc, status, message),
_ => new(KbxProviderResultKind.PermanentFailure, doc, status, message)
};
}
}