import { createHash } from "node:crypto"; import { buildKisErrorDetail } from "@/lib/kis/error-codes"; import type { KisCredentialInput } from "@/lib/kis/config"; import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config"; /** * @file lib/kis/approval.ts * @description KIS 웹소켓 승인키 생명주기를 관리합니다. */ 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)}`; } /** * @description 웹소켓 승인키를 발급합니다. * @see app/api/kis/ws/approval/route.ts */ 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, // Official samples use `secretkey` for Approval endpoint. secretkey: config.appSecret, }), cache: "no-store", }); const rawText = await response.text(); const payload = tryParseApprovalResponse(rawText); if (!response.ok || !payload.approval_key) { const detail = buildKisErrorDetail({ message: payload.msg1, msgCode: payload.msg_cd, extraMessages: [payload.error_description, payload.error], }); throw new Error( detail ? `KIS 웹소켓 승인키 발급 실패 (${config.tradingEnv}, ${response.status}): ${detail}` : `KIS 웹소켓 승인키 발급 실패 (${config.tradingEnv}, ${response.status})`, ); } // KIS samples recommend daily refresh. Cache for 23 hours conservatively. return { approvalKey: payload.approval_key, expiresAt: Date.now() + 23 * 60 * 60 * 1000, }; } function tryParseApprovalResponse(rawText: string): KisApprovalResponse { try { return JSON.parse(rawText) as KisApprovalResponse; } catch { return { msg1: rawText.slice(0, 200), }; } } /** * @description 승인키를 캐시에서 반환하거나 새로 발급합니다. * @see features/settings/store/use-kis-runtime-store.ts */ 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; } /** * @description 거래 환경에 맞는 웹소켓 URL을 반환합니다. * @see app/api/kis/ws/approval/route.ts */ export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) { const config = getKisConfig(credentials); return getKisWebSocketUrl(config.tradingEnv); } /** * @description 승인키 캐시를 제거합니다. * @see lib/kis/token.ts */ export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) { const cacheKey = getApprovalCacheKey(credentials); approvalCacheMap.delete(cacheKey); approvalIssueInFlightMap.delete(cacheKey); }