디자인 변경
This commit is contained in:
@@ -1 +1 @@
|
|||||||
{"mock:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000},"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000}}
|
{"mock:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImRhNWMyYjU5LTA3YTUtNGVhNy1hY2UyLWZlNTMwZTBjM2ZjNCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDc5MzIzOCwiaWF0IjoxNzcwNzA2ODM4LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.vDnx1Vx-LnzaRETwkR5aQUnl7vV3-KlXG5AVlMRrchjdovJzd5n1vL7YfG_146Swrao2Drw8TwnpdK44-aTrhg","expiresAt":1770793238000},"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6IjQ1ZTBmYTczLWI3ZmEtNDg5Mi1iYmZkLTJkYzdlNWQ2YTFhOCIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDg3NDg1NywiaWF0IjoxNzcwNzg4NDU3LCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.f4XsiK4WgzzBNbGEP5bNnJ9r4yAfGBb8SOwEZ-D0knygsFqSOGsj1QfjjVIBo7lG5AxAwyrIUdoC-rjqIVCc3A","expiresAt":1770874857000}}
|
||||||
@@ -1,35 +1,32 @@
|
|||||||
import type { DashboardKisRevokeResponse } from "@/features/dashboard/types/dashboard.types";
|
import type { DashboardKisRevokeResponse } from "@/features/dashboard/types/dashboard.types";
|
||||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
import {
|
||||||
|
parseKisCredentialRequest,
|
||||||
|
validateKisCredentialInput,
|
||||||
|
} from "@/lib/kis/request";
|
||||||
import { revokeKisAccessToken } from "@/lib/kis/token";
|
import { revokeKisAccessToken } from "@/lib/kis/token";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file app/api/kis/revoke/route.ts
|
* @file app/api/kis/revoke/route.ts
|
||||||
* @description 사용자 입력 KIS API 키 기반 접근토큰 폐기 라우트
|
* @description 사용자 입력 KIS API 키로 액세스 토큰을 폐기합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KIS API 접근토큰 폐기
|
* @description KIS 액세스 토큰 폐기
|
||||||
* @param request appKey/appSecret/tradingEnv JSON 본문
|
* @see features/dashboard/components/auth/KisAuthForm.tsx
|
||||||
* @returns 폐기 성공/실패 정보
|
|
||||||
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis - 접근 폐기 버튼 클릭 이벤트
|
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = (await request.json()) as Partial<DashboardKisRevokeRequest>;
|
const credentials = await parseKisCredentialRequest(request);
|
||||||
|
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||||
|
|
||||||
const credentials: KisCredentialInput = {
|
const invalidMessage = validateKisCredentialInput(credentials);
|
||||||
appKey: body.appKey?.trim(),
|
if (invalidMessage) {
|
||||||
appSecret: body.appSecret?.trim(),
|
|
||||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!hasKisConfig(credentials)) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
tradingEnv,
|
||||||
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
|
message: invalidMessage,
|
||||||
} satisfies DashboardKisRevokeResponse,
|
} satisfies DashboardKisRevokeResponse,
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
@@ -40,25 +37,22 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
tradingEnv,
|
||||||
message,
|
message,
|
||||||
} satisfies DashboardKisRevokeResponse);
|
} satisfies DashboardKisRevokeResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다.";
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "API 토큰 폐기 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
tradingEnv,
|
||||||
message,
|
message,
|
||||||
} satisfies DashboardKisRevokeResponse,
|
} satisfies DashboardKisRevokeResponse,
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardKisRevokeRequest {
|
|
||||||
appKey: string;
|
|
||||||
appSecret: string;
|
|
||||||
tradingEnv: "real" | "mock";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,65 +1,58 @@
|
|||||||
import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types";
|
import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types";
|
||||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
import {
|
||||||
|
parseKisCredentialRequest,
|
||||||
|
validateKisCredentialInput,
|
||||||
|
} from "@/lib/kis/request";
|
||||||
import { getKisAccessToken } from "@/lib/kis/token";
|
import { getKisAccessToken } from "@/lib/kis/token";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file app/api/kis/validate/route.ts
|
* @file app/api/kis/validate/route.ts
|
||||||
* @description 사용자 입력 KIS API 키 검증 라우트
|
* @description 사용자 입력 KIS API 키를 검증합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KIS API 키 검증
|
* @description 액세스 토큰 발급 성공 여부로 API 키를 검증합니다.
|
||||||
* @param request appKey/appSecret/tradingEnv JSON 본문
|
* @see features/dashboard/components/auth/KisAuthForm.tsx
|
||||||
* @returns 검증 성공/실패 정보
|
|
||||||
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis - 검증 버튼 클릭 시 호출
|
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = (await request.json()) as Partial<DashboardKisValidateRequest>;
|
const credentials = await parseKisCredentialRequest(request);
|
||||||
|
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||||
|
|
||||||
const credentials: KisCredentialInput = {
|
const invalidMessage = validateKisCredentialInput(credentials);
|
||||||
appKey: body.appKey?.trim(),
|
if (invalidMessage) {
|
||||||
appSecret: body.appSecret?.trim(),
|
|
||||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!hasKisConfig(credentials)) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
tradingEnv,
|
||||||
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
|
message: invalidMessage,
|
||||||
} satisfies DashboardKisValidateResponse,
|
} satisfies DashboardKisValidateResponse,
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 검증 단계는 토큰 발급 성공 여부만 확인합니다.
|
|
||||||
await getKisAccessToken(credentials);
|
await getKisAccessToken(credentials);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
tradingEnv,
|
||||||
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
|
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
|
||||||
} satisfies DashboardKisValidateResponse);
|
} satisfies DashboardKisValidateResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다.";
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "API 키 검증 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
tradingEnv,
|
||||||
message,
|
message,
|
||||||
} satisfies DashboardKisValidateResponse,
|
} satisfies DashboardKisValidateResponse,
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardKisValidateRequest {
|
|
||||||
appKey: string;
|
|
||||||
appSecret: string;
|
|
||||||
tradingEnv: "real" | "mock";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,35 +1,32 @@
|
|||||||
import type { DashboardKisWsApprovalResponse } from "@/features/dashboard/types/dashboard.types";
|
import type { DashboardKisWsApprovalResponse } from "@/features/dashboard/types/dashboard.types";
|
||||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
|
||||||
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
|
||||||
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
|
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
|
||||||
|
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
|
import {
|
||||||
|
parseKisCredentialRequest,
|
||||||
|
validateKisCredentialInput,
|
||||||
|
} from "@/lib/kis/request";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file app/api/kis/ws/approval/route.ts
|
* @file app/api/kis/ws/approval/route.ts
|
||||||
* @description KIS 웹소켓 approval key 발급 라우트
|
* @description KIS 웹소켓 승인키와 WS URL을 발급합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 실시간 웹소켓 승인키 발급
|
* @description 실시간 웹소켓 연결 정보를 발급합니다.
|
||||||
* @param request appKey/appSecret/tradingEnv JSON 본문
|
* @see features/dashboard/hooks/useKisTradeWebSocket.ts
|
||||||
* @returns approval key + ws url
|
|
||||||
* @see features/dashboard/components/dashboard-main.tsx connectKisRealtimePrice - 실시간 체결가 구독 진입점
|
|
||||||
*/
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = (await request.json()) as Partial<DashboardKisWsApprovalRequest>;
|
const credentials = await parseKisCredentialRequest(request);
|
||||||
|
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||||
|
|
||||||
const credentials: KisCredentialInput = {
|
const invalidMessage = validateKisCredentialInput(credentials);
|
||||||
appKey: body.appKey?.trim(),
|
if (invalidMessage) {
|
||||||
appSecret: body.appSecret?.trim(),
|
|
||||||
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!hasKisConfig(credentials)) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
tradingEnv,
|
||||||
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
|
message: invalidMessage,
|
||||||
} satisfies DashboardKisWsApprovalResponse,
|
} satisfies DashboardKisWsApprovalResponse,
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
@@ -41,27 +38,24 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
tradingEnv,
|
||||||
approvalKey,
|
approvalKey,
|
||||||
wsUrl,
|
wsUrl,
|
||||||
message: "KIS 실시간 웹소켓 승인키 발급이 완료되었습니다.",
|
message: "웹소켓 승인키 발급이 완료되었습니다.",
|
||||||
} satisfies DashboardKisWsApprovalResponse);
|
} satisfies DashboardKisWsApprovalResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "웹소켓 승인키 발급 중 오류가 발생했습니다.";
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "웹소켓 승인키 발급 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
tradingEnv,
|
||||||
message,
|
message,
|
||||||
} satisfies DashboardKisWsApprovalResponse,
|
} satisfies DashboardKisWsApprovalResponse,
|
||||||
{ status: 401 },
|
{ status: 401 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardKisWsApprovalRequest {
|
|
||||||
appKey: string;
|
|
||||||
appSecret: string;
|
|
||||||
tradingEnv: "real" | "mock";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,77 +5,77 @@ import type {
|
|||||||
DashboardKisWsApprovalResponse,
|
DashboardKisWsApprovalResponse,
|
||||||
} from "@/features/dashboard/types/dashboard.types";
|
} from "@/features/dashboard/types/dashboard.types";
|
||||||
|
|
||||||
|
interface KisApiBaseResponse {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postKisAuthApi<T extends KisApiBaseResponse>(
|
||||||
|
endpoint: string,
|
||||||
|
credentials: KisRuntimeCredentials,
|
||||||
|
fallbackErrorMessage: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(credentials),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as T;
|
||||||
|
|
||||||
|
if (!response.ok || !payload.ok) {
|
||||||
|
throw new Error(payload.message || fallbackErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KIS API 키 검증 요청
|
* @description KIS API 키를 검증합니다.
|
||||||
* @param credentials 검증할 키 정보
|
* @see app/api/kis/validate/route.ts
|
||||||
*/
|
*/
|
||||||
export async function validateKisCredentials(
|
export async function validateKisCredentials(
|
||||||
credentials: KisRuntimeCredentials,
|
credentials: KisRuntimeCredentials,
|
||||||
): Promise<DashboardKisValidateResponse> {
|
): Promise<DashboardKisValidateResponse> {
|
||||||
const response = await fetch("/api/kis/validate", {
|
return postKisAuthApi<DashboardKisValidateResponse>(
|
||||||
method: "POST",
|
"/api/kis/validate",
|
||||||
headers: {
|
credentials,
|
||||||
"content-type": "application/json",
|
"API 키 검증에 실패했습니다.",
|
||||||
},
|
);
|
||||||
body: JSON.stringify(credentials),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = (await response.json()) as DashboardKisValidateResponse;
|
|
||||||
|
|
||||||
if (!response.ok || !payload.ok) {
|
|
||||||
throw new Error(payload.message || "API 키 검증에 실패했습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KIS 접근토큰 폐기 요청
|
* @description KIS 액세스 토큰을 폐기합니다.
|
||||||
* @param credentials 폐기할 키 정보
|
* @see app/api/kis/revoke/route.ts
|
||||||
*/
|
*/
|
||||||
export async function revokeKisCredentials(
|
export async function revokeKisCredentials(
|
||||||
credentials: KisRuntimeCredentials,
|
credentials: KisRuntimeCredentials,
|
||||||
): Promise<DashboardKisRevokeResponse> {
|
): Promise<DashboardKisRevokeResponse> {
|
||||||
const response = await fetch("/api/kis/revoke", {
|
return postKisAuthApi<DashboardKisRevokeResponse>(
|
||||||
method: "POST",
|
"/api/kis/revoke",
|
||||||
headers: {
|
credentials,
|
||||||
"content-type": "application/json",
|
"API 토큰 폐기에 실패했습니다.",
|
||||||
},
|
);
|
||||||
body: JSON.stringify(credentials),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = (await response.json()) as DashboardKisRevokeResponse;
|
|
||||||
|
|
||||||
if (!response.ok || !payload.ok) {
|
|
||||||
throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KIS 실시간 웹소켓 승인키 발급 요청
|
* @description 웹소켓 승인키와 WS URL을 조회합니다.
|
||||||
* @param credentials 인증 정보
|
* @see app/api/kis/ws/approval/route.ts
|
||||||
*/
|
*/
|
||||||
export async function fetchKisWebSocketApproval(
|
export async function fetchKisWebSocketApproval(
|
||||||
credentials: KisRuntimeCredentials,
|
credentials: KisRuntimeCredentials,
|
||||||
): Promise<DashboardKisWsApprovalResponse> {
|
): Promise<DashboardKisWsApprovalResponse> {
|
||||||
const response = await fetch("/api/kis/ws/approval", {
|
const payload = await postKisAuthApi<DashboardKisWsApprovalResponse>(
|
||||||
method: "POST",
|
"/api/kis/ws/approval",
|
||||||
headers: {
|
credentials,
|
||||||
"content-type": "application/json",
|
"웹소켓 승인키 발급에 실패했습니다.",
|
||||||
},
|
|
||||||
body: JSON.stringify(credentials),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = (await response.json()) as DashboardKisWsApprovalResponse;
|
|
||||||
if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) {
|
|
||||||
throw new Error(
|
|
||||||
payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다.",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!payload.approvalKey || !payload.wsUrl) {
|
||||||
|
throw new Error(payload.message || "웹소켓 연결 정보가 누락되었습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
|
|||||||
@@ -2,23 +2,29 @@ import { useState, useTransition } from "react";
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
|
||||||
import {
|
import {
|
||||||
revokeKisCredentials,
|
revokeKisCredentials,
|
||||||
validateKisCredentials,
|
validateKisCredentials,
|
||||||
} from "@/features/dashboard/apis/kis-auth.api";
|
} from "@/features/dashboard/apis/kis-auth.api";
|
||||||
|
import {
|
||||||
|
KeyRound,
|
||||||
|
Shield,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Lock,
|
||||||
|
Sparkles,
|
||||||
|
Zap,
|
||||||
|
Activity,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { InlineSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description KIS 인증 입력 폼
|
* @description KIS 인증 입력 폼 (Minimal Redesign v4)
|
||||||
* @see features/dashboard/store/use-kis-runtime-store.ts 인증 입력값/검증 상태를 저장합니다.
|
* - User Feedback: "입력창이 너무 길어", "파란색/하늘색 제거해"
|
||||||
|
* - Compact Width: max-w-lg + mx-auto
|
||||||
|
* - Monochrome Mock Mode: Blue -> Zinc/Gray
|
||||||
*/
|
*/
|
||||||
export function KisAuthForm() {
|
export function KisAuthForm() {
|
||||||
const {
|
const {
|
||||||
@@ -54,6 +60,11 @@ export function KisAuthForm() {
|
|||||||
const [isValidating, startValidateTransition] = useTransition();
|
const [isValidating, startValidateTransition] = useTransition();
|
||||||
const [isRevoking, startRevokeTransition] = useTransition();
|
const [isRevoking, startRevokeTransition] = useTransition();
|
||||||
|
|
||||||
|
// 입력 필드 Focus 상태 관리를 위한 State
|
||||||
|
const [focusedField, setFocusedField] = useState<
|
||||||
|
"appKey" | "appSecret" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
function handleValidate() {
|
function handleValidate() {
|
||||||
startValidateTransition(async () => {
|
startValidateTransition(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -67,7 +78,6 @@ export function KisAuthForm() {
|
|||||||
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
|
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 주문 기능에서 계좌번호가 필요할 수 있어 구조는 유지하되, 인증 단계에서는 입력받지 않습니다.
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
appKey,
|
appKey,
|
||||||
appSecret,
|
appSecret,
|
||||||
@@ -114,123 +124,191 @@ export function KisAuthForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-brand-200 bg-linear-to-r from-brand-50/60 to-background dark:border-brand-700/50 dark:from-brand-900/35 dark:to-background">
|
<div className="group relative mx-auto w-full max-w-lg overflow-hidden rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm transition-all hover:border-brand-200 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:hover:border-brand-800 dark:hover:shadow-brand-900/10">
|
||||||
<CardHeader>
|
{/* Inner Content Container - Compact spacing */}
|
||||||
<CardTitle className="text-foreground dark:text-brand-50">KIS API 키 연결</CardTitle>
|
<div className="flex flex-col gap-4">
|
||||||
<CardDescription className="text-muted-foreground dark:text-brand-100/80">
|
{/* Header Section */}
|
||||||
대시보드 사용 전, 개인 API 키를 입력하고 검증해 주세요.
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
검증에 성공해야 시세 조회가 동작합니다.
|
<div className="flex items-center gap-3">
|
||||||
</CardDescription>
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600 ring-1 ring-brand-100 dark:bg-brand-900/20 dark:text-brand-400 dark:ring-brand-800/50">
|
||||||
</CardHeader>
|
<KeyRound className="h-4.5 w-4.5" />
|
||||||
|
</div>
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* ========== CREDENTIAL INPUTS ========== */}
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-muted-foreground dark:text-brand-100/72">
|
<h2 className="flex items-center gap-2 text-base font-bold tracking-tight text-zinc-800 dark:text-zinc-100">
|
||||||
거래 모드
|
KIS API Key Connection
|
||||||
</label>
|
{isKisVerified && (
|
||||||
<div className="flex gap-2">
|
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-1.5 py-0.5 text-[10px] font-medium text-green-600 ring-1 ring-green-600/10 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
|
||||||
<Button
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
type="button"
|
Connected
|
||||||
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
|
</span>
|
||||||
className={cn(
|
|
||||||
"flex-1 border transition-all",
|
|
||||||
"dark:border-brand-700/70 dark:bg-black/20 dark:text-brand-100/80 dark:hover:bg-brand-900/45",
|
|
||||||
kisTradingEnvInput === "real"
|
|
||||||
? "bg-brand-600 text-white shadow-sm ring-2 ring-brand-300/45 hover:bg-brand-500 dark:bg-brand-500 dark:text-white dark:ring-brand-300/55"
|
|
||||||
: "text-brand-700 hover:text-brand-800 dark:text-brand-100/80",
|
|
||||||
)}
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">
|
||||||
|
한국투자증권에서 발급받은 API 키를 입력해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trading Mode Switch (Segmented Control - Compact) */}
|
||||||
|
<div className="flex shrink-0 items-center rounded-md bg-zinc-100 p-0.5 ring-1 ring-zinc-200 dark:bg-zinc-800 dark:ring-zinc-700">
|
||||||
|
<button
|
||||||
onClick={() => setKisTradingEnvInput("real")}
|
onClick={() => setKisTradingEnvInput("real")}
|
||||||
>
|
|
||||||
실전
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 border transition-all",
|
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
|
||||||
"dark:border-brand-700/70 dark:bg-black/20 dark:text-brand-100/80 dark:hover:bg-brand-900/45",
|
kisTradingEnvInput === "real"
|
||||||
kisTradingEnvInput === "mock"
|
? "bg-white text-brand-600 shadow-sm ring-1 ring-black/5 dark:bg-brand-500 dark:text-white dark:ring-brand-400"
|
||||||
? "bg-brand-600 text-white shadow-sm ring-2 ring-brand-300/45 hover:bg-brand-500 dark:bg-brand-500 dark:text-white dark:ring-brand-300/55"
|
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
|
||||||
: "text-brand-700 hover:text-brand-800 dark:text-brand-100/80",
|
|
||||||
)}
|
)}
|
||||||
onClick={() => setKisTradingEnvInput("mock")}
|
|
||||||
>
|
>
|
||||||
모의
|
<Zap
|
||||||
</Button>
|
className={cn(
|
||||||
|
"h-3 w-3",
|
||||||
|
kisTradingEnvInput === "real"
|
||||||
|
? "text-brand-500 dark:text-white"
|
||||||
|
: "text-zinc-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
실전 투자
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setKisTradingEnvInput("mock")}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-[11px] font-semibold transition-all duration-200",
|
||||||
|
kisTradingEnvInput === "mock"
|
||||||
|
? "bg-white text-zinc-800 shadow-sm ring-1 ring-black/5 dark:bg-zinc-600 dark:text-white dark:ring-zinc-500"
|
||||||
|
: "text-zinc-500 hover:text-zinc-700 hover:bg-black/5 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-white/5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Activity
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3",
|
||||||
|
kisTradingEnvInput === "mock"
|
||||||
|
? "text-zinc-800 dark:text-zinc-200"
|
||||||
|
: "text-zinc-400",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
모의 투자
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Input Fields Section (Compact Stacked Layout) */}
|
||||||
<label className="mb-1 block text-xs text-muted-foreground">
|
<div className="flex flex-col gap-2">
|
||||||
KIS App Key
|
{/* App Key Input */}
|
||||||
</label>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
|
||||||
|
focusedField === "appKey"
|
||||||
|
? "border-brand-500 ring-1 ring-brand-500"
|
||||||
|
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
|
||||||
|
<Shield className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
|
||||||
|
App Key
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
className="dark:border-brand-700/60 dark:bg-black/20 dark:text-brand-50 dark:placeholder:text-brand-200/55"
|
|
||||||
value={kisAppKeyInput}
|
value={kisAppKeyInput}
|
||||||
onChange={(e) => setKisAppKeyInput(e.target.value)}
|
onChange={(e) => setKisAppKeyInput(e.target.value)}
|
||||||
|
onFocus={() => setFocusedField("appKey")}
|
||||||
|
onBlur={() => setFocusedField(null)}
|
||||||
placeholder="App Key 입력"
|
placeholder="App Key 입력"
|
||||||
|
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* App Secret Input */}
|
||||||
<label className="mb-1 block text-xs text-muted-foreground">
|
<div
|
||||||
KIS App Secret
|
className={cn(
|
||||||
</label>
|
"group/input relative flex items-center overflow-hidden rounded-lg border bg-white transition-all duration-200 dark:bg-zinc-900/30",
|
||||||
|
focusedField === "appSecret"
|
||||||
|
? "border-brand-500 ring-1 ring-brand-500"
|
||||||
|
: "border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="hidden h-9 w-9 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 transition-colors group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-500 dark:group-focus-within/input:text-brand-400 sm:flex">
|
||||||
|
<Lock className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex h-9 min-w-[70px] items-center justify-center bg-zinc-50 px-2 text-[11px] font-semibold text-zinc-500 border-r border-zinc-100 sm:hidden dark:bg-zinc-800/50 dark:text-zinc-500 dark:border-zinc-800">
|
||||||
|
Secret
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
className="dark:border-brand-700/60 dark:bg-black/20 dark:text-brand-50 dark:placeholder:text-brand-200/55"
|
|
||||||
value={kisAppSecretInput}
|
value={kisAppSecretInput}
|
||||||
onChange={(e) => setKisAppSecretInput(e.target.value)}
|
onChange={(e) => setKisAppSecretInput(e.target.value)}
|
||||||
|
onFocus={() => setFocusedField("appSecret")}
|
||||||
|
onBlur={() => setFocusedField(null)}
|
||||||
placeholder="App Secret 입력"
|
placeholder="App Secret 입력"
|
||||||
|
className="h-9 flex-1 border-none bg-transparent px-3 text-xs text-zinc-900 placeholder:text-zinc-400 focus-visible:ring-0 dark:text-zinc-100 dark:placeholder:text-zinc-600"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ========== ACTIONS ========== */}
|
{/* Action & Status Section */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800/50">
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
onClick={handleValidate}
|
onClick={handleValidate}
|
||||||
disabled={
|
disabled={
|
||||||
isValidating || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()
|
isValidating ||
|
||||||
|
!kisAppKeyInput.trim() ||
|
||||||
|
!kisAppSecretInput.trim()
|
||||||
}
|
}
|
||||||
className="bg-brand-600 hover:bg-brand-700"
|
className="h-9 rounded-lg bg-brand-600 px-4 text-xs font-semibold text-white shadow-sm transition-all hover:bg-brand-700 hover:shadow disabled:opacity-50 disabled:shadow-none dark:bg-brand-600 dark:hover:bg-brand-500"
|
||||||
>
|
>
|
||||||
{isValidating ? "검증 중..." : "API 키 검증"}
|
{isValidating ? (
|
||||||
</Button>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<InlineSpinner className="text-white h-3 w-3" />
|
||||||
<Button
|
검증 중
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleRevoke}
|
|
||||||
disabled={isRevoking || !isKisVerified || !verifiedCredentials}
|
|
||||||
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800 dark:border-brand-700/60 dark:text-brand-200 dark:hover:bg-brand-900/35 dark:hover:text-brand-100"
|
|
||||||
>
|
|
||||||
{isRevoking ? "해제 중..." : "연결 끊기"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{isKisVerified ? (
|
|
||||||
<span className="flex items-center text-sm font-medium text-brand-700 dark:text-brand-200">
|
|
||||||
<span className="mr-1.5 h-2 w-2 rounded-full bg-brand-500 ring-2 ring-brand-100 dark:ring-brand-900" />
|
|
||||||
연결됨 ({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-muted-foreground">미연결</span>
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Sparkles className="h-3 w-3 fill-brand-200 text-brand-200" />
|
||||||
|
API 키 연결
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isKisVerified && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRevoke}
|
||||||
|
disabled={isRevoking}
|
||||||
|
className="h-9 rounded-lg border-zinc-200 bg-white text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
{isRevoking ? "해제 중" : "연결 해제"}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status Messages - Compact */}
|
||||||
|
<div className="flex-1 text-right">
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="text-sm font-medium text-destructive">{errorMessage}</div>
|
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] font-semibold text-red-500">
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{statusMessage && (
|
{statusMessage && (
|
||||||
<div className="text-sm text-brand-700 dark:text-brand-200">{statusMessage}</div>
|
<p className="animate-in fade-in slide-in-from-right-4 flex justify-end gap-1.5 text-[11px] font-semibold text-brand-600 dark:text-brand-400">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
{statusMessage}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
{!errorMessage && !statusMessage && !isKisVerified && (
|
||||||
</Card>
|
<p className="flex justify-end gap-1.5 text-[11px] text-zinc-400 dark:text-zinc-600">
|
||||||
|
<span className="h-1.5 w-1.5 translate-y-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||||
|
미연결
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface AnimatedQuantityProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
/** 값 변동 시 배경 깜빡임 */
|
/** 값 변동 시 배경 깜빡임 */
|
||||||
useColor?: boolean;
|
useColor?: boolean;
|
||||||
|
/** 정렬 방향 (ask: 우측 정렬/왼쪽으로 확장, bid: 좌측 정렬/오른쪽으로 확장) */
|
||||||
|
side?: "ask" | "bid";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +20,7 @@ export function AnimatedQuantity({
|
|||||||
format = (v) => v.toLocaleString(),
|
format = (v) => v.toLocaleString(),
|
||||||
className,
|
className,
|
||||||
useColor = false,
|
useColor = false,
|
||||||
|
side = "bid",
|
||||||
}: AnimatedQuantityProps) {
|
}: AnimatedQuantityProps) {
|
||||||
const prevRef = useRef(value);
|
const prevRef = useRef(value);
|
||||||
const [diff, setDiff] = useState<number | null>(null);
|
const [diff, setDiff] = useState<number | null>(null);
|
||||||
@@ -59,16 +62,26 @@ export function AnimatedQuantity({
|
|||||||
transition={{ duration: 1 }}
|
transition={{ duration: 1 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 z-0 rounded-sm",
|
"absolute inset-0 z-0 rounded-sm",
|
||||||
flash === "up" ? "bg-red-200/50" : "bg-brand-200/50",
|
flash === "up" ? "bg-red-200/50" : "bg-blue-200/50",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 매도(Ask)일 경우 Diff가 먼저 와야 텍스트가 우측 정렬된 상태에서 흔들리지 않음 */}
|
||||||
|
{side === "ask" && <DiffChange diff={diff} />}
|
||||||
|
|
||||||
{/* 수량 값 */}
|
{/* 수량 값 */}
|
||||||
<span className="relative z-10">{format(value)}</span>
|
<span className="relative z-10">{format(value)}</span>
|
||||||
|
|
||||||
{/* ±diff (인라인 표시) */}
|
{/* 매수(Bid)일 경우 Diff가 뒤에 와야 텍스트가 좌측 정렬된 상태에서 흔들리지 않음 */}
|
||||||
|
{side !== "ask" && <DiffChange diff={diff} />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffChange({ diff }: { diff: number | null }) {
|
||||||
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{diff != null && diff !== 0 && (
|
{diff != null && diff !== 0 && (
|
||||||
<motion.span
|
<motion.span
|
||||||
@@ -77,14 +90,13 @@ export function AnimatedQuantity({
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 1.2, ease: "easeOut" }}
|
transition={{ duration: 1.2, ease: "easeOut" }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none",
|
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none tabular-nums",
|
||||||
diff > 0 ? "text-red-500" : "text-brand-600",
|
diff > 0 ? "text-red-500" : "text-blue-600 dark:text-blue-400",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
|
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ function BookSideRows({
|
|||||||
value={row.size}
|
value={row.size}
|
||||||
format={fmt}
|
format={fmt}
|
||||||
useColor
|
useColor
|
||||||
|
side="ask"
|
||||||
className="relative z-10"
|
className="relative z-10"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -323,7 +324,11 @@ function BookSideRows({
|
|||||||
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
|
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-800/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"}>
|
<span
|
||||||
|
className={
|
||||||
|
isAsk ? "text-red-600" : "text-blue-600 dark:text-blue-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
{row.price > 0 ? fmt(row.price) : "-"}
|
{row.price > 0 ? fmt(row.price) : "-"}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -349,6 +354,7 @@ function BookSideRows({
|
|||||||
value={row.size}
|
value={row.size}
|
||||||
format={fmt}
|
format={fmt}
|
||||||
useColor
|
useColor
|
||||||
|
side="bid"
|
||||||
className="relative z-10"
|
className="relative z-10"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -451,7 +457,9 @@ function Row({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
|
<div className="mb-1.5 flex items-center justify-between gap-2 rounded border bg-background px-2 py-1 dark:border-brand-800/45 dark:bg-black/20">
|
||||||
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">{label}</span>
|
<span className="min-w-0 truncate text-muted-foreground dark:text-brand-100/70">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 font-medium tabular-nums",
|
"shrink-0 font-medium tabular-nums",
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ function resolveOrderBookTrId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description KIS 실시간 체결/호가를 단일 WebSocket으로 구독합니다.
|
* @description Subscribes trade ticks and orderbook over one websocket.
|
||||||
* @see features/dashboard/components/DashboardContainer.tsx useKisTradeWebSocket 호출
|
* @see features/dashboard/components/DashboardContainer.tsx
|
||||||
* @see lib/kis/domestic-market-session.ts 장 세션 계산 및 테스트용 override
|
* @see lib/kis/domestic-market-session.ts
|
||||||
*/
|
*/
|
||||||
export function useKisTradeWebSocket(
|
export function useKisTradeWebSocket(
|
||||||
symbol: string | undefined,
|
symbol: string | undefined,
|
||||||
@@ -83,7 +83,6 @@ export function useKisTradeWebSocket(
|
|||||||
? resolveTradeTrId(credentials.tradingEnv, marketSession)
|
? resolveTradeTrId(credentials.tradingEnv, marketSession)
|
||||||
: TRADE_TR_ID;
|
: TRADE_TR_ID;
|
||||||
|
|
||||||
// KST 장 세션을 주기적으로 재평가합니다.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timerId = window.setInterval(() => {
|
const timerId = window.setInterval(() => {
|
||||||
const nextSession = resolveSessionInClient();
|
const nextSession = resolveSessionInClient();
|
||||||
@@ -93,13 +92,12 @@ export function useKisTradeWebSocket(
|
|||||||
return () => window.clearInterval(timerId);
|
return () => window.clearInterval(timerId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 연결은 되었는데 체결이 오래 안 들어오는 경우 안내합니다.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isConnected || lastTickAt) return;
|
if (!isConnected || lastTickAt) return;
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setError(
|
setError(
|
||||||
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간) 여부를 확인해 주세요.",
|
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장 시간(한국시간)과 TR ID를 확인해 주세요.",
|
||||||
);
|
);
|
||||||
}, 8000);
|
}, 8000);
|
||||||
|
|
||||||
@@ -134,22 +132,18 @@ export function useKisTradeWebSocket(
|
|||||||
setError(null);
|
setError(null);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
|
|
||||||
const approvalKey = await useKisRuntimeStore
|
const wsConnection = await useKisRuntimeStore
|
||||||
.getState()
|
.getState()
|
||||||
.getOrFetchApprovalKey();
|
.getOrFetchWsConnection();
|
||||||
|
|
||||||
if (!approvalKey) {
|
if (!wsConnection) {
|
||||||
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
throw new Error("웹소켓 승인키 발급에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
approvalKeyRef.current = approvalKey;
|
approvalKeyRef.current = wsConnection.approvalKey;
|
||||||
|
|
||||||
const wsBase =
|
socket = new WebSocket(`${wsConnection.wsUrl}/tryitout/${tradeTrId}`);
|
||||||
process.env.NEXT_PUBLIC_KIS_WS_URL ||
|
|
||||||
"ws://ops.koreainvestment.com:21000";
|
|
||||||
|
|
||||||
socket = new WebSocket(`${wsBase}/tryitout/${tradeTrId}`);
|
|
||||||
socketRef.current = socket;
|
socketRef.current = socket;
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { create } from "zustand";
|
|
||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
|
||||||
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
|
|
||||||
import { fetchKisWebSocketApproval } from "@/features/dashboard/apis/kis-auth.api";
|
import { fetchKisWebSocketApproval } from "@/features/dashboard/apis/kis-auth.api";
|
||||||
|
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file features/dashboard/store/use-kis-runtime-store.ts
|
* @file features/dashboard/store/use-kis-runtime-store.ts
|
||||||
* @description KIS 키 입력/검증 상태를 zustand로 관리하고 새로고침 시 복원합니다.
|
* @description Stores KIS input, verification, and websocket connection state.
|
||||||
|
* @see features/dashboard/hooks/useKisTradeWebSocket.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface KisRuntimeCredentials {
|
export interface KisRuntimeCredentials {
|
||||||
appKey: string;
|
appKey: string;
|
||||||
appSecret: string;
|
appSecret: string;
|
||||||
@@ -17,73 +17,37 @@ export interface KisRuntimeCredentials {
|
|||||||
accountNo: string;
|
accountNo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KisWsConnection {
|
||||||
|
approvalKey: string;
|
||||||
|
wsUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface KisRuntimeStoreState {
|
interface KisRuntimeStoreState {
|
||||||
// [State] 입력 폼 상태
|
|
||||||
kisTradingEnvInput: KisTradingEnv;
|
kisTradingEnvInput: KisTradingEnv;
|
||||||
kisAppKeyInput: string;
|
kisAppKeyInput: string;
|
||||||
kisAppSecretInput: string;
|
kisAppSecretInput: string;
|
||||||
kisAccountNoInput: string;
|
kisAccountNoInput: string;
|
||||||
|
|
||||||
// [State] 검증/연동 상태
|
|
||||||
verifiedCredentials: KisRuntimeCredentials | null;
|
verifiedCredentials: KisRuntimeCredentials | null;
|
||||||
isKisVerified: boolean;
|
isKisVerified: boolean;
|
||||||
tradingEnv: KisTradingEnv;
|
tradingEnv: KisTradingEnv;
|
||||||
|
|
||||||
// [State] 웹소켓 승인키
|
|
||||||
wsApprovalKey: string | null;
|
wsApprovalKey: string | null;
|
||||||
|
wsUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KisRuntimeStoreActions {
|
interface KisRuntimeStoreActions {
|
||||||
/**
|
|
||||||
* 거래 모드 입력값을 변경하고 기존 검증 상태를 무효화합니다.
|
|
||||||
* @param tradingEnv 거래 모드
|
|
||||||
* @see features/dashboard/components/dashboard-main.tsx 거래 모드 버튼 onClick 이벤트
|
|
||||||
*/
|
|
||||||
setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void;
|
setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void;
|
||||||
/**
|
|
||||||
* 앱 키 입력값을 변경하고 기존 검증 상태를 무효화합니다.
|
|
||||||
* @param appKey 앱 키
|
|
||||||
* @see features/dashboard/components/dashboard-main.tsx App Key onChange 이벤트
|
|
||||||
*/
|
|
||||||
setKisAppKeyInput: (appKey: string) => void;
|
setKisAppKeyInput: (appKey: string) => void;
|
||||||
/**
|
|
||||||
* 앱 시크릿 입력값을 변경하고 기존 검증 상태를 무효화합니다.
|
|
||||||
* @param appSecret 앱 시크릿
|
|
||||||
* @see features/dashboard/components/dashboard-main.tsx App Secret onChange 이벤트
|
|
||||||
*/
|
|
||||||
setKisAppSecretInput: (appSecret: string) => void;
|
setKisAppSecretInput: (appSecret: string) => void;
|
||||||
/**
|
|
||||||
* 계좌번호 입력값을 변경하고 기존 검증 상태를 무효화합니다.
|
|
||||||
* @param accountNo 계좌번호 (8자리-2자리)
|
|
||||||
*/
|
|
||||||
setKisAccountNoInput: (accountNo: string) => void;
|
setKisAccountNoInput: (accountNo: string) => void;
|
||||||
/**
|
|
||||||
* 검증 성공 상태를 저장합니다.
|
|
||||||
* @param credentials 검증 완료된 키
|
|
||||||
* @param tradingEnv 현재 연동 모드
|
|
||||||
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis
|
|
||||||
*/
|
|
||||||
setVerifiedKisSession: (
|
setVerifiedKisSession: (
|
||||||
credentials: KisRuntimeCredentials,
|
credentials: KisRuntimeCredentials,
|
||||||
tradingEnv: KisTradingEnv,
|
tradingEnv: KisTradingEnv,
|
||||||
) => void;
|
) => void;
|
||||||
/**
|
|
||||||
* 검증 실패 또는 입력 변경 시 검증 상태만 초기화합니다.
|
|
||||||
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis catch
|
|
||||||
*/
|
|
||||||
invalidateKisVerification: () => void;
|
invalidateKisVerification: () => void;
|
||||||
/**
|
|
||||||
* 접근 폐기 시 입력값/검증값을 모두 제거합니다.
|
|
||||||
* @param tradingEnv 표시용 모드
|
|
||||||
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis
|
|
||||||
*/
|
|
||||||
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
|
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
|
||||||
|
getOrFetchWsConnection: () => Promise<KisWsConnection | null>;
|
||||||
/**
|
|
||||||
* 웹소켓 승인키를 가져오거나 없으면 발급받습니다.
|
|
||||||
* @returns approvalKey
|
|
||||||
*/
|
|
||||||
getOrFetchApprovalKey: () => Promise<string | null>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_STATE: KisRuntimeStoreState = {
|
const INITIAL_STATE: KisRuntimeStoreState = {
|
||||||
@@ -95,11 +59,22 @@ const INITIAL_STATE: KisRuntimeStoreState = {
|
|||||||
isKisVerified: false,
|
isKisVerified: false,
|
||||||
tradingEnv: "real",
|
tradingEnv: "real",
|
||||||
wsApprovalKey: null,
|
wsApprovalKey: null,
|
||||||
|
wsUrl: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 동시 요청 방지를 위한 모듈 스코프 변수
|
const RESET_VERIFICATION_STATE = {
|
||||||
let approvalPromise: Promise<string | null> | null = null;
|
verifiedCredentials: null,
|
||||||
|
isKisVerified: false,
|
||||||
|
wsApprovalKey: null,
|
||||||
|
wsUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let wsConnectionPromise: Promise<KisWsConnection | null> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Runtime store for KIS session.
|
||||||
|
* @see features/dashboard/components/auth/KisAuthForm.tsx
|
||||||
|
*/
|
||||||
export const useKisRuntimeStore = create<
|
export const useKisRuntimeStore = create<
|
||||||
KisRuntimeStoreState & KisRuntimeStoreActions
|
KisRuntimeStoreState & KisRuntimeStoreActions
|
||||||
>()(
|
>()(
|
||||||
@@ -110,33 +85,25 @@ export const useKisRuntimeStore = create<
|
|||||||
setKisTradingEnvInput: (tradingEnv) =>
|
setKisTradingEnvInput: (tradingEnv) =>
|
||||||
set({
|
set({
|
||||||
kisTradingEnvInput: tradingEnv,
|
kisTradingEnvInput: tradingEnv,
|
||||||
verifiedCredentials: null,
|
...RESET_VERIFICATION_STATE,
|
||||||
isKisVerified: false,
|
|
||||||
wsApprovalKey: null,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setKisAppKeyInput: (appKey) =>
|
setKisAppKeyInput: (appKey) =>
|
||||||
set({
|
set({
|
||||||
kisAppKeyInput: appKey,
|
kisAppKeyInput: appKey,
|
||||||
verifiedCredentials: null,
|
...RESET_VERIFICATION_STATE,
|
||||||
isKisVerified: false,
|
|
||||||
wsApprovalKey: null,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setKisAppSecretInput: (appSecret) =>
|
setKisAppSecretInput: (appSecret) =>
|
||||||
set({
|
set({
|
||||||
kisAppSecretInput: appSecret,
|
kisAppSecretInput: appSecret,
|
||||||
verifiedCredentials: null,
|
...RESET_VERIFICATION_STATE,
|
||||||
isKisVerified: false,
|
|
||||||
wsApprovalKey: null,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setKisAccountNoInput: (accountNo) =>
|
setKisAccountNoInput: (accountNo) =>
|
||||||
set({
|
set({
|
||||||
kisAccountNoInput: accountNo,
|
kisAccountNoInput: accountNo,
|
||||||
verifiedCredentials: null,
|
...RESET_VERIFICATION_STATE,
|
||||||
isKisVerified: false,
|
|
||||||
wsApprovalKey: null,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setVerifiedKisSession: (credentials, tradingEnv) =>
|
setVerifiedKisSession: (credentials, tradingEnv) =>
|
||||||
@@ -144,15 +111,13 @@ export const useKisRuntimeStore = create<
|
|||||||
verifiedCredentials: credentials,
|
verifiedCredentials: credentials,
|
||||||
isKisVerified: true,
|
isKisVerified: true,
|
||||||
tradingEnv,
|
tradingEnv,
|
||||||
// 인증이 바뀌면 승인키도 초기화
|
|
||||||
wsApprovalKey: null,
|
wsApprovalKey: null,
|
||||||
|
wsUrl: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
invalidateKisVerification: () =>
|
invalidateKisVerification: () =>
|
||||||
set({
|
set({
|
||||||
verifiedCredentials: null,
|
...RESET_VERIFICATION_STATE,
|
||||||
isKisVerified: false,
|
|
||||||
wsApprovalKey: null,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
clearKisRuntimeSession: (tradingEnv) =>
|
clearKisRuntimeSession: (tradingEnv) =>
|
||||||
@@ -161,48 +126,52 @@ export const useKisRuntimeStore = create<
|
|||||||
kisAppKeyInput: "",
|
kisAppKeyInput: "",
|
||||||
kisAppSecretInput: "",
|
kisAppSecretInput: "",
|
||||||
kisAccountNoInput: "",
|
kisAccountNoInput: "",
|
||||||
verifiedCredentials: null,
|
...RESET_VERIFICATION_STATE,
|
||||||
isKisVerified: false,
|
|
||||||
tradingEnv,
|
tradingEnv,
|
||||||
wsApprovalKey: null,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getOrFetchApprovalKey: async () => {
|
getOrFetchWsConnection: async () => {
|
||||||
const { wsApprovalKey, verifiedCredentials } = get();
|
const { wsApprovalKey, wsUrl, verifiedCredentials } = get();
|
||||||
|
|
||||||
// 1. 이미 키가 있으면 반환
|
if (wsApprovalKey && wsUrl) {
|
||||||
if (wsApprovalKey) {
|
return { approvalKey: wsApprovalKey, wsUrl };
|
||||||
return wsApprovalKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 인증 정보가 없으면 실패
|
|
||||||
if (!verifiedCredentials) {
|
if (!verifiedCredentials) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 이미 진행 중인 요청이 있다면 해당 Promise 반환 (Deduping)
|
if (wsConnectionPromise) {
|
||||||
if (approvalPromise) {
|
return wsConnectionPromise;
|
||||||
return approvalPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. API 호출
|
wsConnectionPromise = (async () => {
|
||||||
approvalPromise = (async () => {
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchKisWebSocketApproval(verifiedCredentials);
|
const data = await fetchKisWebSocketApproval(verifiedCredentials);
|
||||||
if (data.approvalKey) {
|
if (!data.approvalKey || !data.wsUrl) {
|
||||||
set({ wsApprovalKey: data.approvalKey });
|
|
||||||
return data.approvalKey;
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextConnection = {
|
||||||
|
approvalKey: data.approvalKey,
|
||||||
|
wsUrl: data.wsUrl,
|
||||||
|
} satisfies KisWsConnection;
|
||||||
|
|
||||||
|
set({
|
||||||
|
wsApprovalKey: nextConnection.approvalKey,
|
||||||
|
wsUrl: nextConnection.wsUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextConnection;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
approvalPromise = null;
|
wsConnectionPromise = null;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return approvalPromise;
|
return wsConnectionPromise;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -216,13 +185,7 @@ export const useKisRuntimeStore = create<
|
|||||||
verifiedCredentials: state.verifiedCredentials,
|
verifiedCredentials: state.verifiedCredentials,
|
||||||
isKisVerified: state.isKisVerified,
|
isKisVerified: state.isKisVerified,
|
||||||
tradingEnv: state.tradingEnv,
|
tradingEnv: state.tradingEnv,
|
||||||
// wsApprovalKey도 로컬 스토리지에 저장하여 새로고침 후에도 유지 (선택사항이나 유지하는 게 유리)
|
// wsApprovalKey/wsUrl are kept in memory only (expiration-sensitive).
|
||||||
// 단, 승인키 유효기간 문제가 있을 수 있으나 API 실패 시 재발급 로직을 넣거나,
|
|
||||||
// 현재 로직상 인증 정보가 바뀌면 초기화되므로 저장해도 무방.
|
|
||||||
// 하지만 유효기간 만료 처리가 없으므로 일단 저장하지 않는 게 안전할 수도 있음.
|
|
||||||
// 사용자가 "새로고침"을 하는 빈도보다 "일반적인 사용"이 많으므로 저장하지 않음 (partialize에서 제외)
|
|
||||||
// -> 코드를 보니 여기 포함시키지 않으면 저장이 안 됨.
|
|
||||||
// 유효기간 처리가 없으니 승인키는 메모리에만 유지하도록 함 (새로고침 시 재발급)
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
108
features/layout/components/Logo.tsx
Normal file
108
features/layout/components/Logo.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
className?: string;
|
||||||
|
variant?: "symbol" | "full";
|
||||||
|
/** 배경과 섞이는 모드 (홈 화면 등). 로고가 흰색으로 표시됩니다. */
|
||||||
|
blendWithBackground?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({
|
||||||
|
className,
|
||||||
|
variant = "full",
|
||||||
|
blendWithBackground = false,
|
||||||
|
}: LogoProps) {
|
||||||
|
// 색상 클래스 정의
|
||||||
|
const mainColorClass = blendWithBackground
|
||||||
|
? "fill-brand-500 stroke-brand-500" // 배경 혼합 모드에서도 심볼은 브랜드 컬러 유지
|
||||||
|
: "fill-brand-600 stroke-brand-600 dark:fill-brand-500 dark:stroke-brand-500";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("relative flex items-center gap-2 select-none", className)}
|
||||||
|
aria-label="Jurini Logo"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
|
variant === "full" ? "h-10 w-10" : "h-full w-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{/* Mask for the cutout effect around the arrow */}
|
||||||
|
<mask id="arrow-cutout">
|
||||||
|
<rect width="100%" height="100%" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M10 75 C 35 45, 55 85, 90 25"
|
||||||
|
fill="none"
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="12"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* Arrow Head Cutout */}
|
||||||
|
<path
|
||||||
|
d="M90 25 L 78 32 L 85 42 Z"
|
||||||
|
fill="black"
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
transform="rotate(-15 90 25)"
|
||||||
|
/>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* ========== BARS (Masked) ========== */}
|
||||||
|
<g
|
||||||
|
mask="url(#arrow-cutout)"
|
||||||
|
className={
|
||||||
|
blendWithBackground
|
||||||
|
? "fill-brand-500" // 배경 혼합 모드에서도 브랜드 컬러 사용
|
||||||
|
: "fill-brand-600 dark:fill-brand-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Bar 1 (Left, Short) */}
|
||||||
|
<rect x="15" y="45" width="18" height="40" rx="4" />
|
||||||
|
{/* Bar 2 (Middle, Medium) */}
|
||||||
|
<rect x="41" y="30" width="18" height="55" rx="4" />
|
||||||
|
{/* Bar 3 (Right, Tall) */}
|
||||||
|
<rect x="67" y="10" width="18" height="75" rx="4" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* ========== ARROW (Foreground) ========== */}
|
||||||
|
<g className={mainColorClass}>
|
||||||
|
{/* Arrow Path */}
|
||||||
|
<path
|
||||||
|
d="M10 75 C 35 45, 55 85, 90 25"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* Arrow Head */}
|
||||||
|
<path
|
||||||
|
d="M90 25 L 78 32 L 85 42 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="none"
|
||||||
|
transform="rotate(-15 90 25)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* ========== TEXT (Optional) ========== */}
|
||||||
|
{variant === "full" && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-bold tracking-tight",
|
||||||
|
blendWithBackground
|
||||||
|
? "text-white opacity-95"
|
||||||
|
: "text-brand-900 dark:text-brand-50",
|
||||||
|
)}
|
||||||
|
style={{ fontSize: "1.35rem", fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
JOORIN-E
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { SessionTimer } from "@/features/auth/components/session-timer";
|
import { SessionTimer } from "@/features/auth/components/session-timer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Logo } from "@/features/layout/components/Logo";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
/** 현재 로그인 사용자 정보(null 가능) */
|
/** 현재 로그인 사용자 정보(null 가능) */
|
||||||
@@ -59,40 +60,29 @@ export function Header({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* ========== LEFT: LOGO SECTION ========== */}
|
{/* ========== LEFT: LOGO SECTION ========== */}
|
||||||
<Link
|
{/* ========== LEFT: LOGO SECTION ========== */}
|
||||||
href={AUTH_ROUTES.HOME}
|
<Link href={AUTH_ROUTES.HOME} className="group flex items-center gap-2">
|
||||||
className={cn("group flex items-center gap-2", blendWithBackground ? "text-white" : "")}
|
<Logo
|
||||||
>
|
variant="full"
|
||||||
<div
|
className="h-10 text-xl transition-transform duration-200 group-hover:scale-105"
|
||||||
className={cn(
|
blendWithBackground={blendWithBackground}
|
||||||
"flex h-9 w-9 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-110",
|
/>
|
||||||
blendWithBackground ? "bg-brand-500/45 ring-1 ring-white/55" : "bg-primary/10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="h-5 w-5 rounded-lg bg-linear-to-br from-brand-500 to-brand-700" />
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-xl font-bold tracking-tight transition-colors",
|
|
||||||
blendWithBackground
|
|
||||||
? "!text-white [text-shadow:0_2px_18px_rgba(0,0,0,0.75)] group-hover:text-brand-100"
|
|
||||||
: "text-foreground group-hover:text-primary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Jurini
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* ========== RIGHT: ACTION SECTION ========== */}
|
{/* ========== RIGHT: ACTION SECTION ========== */}
|
||||||
<div className={cn("flex items-center gap-2 sm:gap-3", blendWithBackground ? "text-white" : "")}
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 sm:gap-3",
|
||||||
|
blendWithBackground ? "text-white" : "",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ThemeToggle
|
<ThemeToggle
|
||||||
className={cn(
|
className={cn(
|
||||||
blendWithBackground
|
blendWithBackground
|
||||||
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 focus-visible:ring-white/80"
|
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md hover:bg-black/65 focus-visible:ring-white/80"
|
||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
iconClassName={blendWithBackground ? "!text-white" : undefined}
|
iconClassName={blendWithBackground ? "text-white!" : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
@@ -107,7 +97,7 @@ export function Header({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"hidden font-medium sm:inline-flex",
|
"hidden font-medium sm:inline-flex",
|
||||||
blendWithBackground
|
blendWithBackground
|
||||||
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:!text-white"
|
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:text-white!"
|
||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -126,7 +116,7 @@ export function Header({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"hidden sm:inline-flex",
|
"hidden sm:inline-flex",
|
||||||
blendWithBackground
|
blendWithBackground
|
||||||
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 hover:!text-white"
|
? "rounded-full border border-white/40 bg-black/50 text-white! backdrop-blur-md hover:bg-black/65 hover:text-white!"
|
||||||
: "",
|
: "",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getKisConfig, getKisWebSocketUrl } from "@/lib/kis/config";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @file lib/kis/approval.ts
|
* @file lib/kis/approval.ts
|
||||||
* @description KIS 웹소켓 approval key 발급/캐시 관리
|
* @description KIS 웹소켓 승인키 생명주기를 관리합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface KisApprovalResponse {
|
interface KisApprovalResponse {
|
||||||
@@ -34,12 +34,12 @@ function getApprovalCacheKey(credentials?: KisCredentialInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KIS 웹소켓 approval key 발급
|
* @description 웹소켓 승인키를 발급합니다.
|
||||||
* @param credentials 사용자 입력 키(선택)
|
* @see app/api/kis/ws/approval/route.ts
|
||||||
* @returns approval key + expiresAt
|
|
||||||
* @see app/api/kis/ws/approval/route.ts POST - 실시간 차트 연결 시 호출
|
|
||||||
*/
|
*/
|
||||||
async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise<KisApprovalCache> {
|
async function issueKisApprovalKey(
|
||||||
|
credentials?: KisCredentialInput,
|
||||||
|
): Promise<KisApprovalCache> {
|
||||||
const config = getKisConfig(credentials);
|
const config = getKisConfig(credentials);
|
||||||
|
|
||||||
const response = await fetch(`${config.baseUrl}/oauth2/Approval`, {
|
const response = await fetch(`${config.baseUrl}/oauth2/Approval`, {
|
||||||
@@ -50,6 +50,7 @@ async function issueKisApprovalKey(credentials?: KisCredentialInput): Promise<Ki
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
grant_type: "client_credentials",
|
grant_type: "client_credentials",
|
||||||
appkey: config.appKey,
|
appkey: config.appKey,
|
||||||
|
// Official samples use `secretkey` for Approval endpoint.
|
||||||
secretkey: config.appSecret,
|
secretkey: config.appSecret,
|
||||||
}),
|
}),
|
||||||
cache: "no-store",
|
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 {
|
return {
|
||||||
approvalKey: payload.approval_key,
|
approvalKey: payload.approval_key,
|
||||||
expiresAt: Date.now() + 23 * 60 * 60 * 1000,
|
expiresAt: Date.now() + 23 * 60 * 60 * 1000,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* approval 응답을 안전하게 JSON으로 파싱합니다.
|
|
||||||
* @param rawText fetch 응답 원문
|
|
||||||
* @returns KisApprovalResponse
|
|
||||||
*/
|
|
||||||
function tryParseApprovalResponse(rawText: string): KisApprovalResponse {
|
function tryParseApprovalResponse(rawText: string): KisApprovalResponse {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(rawText) as KisApprovalResponse;
|
return JSON.parse(rawText) as KisApprovalResponse;
|
||||||
@@ -93,9 +89,8 @@ function tryParseApprovalResponse(rawText: string): KisApprovalResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 웹소켓 승인키를 반환합니다.
|
* @description 승인키를 캐시에서 반환하거나 새로 발급합니다.
|
||||||
* @param credentials 사용자 입력 키(선택)
|
* @see features/dashboard/store/use-kis-runtime-store.ts
|
||||||
* @returns approval key
|
|
||||||
*/
|
*/
|
||||||
export async function getKisApprovalKey(credentials?: KisCredentialInput) {
|
export async function getKisApprovalKey(credentials?: KisCredentialInput) {
|
||||||
const cacheKey = getApprovalCacheKey(credentials);
|
const cacheKey = getApprovalCacheKey(credentials);
|
||||||
@@ -113,6 +108,7 @@ export async function getKisApprovalKey(credentials?: KisCredentialInput) {
|
|||||||
|
|
||||||
const nextPromise = issueKisApprovalKey(credentials);
|
const nextPromise = issueKisApprovalKey(credentials);
|
||||||
approvalIssueInFlightMap.set(cacheKey, nextPromise);
|
approvalIssueInFlightMap.set(cacheKey, nextPromise);
|
||||||
|
|
||||||
const next = await nextPromise.finally(() => {
|
const next = await nextPromise.finally(() => {
|
||||||
approvalIssueInFlightMap.delete(cacheKey);
|
approvalIssueInFlightMap.delete(cacheKey);
|
||||||
});
|
});
|
||||||
@@ -122,9 +118,8 @@ export async function getKisApprovalKey(credentials?: KisCredentialInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 거래 모드에 맞는 KIS 웹소켓 URL을 반환합니다.
|
* @description 거래 환경에 맞는 웹소켓 URL을 반환합니다.
|
||||||
* @param credentials 사용자 입력 키(선택)
|
* @see app/api/kis/ws/approval/route.ts
|
||||||
* @returns websocket url
|
|
||||||
*/
|
*/
|
||||||
export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) {
|
export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) {
|
||||||
const config = getKisConfig(credentials);
|
const config = getKisConfig(credentials);
|
||||||
@@ -132,8 +127,8 @@ export function resolveKisWebSocketUrl(credentials?: KisCredentialInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 승인키 캐시를 제거합니다.
|
* @description 승인키 캐시를 제거합니다.
|
||||||
* @param credentials 사용자 입력 키(선택)
|
* @see lib/kis/token.ts
|
||||||
*/
|
*/
|
||||||
export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) {
|
export function clearKisApprovalKeyCache(credentials?: KisCredentialInput) {
|
||||||
const cacheKey = getApprovalCacheKey(credentials);
|
const cacheKey = getApprovalCacheKey(credentials);
|
||||||
|
|||||||
42
lib/kis/request.ts
Normal file
42
lib/kis/request.ts
Normal 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;
|
||||||
|
}
|
||||||
181
lib/kis/token.ts
181
lib/kis/token.ts
@@ -1,18 +1,19 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { KisCredentialInput } from "@/lib/kis/config";
|
|
||||||
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
|
import { clearKisApprovalKeyCache } from "@/lib/kis/approval";
|
||||||
|
import type { KisCredentialInput } from "@/lib/kis/config";
|
||||||
import { getKisConfig } from "@/lib/kis/config";
|
import { getKisConfig } from "@/lib/kis/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @file lib/kis/token.ts
|
* @file lib/kis/token.ts
|
||||||
* @description KIS access token 발급/캐시 관리(실전/모의 공통)
|
* @description KIS 액세스 토큰 발급/폐기/캐시를 관리합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface KisTokenResponse {
|
interface KisTokenResponse {
|
||||||
access_token?: string;
|
access_token?: string;
|
||||||
access_token_token_expired?: string;
|
access_token_token_expired?: string;
|
||||||
|
access_token_expired?: string;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
msg1?: string;
|
msg1?: string;
|
||||||
msg_cd?: string;
|
msg_cd?: string;
|
||||||
@@ -25,6 +26,10 @@ interface KisTokenCache {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PersistedTokenCache {
|
||||||
|
[cacheKey: string]: KisTokenCache;
|
||||||
|
}
|
||||||
|
|
||||||
interface KisRevokeResponse {
|
interface KisRevokeResponse {
|
||||||
code?: number | string;
|
code?: number | string;
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -45,10 +50,6 @@ function getTokenCacheKey(credentials?: KisCredentialInput) {
|
|||||||
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
|
return `${config.tradingEnv}:${hashKey(config.appKey)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PersistedTokenCache {
|
|
||||||
[cacheKey: string]: KisTokenCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readPersistedTokenCache() {
|
async function readPersistedTokenCache() {
|
||||||
try {
|
try {
|
||||||
const raw = await readFile(TOKEN_CACHE_FILE_PATH, "utf8");
|
const raw = await readFile(TOKEN_CACHE_FILE_PATH, "utf8");
|
||||||
@@ -66,6 +67,7 @@ async function writePersistedTokenCache(next: PersistedTokenCache) {
|
|||||||
async function getPersistedToken(cacheKey: string) {
|
async function getPersistedToken(cacheKey: string) {
|
||||||
const cache = await readPersistedTokenCache();
|
const cache = await readPersistedTokenCache();
|
||||||
const token = cache[cacheKey];
|
const token = cache[cacheKey];
|
||||||
|
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
|
|
||||||
if (token.expiresAt - TOKEN_REFRESH_BUFFER_MS <= Date.now()) {
|
if (token.expiresAt - TOKEN_REFRESH_BUFFER_MS <= Date.now()) {
|
||||||
@@ -93,7 +95,7 @@ async function clearPersistedToken(cacheKey: string) {
|
|||||||
try {
|
try {
|
||||||
await unlink(TOKEN_CACHE_FILE_PATH);
|
await unlink(TOKEN_CACHE_FILE_PATH);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore when file does not exist
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -101,11 +103,77 @@ async function clearPersistedToken(cacheKey: string) {
|
|||||||
await writePersistedTokenCache(cache);
|
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 발급
|
* @description 토큰 발급 실패 원인 점검 문구를 만듭니다.
|
||||||
* @param credentials 사용자 입력 키(선택)
|
* @see https://github.com/koreainvestment/open-trading-api
|
||||||
* @returns token + expiresAt
|
*/
|
||||||
* @see app/api/kis/validate/route.ts POST - 사용자 키 검증 시 토큰 발급 경로
|
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> {
|
async function issueKisToken(credentials?: KisCredentialInput): Promise<KisTokenCache> {
|
||||||
const config = getKisConfig(credentials);
|
const config = getKisConfig(credentials);
|
||||||
@@ -147,68 +215,8 @@ async function issueKisToken(credentials?: KisCredentialInput): Promise<KisToken
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 토큰 발급 실패 시 점검 안내를 생성합니다.
|
* @description 캐시된 토큰을 반환하거나 새로 발급합니다.
|
||||||
* @param detail KIS 응답 메시지
|
* @see lib/kis/domestic.ts
|
||||||
* @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) {
|
export async function getKisAccessToken(credentials?: KisCredentialInput) {
|
||||||
const cacheKey = getTokenCacheKey(credentials);
|
const cacheKey = getTokenCacheKey(credentials);
|
||||||
@@ -224,7 +232,6 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) {
|
|||||||
return persisted.token;
|
return persisted.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 같은 키로 동시에 요청이 들어오면 토큰 발급을 1회로 합칩니다.
|
|
||||||
const inFlight = tokenIssueInFlightMap.get(cacheKey);
|
const inFlight = tokenIssueInFlightMap.get(cacheKey);
|
||||||
if (inFlight) {
|
if (inFlight) {
|
||||||
const shared = await inFlight;
|
const shared = await inFlight;
|
||||||
@@ -233,6 +240,7 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) {
|
|||||||
|
|
||||||
const nextPromise = issueKisToken(credentials);
|
const nextPromise = issueKisToken(credentials);
|
||||||
tokenIssueInFlightMap.set(cacheKey, nextPromise);
|
tokenIssueInFlightMap.set(cacheKey, nextPromise);
|
||||||
|
|
||||||
const next = await nextPromise.finally(() => {
|
const next = await nextPromise.finally(() => {
|
||||||
tokenIssueInFlightMap.delete(cacheKey);
|
tokenIssueInFlightMap.delete(cacheKey);
|
||||||
});
|
});
|
||||||
@@ -243,10 +251,8 @@ export async function getKisAccessToken(credentials?: KisCredentialInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KIS access token 폐기 요청
|
* @description 현재 KIS 액세스 토큰을 폐기합니다.
|
||||||
* @param credentials 사용자 입력 키(선택)
|
* @see app/api/kis/revoke/route.ts
|
||||||
* @returns 폐기 응답 메시지
|
|
||||||
* @see app/api/kis/revoke/route.ts POST - 대시보드 접근 폐기 버튼 처리
|
|
||||||
*/
|
*/
|
||||||
export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
|
export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
|
||||||
const config = getKisConfig(credentials);
|
const config = getKisConfig(credentials);
|
||||||
@@ -273,6 +279,7 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
|
|||||||
|
|
||||||
if (!response.ok || !isSuccessCode) {
|
if (!response.ok || !isSuccessCode) {
|
||||||
const detail = [payload.message, payload.msg1].filter(Boolean).join(" / ");
|
const detail = [payload.message, payload.msg1].filter(Boolean).join(" / ");
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
detail
|
detail
|
||||||
? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
|
? `KIS 토큰 폐기 실패 (${config.tradingEnv}, ${response.status}): ${detail}`
|
||||||
@@ -285,21 +292,5 @@ export async function revokeKisAccessToken(credentials?: KisCredentialInput) {
|
|||||||
await clearPersistedToken(cacheKey);
|
await clearPersistedToken(cacheKey);
|
||||||
clearKisApprovalKeyCache(credentials);
|
clearKisApprovalKeyCache(credentials);
|
||||||
|
|
||||||
return payload.message ?? "접근토큰 폐기에 성공하였습니다.";
|
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