import { createHash } from "node:crypto"; import type { KisCredentialInput } from "@/lib/kis/config"; import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config"; /** * @file lib/kis/approval.ts * @description KIS 웹소켓 approval key 발급/캐시 관리 */ interface KisApprovalResponse { approval_key?: string; msg1?: string; msg_cd?: string; error?: string; error_description?: string; } interface KisApprovalCache { approvalKey: string; expiresAt: number; } const approvalCacheMap = new Map(); const approvalIssueInFlightMap = new Map>(); const APPROVAL_REFRESH_BUFFER_MS = 60 * 1000; function hashKey(value: string) { return createHash("sha256").update(value).digest("hex"); } function getApprovalCacheKey(credentials?: KisCredentialInput) { const config = getKisConfig(credentials); return `${config.tradingEnv}:${hashKey(config.appKey)}`; } /** * KIS 웹소켓 approval key 발급 * @param credentials 사용자 입력 키(선택) * @returns approval key + expiresAt * @see app/api/kis/ws/approval/route.ts POST - 실시간 차트 연결 시 호출 */ async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise { const config = getKisConfig(credentials); const response = await fetch(`${config.baseUrl}/oauth2/Approval`, { method: "POST", headers: { "content-type": "application/json; charset=utf-8", }, body: JSON.stringify({ grant_type: "client_credentials", appkey: config.appKey, secretkey: config.appSecret, }), cache: "no-store", }); const rawText = await response.text(); const payload = tryParseApprovalResponse(rawText); if (!response.ok || !payload.approval_key) { const detail = [payload.msg1, payload.error_description, payload.error, payload.msg_cd] .filter(Boolean) .join(" / "); throw new Error( detail ? `KIS 웹소켓 승인키 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}` : `KIS 웹소켓 승인키 발급 실패 (${config.tradingEnv}, ${response.status})`, ); } // 공식 샘플은 1일 단위 재발급을 권장하므로 토큰과 동일하게 보수적으로 23시간 캐시합니다. 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; } catch { return { msg1: rawText.slice(0, 200), }; } } /** * 웹소켓 승인키를 반환합니다. * @param credentials 사용자 입력 키(선택) * @returns approval key */ export async function getKisApprovalKey(credentials?: KisCredentialInput) { const cacheKey = getApprovalCacheKey(credentials); const cached = approvalCacheMap.get(cacheKey); if (cached && cached.expiresAt - APPROVAL_REFRESH_BUFFER_MS > Date.now()) { return cached.approvalKey; } const inFlight = approvalIssueInFlightMap.get(cacheKey); if (inFlight) { const shared = await inFlight; return shared.approvalKey; } const nextPromise = issueKisApprovalKey(credentials); approvalIssueInFlightMap.set(cacheKey, nextPromise); const next = await nextPromise.finally(() => { approvalIssueInFlightMap.delete(cacheKey); }); approvalCacheMap.set(cacheKey, next); return next.approvalKey; } /** * 거래 모드에 맞는 KIS 웹소켓 URL을 반환합니다. * @param credentials 사용자 입력 키(선택) * @returns websocket url */ export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) { const config = getKisConfig(credentials); return getKisWebSocketUrl(config.tradingEnv); } /** * 승인키 캐시를 제거합니다. * @param credentials 사용자 입력 키(선택) */ export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) { const cacheKey = getApprovalCacheKey(credentials); approvalCacheMap.delete(cacheKey); approvalIssueInFlightMap.delete(cacheKey); }