diff --git a/.env.example b/.env.example index 809a30d..54fb7c7 100644 --- a/.env.example +++ b/.env.example @@ -7,23 +7,3 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= # 세션 타임아웃(분 단위) NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30 - -# KIS 거래 모드: real(실전) | mock(모의) -KIS_TRADING_ENV=real - -# 서버 기본 키를 쓰고 싶은 경우(선택) -KIS_APP_KEY_REAL= -KIS_APP_SECRET_REAL= -KIS_BASE_URL_REAL=https://openapi.koreainvestment.com:9443 -KIS_WS_URL_REAL=ws://ops.koreainvestment.com:21000 - -KIS_APP_KEY_MOCK= -KIS_APP_SECRET_MOCK= -KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443 -KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000 - -# (선택) 서버에서 기본 계좌번호를 사용할 경우 -# 형식: KIS_ACCOUNT_NO=12345678 또는 12345678-01 -# KIS_ACCOUNT_PRODUCT_CODE=01 -KIS_ACCOUNT_NO= -KIS_ACCOUNT_PRODUCT_CODE= diff --git a/AGENTS.md b/AGENTS.md index a5f0639..4b05b72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - 모든 응답과 설명은 한국어로 작성. - 쉬운 말로 설명하고, 어려운 용어는 괄호로 짧게 뜻을 덧붙임. - 요청이 모호하면 먼저 질문 1~3개로 범위를 확인. +- 소스 수정시 사이드이팩트가 발생하지 않게 사용자에게 묻거나 최대한 영향이 가지 않게 수정한다. 진짜 필요없는건 삭제하고 영향이 갈 내용이면 이전 소스가 영향이 없는지 검증하고 수정한다. ## 프로젝트 요약 @@ -54,3 +55,8 @@ - `mcp:kis-code-assistant-mcp` 활용 - `C:\dev\auto-trade\.tmp\open-trading-api` 활용 - API 이용시 공식 문서에 최신 업데이트가 안되어 있을수 있으므로 필요시 최신 API 명세 엑셀파일 요청을 한다. 그럼 사용자가 업로드 해줄것이다. + +## 소개문구 + +- 불안감을 해소하고 확신을 주는 문구 +- 친근하고 확신에 찬 문구를 사용하여 심리적 장벽을 낮추는 전략 diff --git a/README.md b/README.md index ad88170..fdb2efd 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,10 @@ Copy-Item .env.example .env.local - `NEXT_PUBLIC_SUPABASE_URL` - `NEXT_PUBLIC_SUPABASE_ANON_KEY` -KIS는 선택입니다(직접 입력 방식이면 서버 기본 키 없이도 동작 가능). +KIS는 `.env`에 저장하지 않고 `/settings` 입력 폼에서 직접 입력합니다. -- `KIS_TRADING_ENV=real|mock` -- `KIS_APP_KEY_REAL`, `KIS_APP_SECRET_REAL` (선택) -- `KIS_APP_KEY_MOCK`, `KIS_APP_SECRET_MOCK` (선택) +- 앱키/시크릿/계좌번호는 사용자 브라우저에서만 관리 +- 서버 API는 로그인 세션 + 요청 헤더로 전달된 키가 있어야 동작 - `NEXT_PUBLIC_BASE_URL` (선택, 배포 도메인 사용 시 권장) ### 5-4. 로컬 실행 diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx index a7ed244..be6aeee 100644 --- a/app/(home)/page.tsx +++ b/app/(home)/page.tsx @@ -4,34 +4,13 @@ */ import Link from "next/link"; -import type { LucideIcon } from "lucide-react"; -import { - Activity, - ArrowRight, - ShieldCheck, - Sparkles, - TrendingUp, - Zap, -} from "lucide-react"; +import { ArrowRight, Sparkles } from "lucide-react"; import { Header } from "@/features/layout/components/header"; import { AUTH_ROUTES } from "@/features/auth/constants"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import ShaderBackground from "@/components/ui/shader-background"; import { createClient } from "@/utils/supabase/server"; - -interface ValuePoint { - value: string; - label: string; - detail: string; -} - -interface FeatureItem { - icon: LucideIcon; - eyebrow: string; - title: string; - description: string; -} +import { AnimatedBrandTone } from "@/components/ui/animated-brand-tone"; interface StartStep { step: string; @@ -39,311 +18,195 @@ interface StartStep { description: string; } -const VALUE_POINTS: ValuePoint[] = [ - { - value: "3분", - label: "초기 세팅 시간", - detail: "복잡한 설정 대신 질문 기반으로 빠르게 시작합니다.", - }, - { - value: "24시간", - label: "실시간 시장 관제", - detail: "자리 비운 시간에도 규칙 기반으로 자동 대응합니다.", - }, - { - value: "0원", - label: "가입 시작 비용", - detail: "부담 없이 계정 생성 후 내 투자 스타일부터 점검합니다.", - }, -]; - -const FEATURE_ITEMS: FeatureItem[] = [ - { - icon: ShieldCheck, - eyebrow: "Risk Guard", - title: "손실 방어를 먼저 설계합니다", - description: - "진입보다 중요한 것은 방어입니다. 손절 기준과 노출 한도를 먼저 세워 감정 개입을 줄입니다.", - }, - { - icon: TrendingUp, - eyebrow: "Data Momentum", - title: "데이터로 타점을 좁힙니다", - description: - "실시간 데이터 흐름을 읽어 조건이 맞을 때만 실행합니다. 초보도 납득 가능한 근거를 확인할 수 있습니다.", - }, - { - icon: Zap, - eyebrow: "Auto Execution", - title: "기회가 오면 즉시 자동 실행합니다", - description: - "규칙이 충족되면 지연 없이 주문이 실행됩니다. 잠자는 시간과 업무 시간에도 전략은 멈추지 않습니다.", - }, -]; - const START_STEPS: StartStep[] = [ { - step: "STEP 01", - title: "계정 연결", - description: "안내에 따라 거래 계정을 안전하게 연결합니다.", + step: "01", + title: "1분이면 충분해요", + description: + "복잡한 서류나 방문 없이, 쓰던 계좌 그대로 안전하게 연결할 수 있어요.", }, { - step: "STEP 02", - title: "성향 선택", - description: "공격형·균형형·안정형 중 내 스타일을 고릅니다.", + step: "02", + title: "내 스타일대로 골라보세요", + description: + "공격적인 투자부터 안정적인 관리까지, 나에게 딱 맞는 전략이 준비되어 있어요.", }, { - step: "STEP 03", - title: "자동 실행 시작", - description: "선택한 전략으로 실시간 관제를 바로 시작합니다.", + step: "03", + title: "이제 일상을 즐기세요", + description: + "차트는 JOORIN-E가 하루 종일 보고 있을게요. 마음 편히 본업에 집중하세요.", }, ]; /** * 홈 메인 랜딩 페이지 * @returns 랜딩 UI - * @see features/layout/components/header.tsx blendWithBackground 모드 헤더를 함께 사용 */ export default async function HomePage() { - // [로그인 상태 조회] 사용자 유무에 따라 CTA 링크를 분기합니다. const supabase = await createClient(); const { data: { user }, } = await supabase.auth.getUser(); - // [CTA 분기] 로그인 여부에 따라 같은 위치에서 다른 행동으로 자연스럽게 전환합니다. const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP; - const primaryCtaLabel = user ? "대시보드로 전략 실행하기" : "무료로 시작하고 첫 전략 받기"; - const secondaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.LOGIN; - const secondaryCtaLabel = user ? "실시간 상태 확인하기" : "기존 계정으로 로그인"; + const primaryCtaLabel = user ? "시작하기" : "지금 무료로 시작하기"; return ( -
+
-
- {/* ========== SHADER BACKGROUND SECTION ========== */} - - )} diff --git a/features/settings/components/KisAuthForm.tsx b/features/settings/components/KisAuthForm.tsx index 37625eb..6e8070c 100644 --- a/features/settings/components/KisAuthForm.tsx +++ b/features/settings/components/KisAuthForm.tsx @@ -162,23 +162,21 @@ export function KisAuthForm() { )} - {isKisVerified && ( - - )} +
), status: ( diff --git a/features/settings/components/SettingsCard.tsx b/features/settings/components/SettingsCard.tsx index c37ba34..527751e 100644 --- a/features/settings/components/SettingsCard.tsx +++ b/features/settings/components/SettingsCard.tsx @@ -49,7 +49,7 @@ export function SettingsCard({ className, )} > -
+
{/* ========== CARD HEADER ========== */} diff --git a/features/settings/components/SettingsContainer.tsx b/features/settings/components/SettingsContainer.tsx index 7f68056..21ee8a0 100644 --- a/features/settings/components/SettingsContainer.tsx +++ b/features/settings/components/SettingsContainer.tsx @@ -31,7 +31,8 @@ export function SettingsContainer() { return (
{/* ========== SETTINGS OVERVIEW ========== */} -
+ +

@@ -43,8 +44,8 @@ export function SettingsContainer() {

- 진행 순서: 1) 앱키 연결 확인 {"->"} 2) 계좌 인증 {"->"} 3) - 거래 화면 사용 + 진행 순서: 1) 앱키 연결 확인 {"->"} 2) 계좌 인증 {"->"} 3) 거래 + 화면 사용

