대시보드
This commit is contained in:
78
app/api/kis/domestic/overview/route.ts
Normal file
78
app/api/kis/domestic/overview/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
|
||||
import type { DashboardStockOverviewResponse } from "@/features/dashboard/types/dashboard.types";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||
import { getDomesticOverview } from "@/lib/kis/domestic";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/overview/route.ts
|
||||
* @description 국내주식 종목 상세(현재가 + 차트) API
|
||||
*/
|
||||
|
||||
/**
|
||||
* 국내주식 종목 상세 API
|
||||
* @param request query string의 symbol(6자리 종목코드) 사용
|
||||
* @returns 대시보드 상세 모델
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
|
||||
|
||||
try {
|
||||
const overview = await getDomesticOverview(symbol, fallbackMeta, credentials);
|
||||
|
||||
const response: DashboardStockOverviewResponse = {
|
||||
stock: overview.stock,
|
||||
source: "kis",
|
||||
priceSource: overview.priceSource,
|
||||
marketPhase: overview.marketPhase,
|
||||
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 : "KIS 조회 중 오류가 발생했습니다.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 헤더에서 KIS 키를 읽어옵니다.
|
||||
* @param headers 요청 헤더
|
||||
* @returns credentials
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
108
app/api/kis/domestic/search/route.ts
Normal file
108
app/api/kis/domestic/search/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
|
||||
import type {
|
||||
DashboardStockSearchItem,
|
||||
DashboardStockSearchResponse,
|
||||
KoreanStockIndexItem,
|
||||
} from "@/features/dashboard/types/dashboard.types";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SEARCH_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* @file app/api/kis/domestic/search/route.ts
|
||||
* @description 국내주식 종목명/종목코드 검색 API
|
||||
* @remarks
|
||||
* - [레이어] API Route
|
||||
* - [사용자 행동] 대시보드 검색창 엔터/검색 버튼 클릭 시 호출
|
||||
* - [데이터 흐름] dashboard-main.tsx -> /api/kis/domestic/search -> KOREAN_STOCK_INDEX 필터/정렬 -> JSON 응답
|
||||
* - [연관 파일] features/dashboard/data/korean-stocks.ts, features/dashboard/components/dashboard-main.tsx
|
||||
* @author jihoon87.lee
|
||||
*/
|
||||
|
||||
/**
|
||||
* 국내주식 검색 API
|
||||
* @param request query string의 q(검색어) 사용
|
||||
* @returns 종목 검색 결과 목록
|
||||
* @see features/dashboard/components/dashboard-main.tsx 검색 폼에서 호출합니다.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = (searchParams.get("q") ?? "").trim();
|
||||
|
||||
// [Step 2] 검색어가 없으면 빈 목록을 즉시 반환해 불필요한 계산을 줄입니다.
|
||||
if (!query) {
|
||||
const response: DashboardStockSearchResponse = {
|
||||
query,
|
||||
items: [],
|
||||
total: 0,
|
||||
};
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const normalized = normalizeKeyword(query);
|
||||
|
||||
// [Step 3] 인덱스에서 코드/이름 포함 여부로 1차 필터링 후 점수를 붙입니다.
|
||||
const ranked = KOREAN_STOCK_INDEX.filter((item) => {
|
||||
const symbol = item.symbol;
|
||||
const name = normalizeKeyword(item.name);
|
||||
return symbol.includes(normalized) || name.includes(normalized);
|
||||
})
|
||||
.map((item) => ({
|
||||
item,
|
||||
score: getSearchScore(item, normalized),
|
||||
}))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (a.item.market !== b.item.market) return a.item.market.localeCompare(b.item.market);
|
||||
return a.item.name.localeCompare(b.item.name, "ko");
|
||||
});
|
||||
|
||||
// [Step 4] UI에서 필요한 최소 필드만 남겨 SEARCH_LIMIT 만큼 반환합니다.
|
||||
const items: DashboardStockSearchItem[] = ranked.slice(0, SEARCH_LIMIT).map(({ item }) => ({
|
||||
symbol: item.symbol,
|
||||
name: item.name,
|
||||
market: item.market,
|
||||
}));
|
||||
|
||||
const response: DashboardStockSearchResponse = {
|
||||
query,
|
||||
items,
|
||||
total: ranked.length,
|
||||
};
|
||||
|
||||
// [Step 5] DashboardStockSearchResponse 형태로 응답합니다.
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색어 정규화(공백 제거 + 소문자)
|
||||
* @param value 원본 문자열
|
||||
* @returns 정규화 문자열
|
||||
* @see app/api/kis/domestic/search/route.ts 한글/영문 검색 비교 정확도를 높입니다.
|
||||
*/
|
||||
function normalizeKeyword(value: string) {
|
||||
return value.replaceAll(/\s+/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 검색 결과 점수 계산
|
||||
* @param item 종목 인덱스 항목
|
||||
* @param normalizedQuery 정규화된 검색어
|
||||
* @returns 높은 값일수록 우선순위 상위
|
||||
* @see app/api/kis/domestic/search/route.ts 검색 결과 정렬 기준으로 사용합니다.
|
||||
*/
|
||||
function getSearchScore(item: KoreanStockIndexItem, normalizedQuery: string) {
|
||||
const normalizedName = normalizeKeyword(item.name);
|
||||
const normalizedSymbol = item.symbol.toLowerCase();
|
||||
|
||||
if (normalizedSymbol === normalizedQuery) return 120;
|
||||
if (normalizedName === normalizedQuery) return 110;
|
||||
if (normalizedSymbol.startsWith(normalizedQuery)) return 100;
|
||||
if (normalizedName.startsWith(normalizedQuery)) return 90;
|
||||
if (normalizedName.includes(normalizedQuery)) return 70;
|
||||
if (normalizedSymbol.includes(normalizedQuery)) return 60;
|
||||
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user