스킬 정리 및 리팩토링

This commit is contained in:
2026-02-26 09:05:17 +09:00
parent 4c52d6d82f
commit 406af7408a
71 changed files with 3776 additions and 3934 deletions

56
app/api/kis/_response.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { KisTradingEnv } from "@/features/trade/types/trade.types";
import { NextResponse } from "next/server";
export const KIS_API_ERROR_CODE = {
AUTH_REQUIRED: "KIS_AUTH_REQUIRED",
INVALID_REQUEST: "KIS_INVALID_REQUEST",
CREDENTIAL_REQUIRED: "KIS_CREDENTIAL_REQUIRED",
ACCOUNT_REQUIRED: "KIS_ACCOUNT_REQUIRED",
UPSTREAM_FAILURE: "KIS_UPSTREAM_FAILURE",
UNAUTHORIZED: "KIS_UNAUTHORIZED",
} as const;
export type KisApiErrorCode =
(typeof KIS_API_ERROR_CODE)[keyof typeof KIS_API_ERROR_CODE];
interface CreateKisApiErrorResponseOptions {
status: number;
code: KisApiErrorCode;
message: string;
tradingEnv?: KisTradingEnv;
extra?: Record<string, unknown>;
}
/**
* @description KIS API 라우트용 표준 에러 응답을 생성합니다.
* @remarks 클라이언트 하위호환을 위해 message/error 키를 동시에 제공합니다.
* @see features/trade/apis/kis-stock.api.ts 종목 API 클라이언트는 error 우선 파싱
* @see features/settings/apis/kis-auth.api.ts 인증 API 클라이언트는 message 우선 파싱
*/
export function createKisApiErrorResponse({
status,
code,
message,
tradingEnv,
extra,
}: CreateKisApiErrorResponseOptions) {
return NextResponse.json(
{
ok: false,
message,
error: message,
errorCode: code,
...(tradingEnv ? { tradingEnv } : {}),
...(extra ?? {}),
},
{ status },
);
}
/**
* @description unknown 에러 객체를 사용자 노출용 메시지로 정규화합니다.
* @see app/api/kis/domestic/balance/route.ts 서버 예외를 공통 메시지로 변환
*/
export function toKisApiErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}

View File

@@ -3,6 +3,11 @@ import type { DashboardActivityResponse } from "@/features/dashboard/types/dashb
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
@@ -23,29 +28,31 @@ import {
export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
const account = readKisAccountParts(request.headers);
if (!account) {
return NextResponse.json(
{
error:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
},
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
});
}
try {
@@ -66,10 +73,13 @@ export async function GET(request: Request) {
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "주문내역/매매일지 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(
error,
"주문내역/매매일지 조회 중 오류가 발생했습니다.",
),
});
}
}

View File

@@ -3,6 +3,11 @@ import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashbo
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import {
readKisAccountParts,
readKisCredentialsFromHeaders,
@@ -21,29 +26,31 @@ import {
export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
const account = readKisAccountParts(request.headers);
if (!account) {
return NextResponse.json(
{
error:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
},
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
});
}
try {
@@ -62,10 +69,10 @@ export async function GET(request: Request) {
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "잔고 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "잔고 조회 중 오류가 발생했습니다."),
});
}
}

View File

@@ -2,11 +2,16 @@ import type {
DashboardChartTimeframe,
DashboardStockChartResponse,
} from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { hasKisConfig } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m",
@@ -23,7 +28,11 @@ const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const { searchParams } = new URL(request.url);
@@ -34,28 +43,29 @@ export async function GET(request: NextRequest) {
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "symbol은 6자리 숫자여야 합니다.",
});
}
if (!VALID_TIMEFRAMES.includes(timeframe)) {
return NextResponse.json(
{ error: "지원하지 않는 timeframe입니다." },
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "지원하지 않는 timeframe입니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
},
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
});
}
try {
@@ -81,24 +91,10 @@ export async function GET(request: NextRequest) {
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "KIS 차트 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "KIS 차트 조회 중 오류가 발생했습니다."),
});
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}

View File

@@ -3,6 +3,11 @@ import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashbo
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
@@ -18,18 +23,21 @@ import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
export async function GET(request: Request) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
try {
@@ -47,10 +55,10 @@ export async function GET(request: Request) {
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "지수 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "지수 조회 중 오류가 발생했습니다."),
});
}
}

View File

