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"; 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"; 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"; 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 createKisApiErrorResponse({ status: 401, code: KIS_API_ERROR_CODE.AUTH_REQUIRED, message: "로그인이 필요합니다.", tradingEnv: fallbackTradingEnv, }); } 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: 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(), tradingEnv: normalizeTradingEnv(body.tradingEnv), }; const tradingEnv = normalizeTradingEnv(credentials.tradingEnv); const invalidCredentialMessage = validateKisCredentialInput(credentials); if (invalidCredentialMessage) { return createKisApiErrorResponse({ status: 400, code: KIS_API_ERROR_CODE.INVALID_REQUEST, message: invalidCredentialMessage, tradingEnv, }); } const accountNoInput = body.accountNo.trim(); const accountParts = parseKisAccountParts(accountNoInput); if (!accountParts) { return createKisApiErrorResponse({ status: 400, code: KIS_API_ERROR_CODE.ACCOUNT_REQUIRED, message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.", tradingEnv, }); } 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) { return createKisApiErrorResponse({ status: 400, code: KIS_API_ERROR_CODE.UNAUTHORIZED, message: toKisApiErrorMessage(error, "계좌 검증 중 오류가 발생했습니다."), tradingEnv, }); } } /** * @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( "/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 : "알 수 없는 오류가 발생했습니다."; }