2026-02-06 17:50:35 +09:00
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
|
import type { KisCredentialInput } from "@/lib/kis/config";
|
|
|
|
|
import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @file lib/kis/approval.ts
|
2026-02-11 15:27:03 +09:00
|
|
|
* @description KIS 웹소켓 승인키 생명주기를 관리합니다.
|
2026-02-06 17:50:35 +09:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
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<string, KisApprovalCache>();
|
|
|
|
|
const approvalIssueInFlightMap = new Map<string, Promise<KisApprovalCache>>();
|
|
|
|
|
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)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-11 15:27:03 +09:00
|
|
|
* @description 웹소켓 승인키를 발급합니다.
|
|
|
|
|
* @see app/api/kis/ws/approval/route.ts
|
2026-02-06 17:50:35 +09:00
|
|
|
*/
|
2026-02-11 15:27:03 +09:00
|
|
|
async function issueKisApprovalKey(
|
|
|
|
|
credentials?: KisCredentialInput,
|
|
|
|
|
): Promise<KisApprovalCache> {
|
2026-02-06 17:50:35 +09:00
|
|
|
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,
|
2026-02-11 15:27:03 +09:00
|
|
|
// Official samples use `secretkey` for Approval endpoint.
|
2026-02-06 17:50:35 +09:00
|
|
|
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})`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 15:27:03 +09:00
|
|
|
// KIS samples recommend daily refresh. Cache for 23 hours conservatively.
|
2026-02-06 17:50:35 +09:00
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-11 15:27:03 +09:00
|
|
|
* @description 승인키를 캐시에서 반환하거나 새로 발급합니다.
|
|
|
|
|
* @see features/dashboard/store/use-kis-runtime-store.ts
|
2026-02-06 17:50:35 +09:00
|
|
|
*/
|
|
|
|
|
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);
|
2026-02-11 15:27:03 +09:00
|
|
|
|
2026-02-06 17:50:35 +09:00
|
|
|
const next = await nextPromise.finally(() => {
|
|
|
|
|
approvalIssueInFlightMap.delete(cacheKey);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
approvalCacheMap.set(cacheKey, next);
|
|
|
|
|
return next.approvalKey;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-11 15:27:03 +09:00
|
|
|
* @description 거래 환경에 맞는 웹소켓 URL을 반환합니다.
|
|
|
|
|
* @see app/api/kis/ws/approval/route.ts
|
2026-02-06 17:50:35 +09:00
|
|
|
*/
|
|
|
|
|
export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) {
|
|
|
|
|
const config = getKisConfig(credentials);
|
|
|
|
|
return getKisWebSocketUrl(config.tradingEnv);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-02-11 15:27:03 +09:00
|
|
|
* @description 승인키 캐시를 제거합니다.
|
|
|
|
|
* @see lib/kis/token.ts
|
2026-02-06 17:50:35 +09:00
|
|
|
*/
|
|
|
|
|
export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) {
|
|
|
|
|
const cacheKey = getApprovalCacheKey(credentials);
|
|
|
|
|
approvalCacheMap.delete(cacheKey);
|
|
|
|
|
approvalIssueInFlightMap.delete(cacheKey);
|
|
|
|
|
}
|