From 08ef577260b9f47b899629161037fa234e764ee0 Mon Sep 17 00:00:00 2001 From: sangwook yoo Date: Thu, 11 Jun 2026 10:07:01 +0900 Subject: [PATCH] =?UTF-8?q?fix(gemini):=20floor-plan=20API=20=EC=97=90=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=9D=B8=EC=A6=9D=C2=B7=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EB=B0=8B=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - iron-session 세션 검사 추가 — 페이지용 layout redirect 가 API 라우트를 보호하지 않아 익명 요청으로 Gemini 과금이 가능하던 문제 차단(401) - 사용자별 분당 5회 in-memory 레이트리밋(429) — 연타 업로드 과금 폭주 방지 Co-Authored-By: Claude Fable 5 --- src/app/api/gemini/floor-plan/route.js | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/app/api/gemini/floor-plan/route.js b/src/app/api/gemini/floor-plan/route.js index 6b50d5ae..a8a8b407 100644 --- a/src/app/api/gemini/floor-plan/route.js +++ b/src/app/api/gemini/floor-plan/route.js @@ -1,13 +1,33 @@ import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai' import { GoogleAIFileManager, FileState } from '@google/generative-ai/server' +import { sessionOptions } from '@/lib/session' import { logger } from '@/util/logger' const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB const MAX_VERTICES = 500 const DEFAULT_MODEL = 'gemini-3.1-pro-preview' +// Gemini 호출은 건당 과금이므로 사용자별 호출 빈도를 제한한다(프로세스 단위 in-memory). +const RATE_LIMIT_WINDOW_MS = 60 * 1000 +const RATE_LIMIT_MAX_REQUESTS = 5 +const rateLimitMap = new Map() + +const isRateLimited = (key) => { + const now = Date.now() + const recent = (rateLimitMap.get(key) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS) + if (recent.length >= RATE_LIMIT_MAX_REQUESTS) { + rateLimitMap.set(key, recent) + return true + } + recent.push(now) + rateLimitMap.set(key, recent) + return false +} + const FLOOR_PLAN_SCHEMA = { type: SchemaType.OBJECT, properties: { @@ -133,6 +153,18 @@ export async function POST(req) { const apiKey = process.env.GEMINI_API_KEY const modelName = process.env.GEMINI_MODEL || DEFAULT_MODEL + // 페이지 인증(layout redirect)은 API 라우트에 적용되지 않으므로 세션을 직접 검사한다 — 익명 Gemini 과금 차단. + const session = await getIronSession(cookies(), sessionOptions) + if (!session?.isLoggedIn) { + return NextResponse.json({ error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 }) + } + if (isRateLimited(session.userId || 'unknown')) { + return NextResponse.json( + { error: { code: 'RATE_LIMITED', message: '분석 요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.' } }, + { status: 429 }, + ) + } + if (!apiKey) { return NextResponse.json({ error: { code: 'NO_API_KEY', message: 'GEMINI_API_KEY 가 설정되지 않았습니다.' } }, { status: 500 }) }