@@ -1,67 +1,137 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { executeOrderCash } from "@/lib/kis/trade";
import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { parseKisAccountParts } from "@/lib/kis/account";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/order-cash/route.ts
* @description 국내주식 현금 주문 API
*/
const orderCashBodySchema = z
.object({
symbol: z
.string()
.trim()
.regex(/^\d{6}$/, "종목코드는 6자리 숫자여야 합니다."),
side: z.enum(["buy", "sell"], {
message: "주문 구분(side)은 buy/sell만 허용됩니다.",
}),
orderType: z.enum(["limit", "market"], {
message: "주문 유형(orderType)은 limit/market만 허용됩니다.",
}),
quantity: z.coerce
.number()
.int("주문수량은 정수여야 합니다.")
.positive("주문수량은 1주 이상이어야 합니다."),
price: z.coerce.number(),
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
accountProductCode: z.string().trim().optional(),
})
.superRefine((body, ctx) => {
if (body.orderType === "limit" && body.price <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["price"],
message: "지정가 주문은 주문가격이 0보다 커야 합니다.",
});
}
if (body.orderType === "market" && body.price < 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["price"],
message: "시장가 주문은 주문가격이 0 이상이어야 합니다.",
});
}
const accountParts = parseKisAccountParts(
body.accountNo,
body.accountProductCode,
);
if (!accountParts) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["accountNo"],
message:
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
});
}
});
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
},
{ status: 401 },
);
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
tradingEnv,
});
}
try {
const body = (await request.json()) as DashboardStockCashOrderRequest;
let rawBody: unknown = {};
try {
rawBody = (await request.json()) as unknown;
} catch {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "요청 본문(JSON)을 읽을 수 없습니다.",
tradingEnv,
});
}
// TODO: Validate body fields (symbol, quantity, price, etc.)
if (
!body.symbol ||
!body.accountNo ||
!body.accountProductCode ||
body.quantity <= 0
) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message:
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)",
},
{ status: 400 },
);
const parsed = orderCashBodySchema.safeParse(rawBody);
if (!parsed.success) {
const firstIssue = parsed.error.issues[0];
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: firstIssue?.message ?? "주문 요청 값이 올바르지 않습니다.",
tradingEnv,
});
}
const body = parsed.data;
const accountParts = parseKisAccountParts(
body.accountNo,
body.accountProductCode,
);
if (!accountParts) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
tradingEnv,
});
}
const output = await executeOrderCash(
@@ -71,8 +141,8 @@ export async function POST(request: NextRequest) {
orderType: body.orderType,
quantity: body.quantity,
price: body.price,
accountNo: body.accountNo,
accountProductCode: body.accountProductCode,
accountNo: accountParts.accountNo,
accountProductCode: accountParts.accountProductCode,
},
credentials,
);
@@ -88,31 +158,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json(response);
} catch (error) {
const message =
error instanceof Error
? error.message
: "주문 전송 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
},
{ status: 500 },
);
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "주문 전송 중 오류가 발생했습니다."),
tradingEnv,
});
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}

View File

@@ -5,15 +5,17 @@ import {
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/orderbook/route.ts
@@ -23,28 +25,32 @@ import {
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "symbol은 6자리 숫자여야 합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message: "KIS API 키 설정이 필요합니다.",
});
}
try {
@@ -95,28 +101,14 @@ export async function GET(request: NextRequest) {
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "호가 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "호가 조회 중 오류가 발생했습니다."),
});
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}
function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);

View File

@@ -1,14 +1,19 @@
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import {
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
parseDomesticKisSession,
} from "@/lib/kis/domestic-market-session";
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
/**
* @file app/api/kis/domestic/overview/route.ts
@@ -23,26 +28,33 @@ import {
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json({ error: "symbol은 6자리 숫자여야 합니다." }, { status: 400 });
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "symbol은 6자리 숫자여야 합니다.",
});
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
},
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.CREDENTIAL_REQUIRED,
message:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
});
}
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
@@ -71,28 +83,14 @@ export async function GET(request: NextRequest) {
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
return createKisApiErrorResponse({
status: 500,
code: KIS_API_ERROR_CODE.UPSTREAM_FAILURE,
message: toKisApiErrorMessage(error, "KIS 조회 중 오류가 발생했습니다."),
});
}
}
/**
* 요청 헤더에서 KIS 키를 읽어옵니다.
* @param headers 요청 헤더
* @returns credentials
*/
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(headers.get("x-kis-trading-env") ?? undefined);
return {
appKey,
appSecret,
tradingEnv,
};
}
function readSessionOverrideFromHeaders(headers: Headers) {
if (process.env.NODE_ENV === "production") return null;
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);

View File

@@ -5,6 +5,10 @@ import type {
KoreanStockIndexItem,
} from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
const SEARCH_LIMIT = 10;
@@ -29,7 +33,11 @@ const SEARCH_LIMIT = 10;
export async function GET(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
});
}
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.

View File

