대시보드
This commit is contained in:
238
lib/kis/token.ts
Normal file
238
lib/kis/token.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
|
||||
import { getKisConfig } from "@/lib/kis/config";
|
||||
|
||||
/**
|
||||
* @file lib/kis/token.ts
|
||||
* @description KIS access token 발급/캐시 관리(실전/모의 공통)
|
||||
*/
|
||||
|
||||
interface KisTokenResponse {
|
||||
access_token?: string;
|
||||
access_token_token_expired?: string;
|
||||
expires_in?: number;
|
||||
msg1?: string;
|
||||
msg_cd?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
interface KisTokenCache {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface KisRevokeResponse {
|
||||
code?: number | string;
|
||||
message?: string;
|
||||
msg1?: string;
|
||||
}
|
||||
|
||||
const tokenCacheMap = new Map<string, KisTokenCache>();
|
||||
const tokenIssueInFlightMap = new Map<string, Promise<KisTokenCache>>();
|
||||
const TOKEN_REFRESH_BUFFER_MS = 60 * 1000;
|
||||
|
||||
function hashKey(value: string) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function getTokenCacheKey(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS access token 발급
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns token + expiresAt
|
||||
* @see app/api/kis/validate/route.ts POST - 사용자 키 검증 시 토큰 발급 경로
|
||||
*/
|
||||
async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> {
|
||||
const config = getKisConfig(credentials);
|
||||
const tokenUrl = `${config.baseUrl}/oauth2/tokenP`;
|
||||
|
||||
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,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const payload = tryParseTokenResponse(rawText);
|
||||
|
||||
if (!response.ok || !payload.access_token) {
|
||||
const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
const hint = buildTokenIssueHint(detail, config.tradingEnv);
|
||||
|
||||
throw new Error(
|
||||
detail
|
||||
? `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}${hint}`
|
||||
: `KIS 토큰 발급 실패 (${config.tradingEnv}, ${response.status})${hint}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
token: payload.access_token,
|
||||
expiresAt: resolveTokenExpiry(payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 발급 실패 시 점검 안내를 생성합니다.
|
||||
* @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 - 현재가/일봉 병렬 조회 시 공용 토큰 사용
|
||||
*/
|
||||
export async function getKisAccessToken(credentials?: KisCredentialInput) {
|
||||
const cacheKey = getTokenCacheKey(credentials);
|
||||
const cached = tokenCacheMap.get(cacheKey);
|
||||
|
||||
if (cached && cached.expiresAt - TOKEN_REFRESH_BUFFER_MS > Date.now()) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
// 같은 키로 동시에 요청이 들어오면 토큰 발급을 1회로 합칩니다.
|
||||
const inFlight = tokenIssueInFlightMap.get(cacheKey);
|
||||
if (inFlight) {
|
||||
const shared = await inFlight;
|
||||
return shared.token;
|
||||
}
|
||||
|
||||
const nextPromise = issueKisToken(credentials);
|
||||
tokenIssueInFlightMap.set(cacheKey, nextPromise);
|
||||
const next = await nextPromise.finally(() => {
|
||||
tokenIssueInFlightMap.delete(cacheKey);
|
||||
});
|
||||
|
||||
tokenCacheMap.set(cacheKey, next);
|
||||
return next.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* KIS access token 폐기 요청
|
||||
* @param credentials 사용자 입력 키(선택)
|
||||
* @returns 폐기 응답 메시지
|
||||
* @see app/api/kis/revoke/route.ts POST - 대시보드 접근 폐기 버튼 처리
|
||||
*/
|
||||
export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
|
||||
const config = getKisConfig(credentials);
|
||||
const cacheKey = getTokenCacheKey(credentials);
|
||||
const token = await getKisAccessToken(credentials);
|
||||
|
||||
const response = await fetch(`${config.baseUrl}/oauth2/revokeP`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
appkey: config.appKey,
|
||||
appsecret: config.appSecret,
|
||||
token,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
const payload = tryParseRevokeResponse(rawText);
|
||||
const code = payload.code != null ? String(payload.code) : "";
|
||||
const isSuccessCode = code === "" || code === "200";
|
||||
|
||||
if (!response.ok || !isSuccessCode) {
|
||||
const detail = [payload.message, payload.msg1].filter(Boolean).join(" / ");
|
||||
throw new Error(
|
||||
detail
|
||||
? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
|
||||
: `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
tokenCacheMap.delete(cacheKey);
|
||||
tokenIssueInFlightMap.delete(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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user