디자인 변경

This commit is contained in:
2026-02-11 15:27:03 +09:00
parent 95291e6922
commit f650d51f68
15 changed files with 667 additions and 505 deletions

View File

@@ -4,7 +4,7 @@ import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config";
/**
* @file lib/kis/approval.ts
* @description KIS 웹소켓 approval key 발급/캐시 관리
* @description KIS 웹소켓 승인키 생명주기를 관리합니다.
*/
interface KisApprovalResponse {
@@ -34,12 +34,12 @@ function getApprovalCacheKey(credentials?: KisCredentialInput) {
}
/**
* KIS 웹소켓 approval key 발급
* @param credentials 사용자 입력 키(선택)
* @returns approval key + expiresAt
* @see app/api/kis/ws/approval/route.ts POST - 실시간 차트 연결 시 호출
* @description 웹소켓 승인키를 발급합니다.
* @see app/api/kis/ws/approval/route.ts
*/
async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise<KisApprovalCache> {
async function issueKisApprovalKey(
credentials?: KisCredentialInput,
): Promise<KisApprovalCache> {
const config = getKisConfig(credentials);
const response = await fetch(`${config.baseUrl}/oauth2/Approval`, {
@@ -50,6 +50,7 @@ async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise<Ki
body: JSON.stringify({
grant_type: "client_credentials",
appkey: config.appKey,
// Official samples use `secretkey` for Approval endpoint.
secretkey: config.appSecret,
}),
cache: "no-store",
@@ -70,18 +71,13 @@ async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise<Ki
);
}
// 공식 샘플은 1일 단위 재발급을 권장하므로 토큰과 동일하게 보수적으로 23시간 캐시합니다.
// KIS samples recommend daily refresh. Cache for 23 hours conservatively.
return {
approvalKey: payload.approval_key,
expiresAt: Date.now() + 23 * 60 * 60 * 1000,
};
}
/**
* approval 응답을 안전하게 JSON으로 파싱합니다.
* @param rawText fetch 응답 원문
* @returns KisApprovalResponse
*/
function tryParseApprovalResponse(rawText: string): KisApprovalResponse {
try {
return JSON.parse(rawText) as KisApprovalResponse;
@@ -93,9 +89,8 @@ function tryParseApprovalResponse(rawText: string): KisApprovalResponse {
}
/**
* 웹소켓 승인키를 반환합니다.
* @param credentials 사용자 입력 키(선택)
* @returns approval key
* @description 승인키를 캐시에서 반환하거나 새로 발급합니다.
* @see features/dashboard/store/use-kis-runtime-store.ts
*/
export async function getKisApprovalKey(credentials?: KisCredentialInput) {
const cacheKey = getApprovalCacheKey(credentials);
@@ -113,6 +108,7 @@ export async function getKisApprovalKey(credentials?: KisCredentialInput) {
const nextPromise = issueKisApprovalKey(credentials);
approvalIssueInFlightMap.set(cacheKey, nextPromise);
const next = await nextPromise.finally(() => {
approvalIssueInFlightMap.delete(cacheKey);
});
@@ -122,9 +118,8 @@ export async function getKisApprovalKey(credentials?: KisCredentialInput) {
}
/**
* 거래 모드에 맞는 KIS 웹소켓 URL을 반환합니다.
* @param credentials 사용자 입력 키(선택)
* @returns websocket url
* @description 거래 환경에 맞는 웹소켓 URL을 반환합니다.
* @see app/api/kis/ws/approval/route.ts
*/
export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) {
const config = getKisConfig(credentials);
@@ -132,8 +127,8 @@ export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) {
}
/**
* 승인키 캐시를 제거합니다.
* @param credentials 사용자 입력 키(선택)
* @description 승인키 캐시를 제거합니다.
* @see lib/kis/token.ts
*/
export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) {
const cacheKey = getApprovalCacheKey(credentials);

42
lib/kis/request.ts Normal file
View File

@@ -0,0 +1,42 @@
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
import type { NextRequest } from "next/server";
interface KisCredentialRequestBody {
appKey?: string;
appSecret?: string;
tradingEnv?: string;
}
/**
* @description 요청 본문에서 KIS 인증 정보를 파싱합니다.
* @see app/api/kis/validate/route.ts
*/
export async function parseKisCredentialRequest(
request: NextRequest,
): Promise<KisCredentialInput> {
let body: KisCredentialRequestBody = {};
try {
body = (await request.json()) as KisCredentialRequestBody;
} catch {
// 빈 본문 또는 JSON 파싱 실패는 아래 필수값 검증에서 처리합니다.
}
return {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
}
/**
* @description 인증키 필수값을 검증합니다.
* @see app/api/kis/revoke/route.ts
*/
export function validateKisCredentialInput(credentials: KisCredentialInput) {
if (!credentials.appKey || !credentials.appSecret) {
return "앱 키와 앱 시크릿을 모두 입력해 주세요.";
}
return null;
}

View File

@@ -1,18 +1,19 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { KisCredentialInput } from "@/lib/kis/config";
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
import type { KisCredentialInput } from "@/lib/kis/config";
import { getKisConfig } from "@/lib/kis/config";
/**
* @file lib/kis/token.ts
* @description KIS access token 발급/캐시 관리(실전/모의 공통)
* @description KIS 액세스 토큰 발급/폐기/캐시 관리합니다.
*/
interface KisTokenResponse {
access_token?: string;
access_token_token_expired?: string;
access_token_expired?: string;
expires_in?: number;
msg1?: string;
msg_cd?: string;
@@ -25,6 +26,10 @@ interface KisTokenCache {
expiresAt: number;
}
interface PersistedTokenCache {
[cacheKey: string]: KisTokenCache;
}
interface KisRevokeResponse {
code?: number | string;
message?: string;
@@ -45,10 +50,6 @@ function getTokenCacheKey(credentials?: KisCredentialInput) {
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
}
interface PersistedTokenCache {
[cacheKey: string]: KisTokenCache;
}
async function readPersistedTokenCache() {
try {
const raw = await readFile(TOKEN_CACHE_FILE_PATH, "utf8");
@@ -66,6 +67,7 @@ async function writePersistedTokenCache(next: PersistedTokenCache) {
async function getPersistedToken(cacheKey: string) {
const cache = await readPersistedTokenCache();
const token = cache[cacheKey];
if (!token) return null;
if (token.expiresAt - TOKEN_REFRESH_BUFFER_MS <= Date.now()) {
@@ -93,7 +95,7 @@ async function clearPersistedToken(cacheKey: string) {
try {
await unlink(TOKEN_CACHE_FILE_PATH);
} catch {
// ignore
// ignore when file does not exist
}
return;
}
@@ -101,11 +103,77 @@ async function clearPersistedToken(cacheKey: string) {
await writePersistedTokenCache(cache);
}
function tryParseTokenResponse(rawText: string): KisTokenResponse {
try {
return JSON.parse(rawText) as KisTokenResponse;
} catch {
return {
msg1: rawText.slice(0, 200),
};
}
}
function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
try {
return JSON.parse(rawText) as KisRevokeResponse;
} catch {
return {
message: rawText.slice(0, 200),
};
}
}
function parseTokenExpiryText(value?: string) {
if (!value) return null;
const normalized = value.includes("T") ? value : value.replace(" ", "T");
const parsed = Date.parse(normalized);
if (Number.isNaN(parsed)) return null;
return parsed;
}
function resolveTokenExpiry(payload: KisTokenResponse) {
if (typeof payload.expires_in === "number" && payload.expires_in > 0) {
return Date.now() + payload.expires_in * 1000;
}
const absoluteExpiry =
parseTokenExpiryText(payload.access_token_token_expired) ??
parseTokenExpiryText(payload.access_token_expired);
if (absoluteExpiry) {
return absoluteExpiry;
}
// 예외 상황 기본값: 23시간
return Date.now() + 23 * 60 * 60 * 1000;
}
/**
* KIS access token 발급
* @param credentials 사용자 입력 키(선택)
* @returns token + expiresAt
* @see app/api/kis/validate/route.ts POST - 사용자 키 검증 시 토큰 발급 경로
* @description 토큰 발급 실패 원인 점검 문구를 만듭니다.
* @see https://github.com/koreainvestment/open-trading-api
*/
function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") {
const lower = detail.toLowerCase();
const keyError =
lower.includes("appkey") ||
lower.includes("appsecret") ||
lower.includes("secret") ||
lower.includes("invalid") ||
lower.includes("auth");
if (keyError) {
return ` | 점검: ${tradingEnv === "real" ? "실전" : "모의"} 앱 키/시크릿 쌍을 확인해 주세요.`;
}
return " | 점검: API 서비스 상태와 거래 환경(real/mock)을 확인해 주세요.";
}
/**
* @description KIS 액세스 토큰을 발급합니다.
* @see app/api/kis/validate/route.ts
*/
async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> {
const config = getKisConfig(credentials);
@@ -147,68 +215,8 @@ async function issueKisToken(credentials?: KisCredentialInput): Promise<KisToken
}
/**
* 토큰 발급 실패 시 점검 안내를 생성합니다.
* @param detail KIS 응답 메시지
* @param tradingEnv 거래 모드(real/mock)
* @returns 점검 안내 문자열
* @see https://apiportal.koreainvestment.com/apiservice-apiservice
*/
function buildTokenIssueHint(detail: string, tradingEnv: "real" | "mock") {
const lower = detail.toLowerCase();
const keyError =
lower.includes("appkey") ||
lower.includes("appsecret") ||
lower.includes("secret") ||
lower.includes("invalid") ||
lower.includes("인증");
if (keyError) {
return ` | 점검: ${tradingEnv === "real" ? "실전" : "모의"} 앱키/시크릿 쌍이 맞는지 확인하세요.`;
}
return " | 점검: KIS API 포털에서 앱 상태(사용 가능/차단)와 실전·모의 구분을 다시 확인하세요.";
}
/**
* 토큰 응답 문자열을 안전하게 JSON으로 변환합니다.
* @param rawText fetch 응답 원문
* @returns KisTokenResponse
*/
function tryParseTokenResponse(rawText: string): KisTokenResponse {
try {
return JSON.parse(rawText) as KisTokenResponse;
} catch {
// JSON 파싱 실패 시에도 호출부에서 상태코드 기반 에러를 만들 수 있게 기본 객체를 반환합니다.
return {
msg1: rawText.slice(0, 200),
};
}
}
/**
* 토큰 만료시각 계산
* @param payload 토큰 응답
* @returns epoch ms
*/
function resolveTokenExpiry(payload: KisTokenResponse) {
if (payload.access_token_token_expired) {
const parsed = Date.parse(payload.access_token_token_expired.replace(" ", "T"));
if (!Number.isNaN(parsed)) return parsed;
}
if (typeof payload.expires_in === "number" && payload.expires_in > 0) {
return Date.now() + payload.expires_in * 1000;
}
return Date.now() + 23 * 60 * 60 * 1000;
}
/**
* access token 반환(환경/키 단위 메모리 캐시)
* @param credentials 사용자 입력 키(선택)
* @returns access token
* @see lib/kis/domestic.ts getDomesticOverview - 현재가/일봉 병렬 조회 시 공용 토큰 사용
* @description 캐시된 토큰을 반환하거나 새로 발급합니다.
* @see lib/kis/domestic.ts
*/
export async function getKisAccessToken(credentials?: KisCredentialInput) {
const cacheKey = getTokenCacheKey(credentials);
@@ -224,7 +232,6 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) {
return persisted.token;
}
// 같은 키로 동시에 요청이 들어오면 토큰 발급을 1회로 합칩니다.
const inFlight = tokenIssueInFlightMap.get(cacheKey);
if (inFlight) {
const shared = await inFlight;
@@ -233,6 +240,7 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) {
const nextPromise = issueKisToken(credentials);
tokenIssueInFlightMap.set(cacheKey, nextPromise);
const next = await nextPromise.finally(() => {
tokenIssueInFlightMap.delete(cacheKey);
});
@@ -243,10 +251,8 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) {
}
/**
* KIS access token 폐기 요청
* @param credentials 사용자 입력 키(선택)
* @returns 폐기 응답 메시지
* @see app/api/kis/revoke/route.ts POST - 대시보드 접근 폐기 버튼 처리
* @description 현재 KIS 액세스 토큰을 폐기합니다.
* @see app/api/kis/revoke/route.ts
*/
export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
const config = getKisConfig(credentials);
@@ -273,6 +279,7 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
if (!response.ok || !isSuccessCode) {
const detail = [payload.message, payload.msg1].filter(Boolean).join(" / ");
throw new Error(
detail
? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
@@ -285,21 +292,5 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
await clearPersistedToken(cacheKey);
clearKisApprovalKeyCache(credentials);
return payload.message ?? "접근토큰 폐기에 성공하였습니다.";
}
/**
* 토큰 폐기 응답 문자열을 안전하게 JSON으로 변환합니다.
* @param rawText fetch 응답 원문
* @returns KisRevokeResponse
* @see https://apiportal.koreainvestment.com/apiservice-apiservice?/oauth2/revokeP
*/
function tryParseRevokeResponse(rawText: string): KisRevokeResponse {
try {
return JSON.parse(rawText) as KisRevokeResponse;
} catch {
return {
message: rawText.slice(0, 200),
};
}
return payload.message ?? "액세스 토큰 폐기가 완료되었습니다.";
}