대시보드

This commit is contained in:
2026-02-06 17:50:35 +09:00
parent 35916430b7
commit 851a2acd69
34 changed files with 45632 additions and 108 deletions

View 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,
};
}

View 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;
}

View File

@@ -0,0 +1,64 @@
import type { DashboardKisRevokeResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { revokeKisAccessToken } from "@/lib/kis/token";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/revoke/route.ts
* @description 사용자 입력 KIS API 키 기반 접근토큰 폐기 라우트
*/
/**
* KIS API 접근토큰 폐기
* @param request appKey/appSecret/tradingEnv JSON 본문
* @returns 폐기 성공/실패 정보
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis - 접근 폐기 버튼 클릭 이벤트
*/
export async function POST(request: NextRequest) {
const body = (await request.json()) as Partial<DashboardKisRevokeRequest>;
const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
} satisfies DashboardKisRevokeResponse,
{ status: 400 },
);
}
try {
const message = await revokeKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisRevokeResponse);
} catch (error) {
const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
}
}
interface DashboardKisRevokeRequest {
appKey: string;
appSecret: string;
tradingEnv: "real" | "mock";
}

View File

@@ -0,0 +1,65 @@
import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getKisAccessToken } from "@/lib/kis/token";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/validate/route.ts
* @description 사용자 입력 KIS API 키 검증 라우트
*/
/**
* KIS API 키 검증
* @param request appKey/appSecret/tradingEnv JSON 본문
* @returns 검증 성공/실패 정보
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis - 검증 버튼 클릭 시 호출
*/
export async function POST(request: NextRequest) {
const body = (await request.json()) as Partial<DashboardKisValidateRequest>;
const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
} satisfies DashboardKisValidateResponse,
{ status: 400 },
);
}
try {
// 검증 단계는 토큰 발급 성공 여부만 확인합니다.
await getKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse);
} catch (error) {
const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
}
}
interface DashboardKisValidateRequest {
appKey: string;
appSecret: string;
tradingEnv: "real" | "mock";
}

View File

@@ -0,0 +1,67 @@
import type { DashboardKisWsApprovalResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/ws/approval/route.ts
* @description KIS 웹소켓 approval key 발급 라우트
*/
/**
* 실시간 웹소켓 승인키 발급
* @param request appKey/appSecret/tradingEnv JSON 본문
* @returns approval key + ws url
* @see features/dashboard/components/dashboard-main.tsx connectKisRealtimePrice - 실시간 체결가 구독 진입점
*/
export async function POST(request: NextRequest) {
const body = (await request.json()) as Partial<DashboardKisWsApprovalRequest>;
const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
} satisfies DashboardKisWsApprovalResponse,
{ status: 400 },
);
}
try {
const approvalKey = await getKisApprovalKey(credentials);
const wsUrl = resolveKisWebSocketUrl(credentials);
return NextResponse.json({
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
approvalKey,
wsUrl,
message: "KIS 실시간 웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse);
} catch (error) {
const message = error instanceof Error ? error.message : "웹소켓 승인키 발급 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
}
}
interface DashboardKisWsApprovalRequest {
appKey: string;
appSecret: string;
tradingEnv: "real" | "mock";
}