109 lines
3.8 KiB
TypeScript
109 lines
3.8 KiB
TypeScript
|
|
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;
|
||
|
|
}
|