253 lines
7.5 KiB
TypeScript
253 lines
7.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
|
|
import { hasKisApiSession } from "@/app/api/kis/_session";
|
|
import { parseKisAccountParts } from "@/lib/kis/account";
|
|
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";
|
|
|
|
interface KisProfileValidateRequestBody {
|
|
appKey?: string;
|
|
appSecret?: string;
|
|
tradingEnv?: string;
|
|
accountNo?: string;
|
|
}
|
|
|
|
interface BalanceValidationPreset {
|
|
inqrDvsn: "01" | "02";
|
|
prcsDvsn: "00" | "01";
|
|
}
|
|
|
|
const BALANCE_VALIDATION_PRESETS: BalanceValidationPreset[] = [
|
|
{
|
|
// 명세 기본 요청값
|
|
inqrDvsn: "01",
|
|
prcsDvsn: "01",
|
|
},
|
|
{
|
|
// 일부 계좌/환경 호환값
|
|
inqrDvsn: "02",
|
|
prcsDvsn: "00",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* @file app/api/kis/validate-profile/route.ts
|
|
* @description 한국투자증권 계좌번호를 검증합니다.
|
|
*/
|
|
|
|
/**
|
|
* @description 앱키/앱시크릿키 + 계좌번호 유효성을 검증합니다.
|
|
* @remarks UI 흐름: /settings -> KisProfileForm 확인 버튼 -> /api/kis/validate-profile -> store 반영 -> 대시보드 상태 확장
|
|
* @see features/settings/components/KisProfileForm.tsx 계좌 확인 버튼에서 호출합니다.
|
|
* @see features/settings/apis/kis-auth.api.ts validateKisProfile 클라이언트 API 함수
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
const fallbackTradingEnv = normalizeTradingEnv(
|
|
request.headers.get("x-kis-trading-env") ?? undefined,
|
|
);
|
|
|
|
const hasSession = await hasKisApiSession();
|
|
if (!hasSession) {
|
|
return NextResponse.json(
|
|
{
|
|
ok: false,
|
|
tradingEnv: fallbackTradingEnv,
|
|
message: "로그인이 필요합니다.",
|
|
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
let body: KisProfileValidateRequestBody = {};
|
|
|
|
try {
|
|
body = (await request.json()) as KisProfileValidateRequestBody;
|
|
} catch {
|
|
return NextResponse.json(
|
|
{
|
|
ok: false,
|
|
tradingEnv: fallbackTradingEnv,
|
|
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
|
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const credentials: KisCredentialInput = {
|
|
appKey: body.appKey?.trim(),
|
|
appSecret: body.appSecret?.trim(),
|
|
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
|
};
|
|
|
|
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
|
|
|
const invalidCredentialMessage = validateKisCredentialInput(credentials);
|
|
if (invalidCredentialMessage) {
|
|
return NextResponse.json(
|
|
{
|
|
ok: false,
|
|
tradingEnv,
|
|
message: invalidCredentialMessage,
|
|
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const accountNoInput = (body.accountNo ?? "").trim();
|
|
|
|
if (!accountNoInput) {
|
|
return NextResponse.json(
|
|
{
|
|
ok: false,
|
|
tradingEnv,
|
|
message: "계좌번호를 입력해 주세요.",
|
|
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
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 },
|
|
);
|
|
}
|
|
|
|
try {
|
|
// 1) 토큰 발급으로 앱키/시크릿 사전 검증
|
|
try {
|
|
await getKisAccessToken(credentials);
|
|
} catch (error) {
|
|
throw new Error(`앱키 검증 실패: ${toErrorMessage(error)}`);
|
|
}
|
|
|
|
// 2) 계좌 유효성 검증 (실제 계좌 조회 API)
|
|
try {
|
|
await validateAccountByBalanceApi(
|
|
accountParts.accountNo,
|
|
accountParts.accountProductCode,
|
|
credentials,
|
|
);
|
|
} catch (error) {
|
|
throw new Error(`계좌 검증 실패: ${toErrorMessage(error)}`);
|
|
}
|
|
|
|
const normalizedAccountNo = `${accountParts.accountNo}-${accountParts.accountProductCode}`;
|
|
|
|
return NextResponse.json({
|
|
ok: true,
|
|
tradingEnv,
|
|
message: "계좌번호 검증이 완료되었습니다.",
|
|
account: {
|
|
normalizedAccountNo,
|
|
},
|
|
} 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 },
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @description 계좌번호로 잔고 조회 API를 호출해 유효성을 확인합니다.
|
|
* @param accountNo 계좌번호 앞 8자리
|
|
* @param accountProductCode 계좌번호 뒤 2자리
|
|
* @param credentials KIS 인증 정보
|
|
* @see app/api/kis/validate-profile/route.ts POST
|
|
*/
|
|
async function validateAccountByBalanceApi(
|
|
accountNo: string,
|
|
accountProductCode: string,
|
|
credentials: KisCredentialInput,
|
|
) {
|
|
const trId = normalizeTradingEnv(credentials.tradingEnv) === "real" ? "TTTC8434R" : "VTTC8434R";
|
|
const attemptErrors: string[] = [];
|
|
|
|
for (const preset of BALANCE_VALIDATION_PRESETS) {
|
|
try {
|
|
const response = await kisGet<unknown>(
|
|
"/uapi/domestic-stock/v1/trading/inquire-balance",
|
|
trId,
|
|
{
|
|
CANO: accountNo,
|
|
ACNT_PRDT_CD: accountProductCode,
|
|
AFHR_FLPR_YN: "N",
|
|
OFL_YN: "",
|
|
INQR_DVSN: preset.inqrDvsn,
|
|
UNPR_DVSN: "01",
|
|
FUND_STTL_ICLD_YN: "N",
|
|
FNCG_AMT_AUTO_RDPT_YN: "N",
|
|
PRCS_DVSN: preset.prcsDvsn,
|
|
CTX_AREA_FK100: "",
|
|
CTX_AREA_NK100: "",
|
|
},
|
|
credentials,
|
|
);
|
|
|
|
validateInquireBalanceResponse(response);
|
|
return;
|
|
} catch (error) {
|
|
attemptErrors.push(
|
|
`INQR_DVSN=${preset.inqrDvsn}, PRCS_DVSN=${preset.prcsDvsn}: ${toErrorMessage(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`계좌 확인 요청이 모두 실패했습니다. ${attemptErrors.join(" | ")}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @description 주식잔고조회 응답 구조를 최소 검증합니다.
|
|
* @param response KIS 원본 응답
|
|
* @see app/api/kis/validate-profile/route.ts validateAccountByBalanceApi
|
|
*/
|
|
function validateInquireBalanceResponse(
|
|
response: {
|
|
output1?: unknown;
|
|
output2?: unknown;
|
|
},
|
|
) {
|
|
const output1Ok =
|
|
Array.isArray(response.output1) ||
|
|
(response.output1 !== null && typeof response.output1 === "object");
|
|
const output2Ok =
|
|
Array.isArray(response.output2) ||
|
|
(response.output2 !== null && typeof response.output2 === "object");
|
|
|
|
if (!output1Ok && !output2Ok) {
|
|
throw new Error("응답에 output1/output2가 없습니다. 요청 파라미터를 확인해 주세요.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @description Error 객체를 사용자 표시용 문자열로 변환합니다.
|
|
* @param error unknown 에러
|
|
* @returns 메시지 문자열
|
|
* @see app/api/kis/validate-profile/route.ts POST
|
|
*/
|
|
function toErrorMessage(error: unknown) {
|
|
return error instanceof Error
|
|
? error.message
|
|
: "알 수 없는 오류가 발생했습니다.";
|
|
}
|