diff --git a/features/trade/components/TradeContainer.tsx b/features/trade/components/TradeContainer.tsx index 499f300..bf25779 100644 --- a/features/trade/components/TradeContainer.tsx +++ b/features/trade/components/TradeContainer.tsx @@ -1,7 +1,7 @@ "use client"; import { type FormEvent, useCallback, useEffect, useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store"; @@ -14,6 +14,7 @@ import { useKisTradeWebSocket } from "@/features/trade/hooks/useKisTradeWebSocke import { useStockOverview } from "@/features/trade/hooks/useStockOverview"; import { useCurrentPrice } from "@/features/trade/hooks/useCurrentPrice"; import { useTradeSearchPanel } from "@/features/trade/hooks/useTradeSearchPanel"; +import { useTradeNavigationStore } from "@/features/trade/store/use-trade-navigation-store"; import type { DashboardStockOrderBookResponse, DashboardStockSearchItem, @@ -27,9 +28,10 @@ import type { * @see features/trade/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다. */ export function TradeContainer() { - const searchParams = useSearchParams(); - const symbolParam = searchParams.get("symbol"); - const nameParam = searchParams.get("name"); + const router = useRouter(); + const consumePendingTarget = useTradeNavigationStore( + (state) => state.consumePendingTarget, + ); // [State] 호가 실시간 데이터 (체결 WS와 동일 소켓에서 수신) const [realtimeOrderBook, setRealtimeOrderBook] = @@ -60,28 +62,47 @@ export function TradeContainer() { useStockOverview(); /** - * [Effect] URL 파라미터(symbol) 감지 시 자동 종목 로드 - * 대시보드 등 외부에서 종목 코드를 넘겨받아 트레이딩 페이지로 진입할 때 사용합니다. + * [Effect] query string이 남아 들어온 경우 즉시 깨끗한 /trade URL로 정리합니다. + * 과거 링크/브라우저 히스토리로 유입되는 query 오염을 제거하기 위한 방어 로직입니다. */ useEffect(() => { - if (symbolParam && isKisVerified && verifiedCredentials && _hasHydrated) { - // 현재 선택된 종목과 파라미터가 다를 경우에만 자동 로드 수행 - if (selectedStock?.symbol !== symbolParam) { - setKeyword(nameParam || symbolParam); - appendSearchHistory({ - symbol: symbolParam, - name: nameParam || symbolParam, - market: "KOSPI", // 기본값 설정, loadOverview 이후 실제 데이터로 보완됨 - }); - loadOverview(symbolParam, verifiedCredentials); - } + if (typeof window === "undefined") return; + if (!window.location.search) return; + router.replace("/trade"); + }, [router]); + + /** + * [Effect] Dashboard에서 넘긴 종목을 1회 소비해 자동 로드합니다. + * @remarks UI 흐름: Dashboard 종목 클릭 -> useTradeNavigationStore.setPendingTarget -> /trade -> consumePendingTarget -> loadOverview + */ + useEffect(() => { + if (!isKisVerified || !verifiedCredentials || !_hasHydrated) { + return; } + + const pendingTarget = consumePendingTarget(); + if (!pendingTarget) return; + + if (selectedStock?.symbol === pendingTarget.symbol) { + return; + } + + setKeyword(pendingTarget.name || pendingTarget.symbol); + appendSearchHistory({ + symbol: pendingTarget.symbol, + name: pendingTarget.name || pendingTarget.symbol, + market: pendingTarget.market, + }); + loadOverview( + pendingTarget.symbol, + verifiedCredentials, + pendingTarget.market, + ); }, [ - symbolParam, - nameParam, isKisVerified, verifiedCredentials, _hasHydrated, + consumePendingTarget, selectedStock?.symbol, loadOverview, setKeyword, diff --git a/features/trade/hooks/useStockSearch.ts b/features/trade/hooks/useStockSearch.ts index b07fc45..60c15f6 100644 --- a/features/trade/hooks/useStockSearch.ts +++ b/features/trade/hooks/useStockSearch.ts @@ -6,7 +6,7 @@ import type { DashboardStockSearchItem, } from "@/features/trade/types/trade.types"; -const SEARCH_HISTORY_STORAGE_KEY = "jurini:stock-search-history:v1"; +const SEARCH_HISTORY_STORAGE_KEY = "joorine:stock-search-history:v1"; const SEARCH_HISTORY_LIMIT = 12; interface StoredSearchHistory { @@ -39,7 +39,10 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) { version: 1, items, }; - window.localStorage.setItem(SEARCH_HISTORY_STORAGE_KEY, JSON.stringify(payload)); + window.localStorage.setItem( + SEARCH_HISTORY_STORAGE_KEY, + JSON.stringify(payload), + ); } /** @@ -50,14 +53,16 @@ function writeSearchHistory(items: DashboardStockSearchHistoryItem[]) { export function useStockSearch() { // ========== SEARCH STATE ========== const [keyword, setKeyword] = useState(""); - const [searchResults, setSearchResults] = useState([]); + const [searchResults, setSearchResults] = useState< + DashboardStockSearchItem[] + >([]); const [error, setError] = useState(null); const [isSearching, setIsSearching] = useState(false); // ========== SEARCH HISTORY STATE ========== - const [searchHistory, setSearchHistory] = useState( - () => readSearchHistory(), - ); + const [searchHistory, setSearchHistory] = useState< + DashboardStockSearchHistoryItem[] + >(() => readSearchHistory()); // 동일 시점 중복 요청과 경합 응답을 막기 위한 취소 컨트롤러 const abortRef = useRef(null); @@ -142,7 +147,9 @@ export function useStockSearch() { */ const appendSearchHistory = useCallback((item: DashboardStockSearchItem) => { setSearchHistory((prev) => { - const deduped = prev.filter((historyItem) => historyItem.symbol !== item.symbol); + const deduped = prev.filter( + (historyItem) => historyItem.symbol !== item.symbol, + ); const nextItems: DashboardStockSearchHistoryItem[] = [ { ...item, savedAt: Date.now() }, ...deduped, diff --git a/features/trade/store/use-trade-navigation-store.ts b/features/trade/store/use-trade-navigation-store.ts new file mode 100644 index 0000000..e21568d --- /dev/null +++ b/features/trade/store/use-trade-navigation-store.ts @@ -0,0 +1,56 @@ +"use client"; + +import { create } from "zustand"; +import type { DashboardStockSearchItem } from "@/features/trade/types/trade.types"; + +/** + * @file features/trade/store/use-trade-navigation-store.ts + * @description 대시보드 -> 트레이드 이동 시 URL 쿼리 없이 종목 선택 상태를 1회 전달합니다. + */ + +export interface TradeNavigationTarget { + symbol: string; + name: string; + market: DashboardStockSearchItem["market"]; + requestedAt: number; +} + +interface TradeNavigationStoreState { + pendingTarget: TradeNavigationTarget | null; +} + +interface TradeNavigationStoreActions { + setPendingTarget: (target: Omit) => void; + consumePendingTarget: () => TradeNavigationTarget | null; + clearPendingTarget: () => void; +} + +/** + * @description 트레이드 화면 진입 시 사용할 종목 이동 상태 store + * @remarks UI 흐름: Dashboard 종목 클릭 -> setPendingTarget -> /trade 이동 -> TradeContainer consumePendingTarget -> 종목 로드 + * @see features/dashboard/components/StockDetailPreview.tsx setPendingTarget 호출 + * @see features/trade/components/TradeContainer.tsx consumePendingTarget 호출 + */ +export const useTradeNavigationStore = create< + TradeNavigationStoreState & TradeNavigationStoreActions +>()((set, get) => ({ + pendingTarget: null, + + setPendingTarget: (target) => + set({ + pendingTarget: { + ...target, + requestedAt: Date.now(), + }, + }), + + consumePendingTarget: () => { + const target = get().pendingTarget; + if (!target) return null; + + set({ pendingTarget: null }); + return target; + }, + + clearPendingTarget: () => set({ pendingTarget: null }), +})); diff --git a/lib/kis/config.ts b/lib/kis/config.ts index 5e7b903..d74e25a 100644 --- a/lib/kis/config.ts +++ b/lib/kis/config.ts @@ -33,14 +33,6 @@ export function normalizeTradingEnv(value?: string): KisTradingEnv { return (value ?? "mock").toLowerCase() === "real" ? "real" : "mock"; } -/** - * 현재 거래 환경을 반환합니다. - * @returns real | mock - */ -export function getKisTradingEnv() { - return normalizeTradingEnv(process.env.KIS_TRADING_ENV); -} - /** * KIS 웹소켓 URL을 반환합니다. * @param tradingEnvInput 거래 모드(real/mock) @@ -50,10 +42,10 @@ export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) { const tradingEnv = normalizeTradingEnv(tradingEnvInput); if (tradingEnv === "real") { - return process.env.KIS_WS_URL_REAL ?? DEFAULT_KIS_REAL_WS_URL; + return DEFAULT_KIS_REAL_WS_URL; } - return process.env.KIS_WS_URL_MOCK ?? DEFAULT_KIS_MOCK_WS_URL; + return DEFAULT_KIS_MOCK_WS_URL; } /** @@ -62,14 +54,7 @@ export function getKisWebSocketUrl(tradingEnvInput?: KisTradingEnv) { * @returns 사용 가능 여부 */ export function hasKisConfig(input?: KisCredentialInput) { - if (input?.appKey && input?.appSecret) return true; - - const env = getKisTradingEnv(); - if (env === "real") { - return Boolean(process.env.KIS_APP_KEY_REAL && process.env.KIS_APP_SECRET_REAL); - } - - return Boolean(process.env.KIS_APP_KEY_MOCK && process.env.KIS_APP_SECRET_MOCK); + return Boolean(input?.appKey?.trim() && input?.appSecret?.trim()); } /** @@ -78,7 +63,7 @@ export function hasKisConfig(input?: KisCredentialInput) { * @returns tradingEnv/appKey/appSecret/baseUrl */ export function getKisConfig(input?: KisCredentialInput): KisConfig { - if (input?.appKey && input?.appSecret) { + if (input?.appKey?.trim() && input?.appSecret?.trim()) { const tradingEnv = normalizeTradingEnv(input.tradingEnv); const baseUrl = input.baseUrl ?? @@ -86,37 +71,13 @@ export function getKisConfig(input?: KisCredentialInput): KisConfig { return { tradingEnv, - appKey: input.appKey, - appSecret: input.appSecret, + appKey: input.appKey.trim(), + appSecret: input.appSecret.trim(), baseUrl, }; } - const tradingEnv = getKisTradingEnv(); - - if (tradingEnv === "real") { - const appKey = process.env.KIS_APP_KEY_REAL; - const appSecret = process.env.KIS_APP_SECRET_REAL; - const baseUrl = process.env.KIS_BASE_URL_REAL ?? DEFAULT_KIS_REAL_BASE_URL; - - if (!appKey || !appSecret) { - throw new Error( - "KIS 실전투자 키가 없습니다. KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL 환경변수를 설정하세요.", - ); - } - - return { tradingEnv, appKey, appSecret, baseUrl }; - } - - const appKey = process.env.KIS_APP_KEY_MOCK; - const appSecret = process.env.KIS_APP_SECRET_MOCK; - const baseUrl = process.env.KIS_BASE_URL_MOCK ?? DEFAULT_KIS_MOCK_BASE_URL; - - if (!appKey || !appSecret) { - throw new Error( - "KIS 모의투자 키가 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 환경변수를 설정하세요.", - ); - } - - return { tradingEnv, appKey, appSecret, baseUrl }; + throw new Error( + "KIS API 키가 없습니다. 설정 화면에서 앱 키와 앱 시크릿을 먼저 입력해 주세요.", + ); } diff --git a/lib/kis/token.ts b/lib/kis/token.ts index 3fd7e24..5e5174e 100644 --- a/lib/kis/token.ts +++ b/lib/kis/token.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { clearKisApprovalKeyCache } from "@/lib/kis/approval"; -import type { KisCredentialInput } from "@/lib/kis/config"; +import type { KisConfig, KisCredentialInput } from "@/lib/kis/config"; import { getKisConfig } from "@/lib/kis/config"; /** @@ -11,8 +11,9 @@ import { getKisConfig } from "@/lib/kis/config"; interface KisTokenResponse { access_token?: string; access_token_token_expired?: string; + token_type?: string; access_token_expired?: string; - expires_in?: number; + expires_in?: number | string; msg1?: string; msg_cd?: string; error?: string; @@ -33,16 +34,39 @@ interface KisRevokeResponse { const tokenCacheMap = new Map(); const tokenIssueInFlightMap = new Map>(); const TOKEN_REFRESH_BUFFER_MS = 60 * 1000; +const TOKEN_DEFAULT_TTL_MS = 23 * 60 * 60 * 1000; +const KIS_TOKEN_ISSUE_PATH = "/oauth2/tokenP"; +const KIS_TOKEN_REVOKE_PATH = "/oauth2/revokeP"; +const KIS_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"; +const KIS_TOKEN_TYPE_BEARER = "bearer"; +/** + * @description 토큰 캐시 식별용 해시를 생성합니다. + * @param value 해시 입력값 + * @returns sha256 hex 문자열 + * @see getTokenCacheKey 앱키 원문을 직접 저장하지 않기 위한 보조 함수 + */ function hashKey(value: string) { return createHash("sha256").update(value).digest("hex"); } +/** + * @description 거래환경+앱키 기준 토큰 캐시 키를 생성합니다. + * @param credentials 사용자 입력 KIS 인증정보 + * @returns 캐시 키 + * @see getKisAccessToken 토큰 캐시 조회/갱신 키로 사용 + */ function getTokenCacheKey(credentials?: KisCredentialInput) { const config = getKisConfig(credentials); return `${config.tradingEnv}:${hashKey(config.appKey)}`; } +/** + * @description 토큰 응답 문자열을 안전하게 JSON 파싱합니다. + * @param rawText 응답 원문 + * @returns 파싱된 토큰 응답 객체 + * @see issueKisToken 토큰 발급 응답 파싱 단계 + */ function tryParseTokenResponse(rawText: string): KisTokenResponse { try { return JSON.parse(rawText) as KisTokenResponse; @@ -53,6 +77,12 @@ function tryParseTokenResponse(rawText: string): KisTokenResponse { } } +/** + * @description 토큰 폐기 응답 문자열을 안전하게 JSON 파싱합니다. + * @param rawText 응답 원문 + * @returns 파싱된 토큰 폐기 응답 객체 + * @see revokeKisAccessToken 토큰 폐기 응답 파싱 단계 + */ function tryParseRevokeResponse(rawText: string): KisRevokeResponse { try { return JSON.parse(rawText) as KisRevokeResponse; @@ -63,19 +93,62 @@ function tryParseRevokeResponse(rawText: string): KisRevokeResponse { } } -function parseTokenExpiryText(value?: string) { - if (!value) return null; +/** + * @description number 또는 숫자 문자열을 안전하게 숫자로 변환합니다. + * @param value 원본 값 + * @returns 숫자값 또는 null + * @see resolveTokenExpiry expires_in 파싱 단계 + */ +function parseNumericSeconds(value?: number | string) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } - const normalized = value.includes("T") ? value : value.replace(" ", "T"); + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return null; +} + +/** + * @description KIS 만료일시 문자열을 epoch(ms)로 변환합니다. + * @param value 만료일시 문자열 (예: 2023-12-22 08:16:59) + * @returns epoch(ms) 또는 null + * @see resolveTokenExpiry access_token_token_expired 파싱 단계 + */ +function parseTokenExpiryText(value?: string) { + if (!value?.trim()) return null; + const trimmed = value.trim(); + + // 명세 샘플("YYYY-MM-DD HH:mm:ss")은 한국시간(KST)으로 해석해 UTC 서버에서도 오차를 줄입니다. + if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}$/.test(trimmed)) { + const parsedKst = Date.parse(`${trimmed.replace(" ", "T")}+09:00`); + if (!Number.isNaN(parsedKst)) { + return parsedKst; + } + } + + const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T"); const parsed = Date.parse(normalized); if (Number.isNaN(parsed)) return null; return parsed; } +/** + * @description 토큰 만료시각을 계산합니다. + * @param payload KIS 토큰 발급 응답 + * @returns 만료시각 epoch(ms) + * @see issueKisToken 토큰 캐시 저장 만료시간 계산 + */ function resolveTokenExpiry(payload: KisTokenResponse) { - if (typeof payload.expires_in === "number" && payload.expires_in > 0) { - return Date.now() + payload.expires_in * 1000; + const expiresInSeconds = parseNumericSeconds(payload.expires_in); + if (expiresInSeconds && expiresInSeconds > 0) { + return Date.now() + expiresInSeconds * 1000; } const absoluteExpiry = @@ -87,7 +160,7 @@ function resolveTokenExpiry(payload: KisTokenResponse) { } // 예외 상황 기본값: 23시간 - return Date.now() + 23 * 60 * 60 * 1000; + return Date.now() + TOKEN_DEFAULT_TTL_MS; } /** @@ -111,24 +184,58 @@ function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") { return " | 점검: API 서비스 상태와 거래 환경(real/mock)을 확인해 주세요."; } +/** + * @description 토큰 발급/폐기 공통 헤더를 생성합니다. + * @returns OAuth 요청 헤더 + * @see issueKisToken 접근토큰발급(P) 호출 + * @see revokeKisAccessToken 접근토큰폐기(P) 호출 + */ +function buildOauthHeaders(): HeadersInit { + return { + "content-type": "application/json; charset=utf-8", + accept: "application/json, text/plain, */*", + }; +} + +/** + * @description 접근토큰발급(P) 요청 바디를 생성합니다. + * @param config KIS 설정 + * @returns tokenP 요청 바디 + * @see 접근토큰발급(P)[인증-001].xlsx grant_type/appkey/appsecret 명세 + */ +function buildTokenIssueBody(config: KisConfig) { + return { + grant_type: KIS_GRANT_TYPE_CLIENT_CREDENTIALS, + appkey: config.appKey, + appsecret: config.appSecret, + }; +} + +/** + * @description 토큰 응답 실패 상세 메시지를 조합합니다. + * @param payload KIS 토큰 응답 + * @returns 상세 메시지 + * @see issueKisToken 토큰 발급 실패 에러 메시지 구성 + */ +function buildTokenIssueDetail(payload: KisTokenResponse) { + return [payload.msg1, payload.error_description, payload.error, payload.msg_cd] + .filter(Boolean) + .join(" / "); +} + /** * @description KIS 액세스 토큰을 발급합니다. * @see app/api/kis/validate/route.ts + * @see C:/Users/이지훈/Downloads/접근토큰발급(P)[인증-001].xlsx 접근토큰발급(P) 명세 */ async function issueKisToken(credentials?: KisCredentialInput): Promise { const config = getKisConfig(credentials); - const tokenUrl = `${config.baseUrl}/oauth2/tokenP`; + const tokenUrl = `${config.baseUrl}${KIS_TOKEN_ISSUE_PATH}`; const response = await fetch(tokenUrl, { method: "POST", - headers: { - "content-type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ - grant_type: "client_credentials", - appkey: config.appKey, - appsecret: config.appSecret, - }), + headers: buildOauthHeaders(), + body: JSON.stringify(buildTokenIssueBody(config)), cache: "no-store", }); @@ -136,9 +243,7 @@ async function issueKisToken(credentials?: KisCredentialInput): Promise