@@ -6,6 +6,11 @@ import {
} from "@/lib/kis/request";
import { revokeKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisRevokeResponse,
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidMessage,
tradingEnv,
});
}
try {
@@ -54,18 +55,11 @@ export async function POST(request: NextRequest) {
message,
} satisfies DashboardKisRevokeResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "API 토큰 폐기 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(error, "API 토큰 폐기 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
import { hasKisApiSession } from "@/app/api/kis/_session";
import { parseKisAccountParts } from "@/lib/kis/account";
@@ -6,13 +7,18 @@ import { kisGet } from "@/lib/kis/client";
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
import { validateKisCredentialInput } from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
interface KisProfileValidateRequestBody {
appKey?: string;
appSecret?: string;
tradingEnv?: string;
accountNo?: string;
}
const kisProfileValidateBodySchema = z.object({
appKey: z.string().trim().min(1, "앱 키를 입력해 주세요."),
appSecret: z.string().trim().min(1, "앱 시크릿을 입력해 주세요."),
tradingEnv: z.string().optional(),
accountNo: z.string().trim().min(1, "계좌번호를 입력해 주세요."),
});
interface BalanceValidationPreset {
inqrDvsn: "01" | "02";
@@ -50,34 +56,44 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv: fallbackTradingEnv,
message: "로그인이 필요합니다.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 401 },
);
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv: fallbackTradingEnv,
});
}
let body: KisProfileValidateRequestBody = {};
let rawBody: unknown = {};
try {
body = (await request.json()) as KisProfileValidateRequestBody;
rawBody = (await request.json()) as unknown;
} catch {
return NextResponse.json(
{
ok: false,
tradingEnv: fallbackTradingEnv,
message: "요청 본문(JSON)을 읽을 수 없습니다.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: "요청 본문(JSON)을 읽을 수 없습니다.",
tradingEnv: fallbackTradingEnv,
});
}
const parsedBody = kisProfileValidateBodySchema.safeParse(rawBody);
if (!parsedBody.success) {
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message:
parsedBody.error.issues[0]?.message ??
"요청 본문 값이 올바르지 않습니다.",
tradingEnv: fallbackTradingEnv,
});
}
const body = parsedBody.data;
const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
appKey: body.appKey.trim(),
appSecret: body.appSecret.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
@@ -85,39 +101,25 @@ export async function POST(request: NextRequest) {
const invalidCredentialMessage = validateKisCredentialInput(credentials);
if (invalidCredentialMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidCredentialMessage,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidCredentialMessage,
tradingEnv,
});
}
const accountNoInput = (body.accountNo ?? "").trim();
if (!accountNoInput) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "계좌번호를 입력해 주세요.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
}
const accountNoInput = body.accountNo.trim();
const accountParts = parseKisAccountParts(accountNoInput);
if (!accountParts) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED,
message:
"계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
tradingEnv,
});
}
try {
@@ -150,19 +152,12 @@ export async function POST(request: NextRequest) {
},
} satisfies DashboardKisProfileValidateResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "계좌 검증 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(error, "계좌 검증 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

@@ -6,6 +6,11 @@ import {
} from "@/lib/kis/request";
import { getKisAccessToken } from "@/lib/kis/token";
import { hasKisApiSession } from "@/app/api/kis/_session";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisValidateResponse,
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidMessage,
tradingEnv,
});
}
try {
@@ -54,18 +55,11 @@ export async function POST(request: NextRequest) {
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "API 키 검증 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(error, "API 키 검증 중 오류가 발생했습니다."),
tradingEnv,
});
}
}

View File

@@ -6,6 +6,11 @@ import {
parseKisCredentialRequest,
validateKisCredentialInput,
} from "@/lib/kis/request";
import {
createKisApiErrorResponse,
KIS_API_ERROR_CODE,
toKisApiErrorMessage,
} from "@/app/api/kis/_response";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -23,26 +28,22 @@ export async function POST(request: NextRequest) {
const hasSession = await hasKisApiSession();
if (!hasSession) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: "로그인이 필요합니다.",
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.AUTH_REQUIRED,
message: "로그인이 필요합니다.",
tradingEnv,
});
}
const invalidMessage = validateKisCredentialInput(credentials);
if (invalidMessage) {
return NextResponse.json(
{
ok: false,
tradingEnv,
message: invalidMessage,
} satisfies DashboardKisWsApprovalResponse,
{ status: 400 },
);
return createKisApiErrorResponse({
status: 400,
code: KIS_API_ERROR_CODE.INVALID_REQUEST,
message: invalidMessage,
tradingEnv,
});
}
try {
@@ -57,18 +58,14 @@ export async function POST(request: NextRequest) {
message: "웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse);
} catch (error) {
const message =
error instanceof Error
? error.message
: "웹소켓 승인키 발급 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv,
message,
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
return createKisApiErrorResponse({
status: 401,
code: KIS_API_ERROR_CODE.UNAUTHORIZED,
message: toKisApiErrorMessage(
error,
"웹소켓 승인키 발급 중 오류가 발생했습니다.",
),
tradingEnv,
});
}
}