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 }) }