import { NextRequest, NextResponse } from "next/server"; import { getDomesticOrderBook, KisDomesticOrderBookOutput, } from "@/lib/kis/domestic"; import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types"; import { hasKisApiSession } from "@/app/api/kis/_session"; import { KisCredentialInput, hasKisConfig, normalizeTradingEnv, } from "@/lib/kis/config"; import { DOMESTIC_KIS_SESSION_OVERRIDE_HEADER, parseDomesticKisSession, } from "@/lib/kis/domestic-market-session"; /** * @file app/api/kis/domestic/orderbook/route.ts * @description 국내주식 호가 조회 API */ export async function GET(request: NextRequest) { const hasSession = await hasKisApiSession(); if (!hasSession) { return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); } const { searchParams } = new URL(request.url); const symbol = (searchParams.get("symbol") ?? "").trim(); if (!/^\d{6}$/.test(symbol)) { return NextResponse.json( { error: "symbol은 6자리 숫자여야 합니다." }, { status: 400 }, ); } const credentials = readKisCredentialsFromHeaders(request.headers); if (!hasKisConfig(credentials)) { return NextResponse.json( { error: "KIS API 키 설정이 필요합니다.", }, { status: 400 }, ); } try { const sessionOverride = readSessionOverrideFromHeaders(request.headers); const raw = await getDomesticOrderBook(symbol, credentials, { sessionOverride, }); const levels = Array.from({ length: 10 }, (_, i) => { const idx = i + 1; return { askPrice: readOrderBookNumber(raw, `askp${idx}`, `ovtm_untp_askp${idx}`), bidPrice: readOrderBookNumber(raw, `bidp${idx}`, `ovtm_untp_bidp${idx}`), askSize: readOrderBookNumber( raw, `askp_rsqn${idx}`, `ovtm_untp_askp_rsqn${idx}`, ), bidSize: readOrderBookNumber(raw, ...resolveBidSizeKeys(idx)), }; }); const response: DashboardStockOrderBookResponse = { symbol, source: "kis", levels, totalAskSize: readOrderBookNumber( raw, "total_askp_rsqn", "ovtm_untp_total_askp_rsqn", "ovtm_total_askp_rsqn", ), totalBidSize: readOrderBookNumber( raw, "total_bidp_rsqn", "ovtm_untp_total_bidp_rsqn", "ovtm_total_bidp_rsqn", ), businessHour: readOrderBookString(raw, "bsop_hour", "ovtm_untp_last_hour"), hourClassCode: readOrderBookString(raw, "hour_cls_code"), tradingEnv: normalizeTradingEnv(credentials.tradingEnv), fetchedAt: new Date().toISOString(), }; return NextResponse.json(response, { headers: { "cache-control": "no-store", }, }); } catch (error) { const message = error instanceof Error ? error.message : "호가 조회 중 오류가 발생했습니다."; return NextResponse.json({ error: message }, { status: 500 }); } } function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput { const appKey = headers.get("x-kis-app-key")?.trim(); const appSecret = headers.get("x-kis-app-secret")?.trim(); const tradingEnv = normalizeTradingEnv( headers.get("x-kis-trading-env") ?? undefined, ); return { appKey, appSecret, tradingEnv, }; } function readSessionOverrideFromHeaders(headers: Headers) { if (process.env.NODE_ENV === "production") return null; const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER); return parseDomesticKisSession(raw); } /** * @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다. * @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다. */ function readOrderBookNumber(raw: KisDomesticOrderBookOutput, ...keys: string[]) { const record = raw as Record; const value = resolveOrderBookValue(record, keys) ?? "0"; const normalized = typeof value === "string" ? value.replaceAll(",", "").trim() : String(value ?? "0"); const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : 0; } /** * @description 호가 응답 필드를 문자열로 읽습니다. * @see app/api/kis/domestic/orderbook/route.ts GET 응답 생성 시 businessHour/hourClassCode 추출 */ function readOrderBookString(raw: KisDomesticOrderBookOutput, ...keys: string[]) { const record = raw as Record; const value = resolveOrderBookValue(record, keys); if (value === undefined || value === null) return undefined; const text = String(value).trim(); return text.length > 0 ? text : undefined; } function resolveOrderBookValue(record: Record, keys: string[]) { for (const key of keys) { const direct = record[key]; if (direct !== undefined && direct !== null) return direct; const upper = record[key.toUpperCase()]; if (upper !== undefined && upper !== null) return upper; } return undefined; } function resolveBidSizeKeys(index: number) { if (index === 2) { return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`, "ovtm_untp_bidp_rsqn"]; } return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`]; }