import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks"; import type { DashboardStockSearchItem, DashboardStockSearchResponse, KoreanStockIndexItem, } from "@/features/trade/types/trade.types"; import { hasKisApiSession } from "@/app/api/kis/_session"; 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/trade/data/korean-stocks.ts, features/trade/components/dashboard-main.tsx * @author jihoon87.lee */ /** * 국내주식 검색 API * @param request query string의 q(검색어) 사용 * @returns 종목 검색 결과 목록 * @see features/trade/components/dashboard-main.tsx 검색 폼에서 호출합니다. */ export async function GET(request: NextRequest) { const hasSession = await hasKisApiSession(); if (!hasSession) { return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 }); } // [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; }