From 2aa424f3ce5c9f87f4fec8bf453b70e2cc8c7f8c Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 12:56:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(phase3a):=20=EC=8A=A4=ED=86=A0=EB=A6=AC?= =?UTF-8?q?=E2=86=92=EA=B0=80=EC=82=AC(Gemini)=20+=20generate=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=C2=B7=EC=9D=BC=EC=9D=BC=EC=A0=9C=ED=95=9C=20+=20callb?= =?UTF-8?q?ack=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/music/story-prompt.ts: MusicStory 스키마 + Gemini 응답 파싱/검증(타로 prompt.ts 방어 패턴 포팅) - app/api/studio/story/route.ts: 로그인 인증 후 Gemini 모델 폴백(2.5-pro→2.5-flash→2.0-flash)으로 가사 JSON 생성. 일일 사용량은 미집계(생성 확정 전 초안 단계) - app/api/studio/callback/route.ts: Suno webhook 수신용 최소 200 응답 엔드포인트 - app/api/studio/generate/route.ts: 인증(401) + 일일 제한(429, MUSIC_DAILY_LIMIT) 추가, Suno 생성 성공 시에만 recordUsage('music') 기록 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/studio/callback/route.ts | 9 +++ app/api/studio/generate/route.ts | 21 ++++++ app/api/studio/story/route.ts | 115 +++++++++++++++++++++++++++++++ lib/music/story-prompt.ts | 45 ++++++++++++ 4 files changed, 190 insertions(+) create mode 100644 app/api/studio/callback/route.ts create mode 100644 app/api/studio/story/route.ts create mode 100644 lib/music/story-prompt.ts diff --git a/app/api/studio/callback/route.ts b/app/api/studio/callback/route.ts new file mode 100644 index 0000000..3c20312 --- /dev/null +++ b/app/api/studio/callback/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +// Suno webhook 수신용 최소 엔드포인트. +// 트랙 저장은 폴링 + 클라이언트 트리거(/api/studio/tracks)가 담당하므로 여기서는 200만 반환한다. +export async function POST() { + return NextResponse.json({ ok: true }); +} diff --git a/app/api/studio/generate/route.ts b/app/api/studio/generate/route.ts index c6b7c1e..c775dc8 100644 --- a/app/api/studio/generate/route.ts +++ b/app/api/studio/generate/route.ts @@ -1,4 +1,7 @@ import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { getTodayUsage, recordUsage, MUSIC_DAILY_LIMIT } from '@/lib/ai-usage'; export const runtime = 'nodejs'; @@ -13,6 +16,23 @@ type GenerateBody = { }; export async function POST(request: Request) { + // 1) 인증 — 로그인 사용자만 (Suno API 무단 호출 방지) + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 }); + } + + // 2) 일일 제한 + const admin = createAdminClient(); + const used = await getTodayUsage(admin, user.id, 'music'); + if (used >= MUSIC_DAILY_LIMIT) { + return NextResponse.json( + { error: `오늘 음악 생성을 모두 사용했습니다. (${MUSIC_DAILY_LIMIT}회/일)` }, + { status: 429 } + ); + } + const apiUrl = process.env.SUNO_API_URL ?? 'https://api.sunoapi.org'; const apiKey = process.env.SUNO_API_KEY; @@ -69,6 +89,7 @@ export async function POST(request: Request) { { status: res.ok ? 502 : res.status }, ); } + await recordUsage(admin, user.id, 'music'); return NextResponse.json({ ok: true, data }); } catch (e) { return NextResponse.json( diff --git a/app/api/studio/story/route.ts b/app/api/studio/story/route.ts new file mode 100644 index 0000000..5834dcc --- /dev/null +++ b/app/api/studio/story/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from 'next/server'; +import { GoogleGenerativeAI } from '@google/generative-ai'; +import { createClient } from '@/lib/supabase/server'; +import { + STORY_SYSTEM_PROMPT, + buildStoryUserMessage, + parseStoryJson, + validateStory, +} from '@/lib/music/story-prompt'; +import { config as loadDotenv } from 'dotenv'; +import { resolve } from 'path'; + +export const runtime = 'nodejs'; +// Vercel 최대 타임아웃 (Pro plan 300s, Hobby 60s) +export const maxDuration = 60; + +// Next.js가 env 로드를 놓치는 경우 대비해 직접 로드 (Windows 환경 대응) +loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true }); + +// 모델 우선순위 — 사주 analyze·타로 interpret와 동일 폴백 목록(이 API 키로 접근 가능한 모델만) +const MODELS = [ + { id: 'gemini-2.5-pro', maxTokens: 8192 }, + { id: 'gemini-2.5-flash', maxTokens: 8192 }, + { id: 'gemini-2.0-flash', maxTokens: 8192 }, +] as const; + +// wall-clock 예산 — maxDuration(60s)보다 여유 있게 끊어 graceful 502를 반환 +const TIME_BUDGET_MS = 45_000; +// 최악 호출 수 상한 — 모델 폴백 × 검증 실패 reroll을 합쳐도 이 값을 넘지 않음 +const MAX_ATTEMPTS = 3; + +export async function POST(request: Request) { + // 1) 인증 — 로그인 사용자만 (Gemini API 무단 호출 방지) + // 일일 사용량 집계·제한은 generate 단계에서만 수행 — story는 가사 초안 생성일 뿐이라 미집계. + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 }); + } + + // 2) 입력 검증 + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: '잘못된 요청 형식입니다.' }, { status: 400 }); + } + const story = typeof body.story === 'string' ? body.story.trim() : ''; + if (!story) { + return NextResponse.json({ error: '이야기를 입력해주세요.' }, { status: 400 }); + } + + // 3) API 키 + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + console.warn('[스튜디오] GEMINI_API_KEY 미설정 — 503 반환 (예시 가사 반환 금지, 데이터 오염 방지)'); + return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 }); + } + const genAI = new GoogleGenerativeAI(apiKey); + const userMessage = buildStoryUserMessage(story); + + // 4) 호출 — 모델 폴백 + 검증 실패 시 같은 모델로 1회 reroll + // wall-clock 45s 예산과 총 호출 3회 상한으로 최악 케이스를 조기 종료(→ 502) + const startedAt = Date.now(); + let feedback = ''; + let attempts = 0; + + modelLoop: + for (const { id: modelId, maxTokens } of MODELS) { + // retry 0: 최초 시도, retry 1: 검증 실패 시에만 같은 모델로 1회 reroll + for (let retry = 0; retry < 2; retry += 1) { + if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) { + break modelLoop; + } + attempts += 1; + + try { + const model = genAI.getGenerativeModel({ + model: modelId, + systemInstruction: STORY_SYSTEM_PROMPT, + generationConfig: { + temperature: 0.9, + topP: 0.95, + maxOutputTokens: maxTokens, + }, + }); + + const prompt = feedback + ? `${userMessage}\n\n[이전 시도 오류: ${feedback}] 스키마를 정확히 지켜 다시 출력하세요.` + : userMessage; + + const result = await model.generateContent(prompt); + const text = result.response.text(); + const parsed = parseStoryJson(text); + const invalid = parsed ? validateStory(parsed) : 'JSON 파싱 실패'; + + if (parsed && !invalid) { + return NextResponse.json({ story: parsed }); + } + + // 검증 실패 — 사유를 피드백으로 주입해 같은 모델로 1회 reroll(retry 루프 계속) + feedback = invalid ?? 'JSON 파싱 실패'; + } catch (modelError) { + // 호출 자체의 예외(레이트리밋 등)는 reroll하지 않고 바로 다음 모델로 폴백 + feedback = modelError instanceof Error ? modelError.message : 'model error'; + break; + } + } + } + + return NextResponse.json( + { error: '가사 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' }, + { status: 502 } + ); +} diff --git a/lib/music/story-prompt.ts b/lib/music/story-prompt.ts new file mode 100644 index 0000000..56b34e0 --- /dev/null +++ b/lib/music/story-prompt.ts @@ -0,0 +1,45 @@ +// 스토리→가사 생성 프롬프트·스키마. +// lib/tarot/prompt.ts의 방어 패턴(코드블록 스트립 + {...} 추출 + 스키마 검증)을 음악 스토리용으로 포팅. + +export type MusicStory = { + title: string; + lyrics: string; + style: string; + mood: string; +}; + +export const STORY_SYSTEM_PROMPT = `당신은 사용자의 개인적 이야기를 노래로 바꾸는 작사가 겸 음악 프로듀서입니다. +사용자가 들려준 이야기를 바탕으로: +1. title: 노래 제목(짧고 인상적으로) +2. lyrics: 이야기의 감정과 장면을 담은 한국어 가사(절/후렴 구조, 6~16줄) +3. style: 어울리는 음악 장르·악기·템포를 영어 키워드로(Suno style, 예 "acoustic ballad, warm piano, mid tempo") +4. mood: 전체 정서를 한 단어로(예 "그리움", "희망") +반드시 코드블록 없이 순수 JSON만 출력합니다: {"title","lyrics","style","mood"} +사용자 이야기에 없는 사실을 지어내지 말고, 감정에 충실하게 각색합니다.`; + +export function buildStoryUserMessage(story: string): string { + return `사용자의 이야기:\n${story}\n\n위 이야기를 노래로 만들기 위한 JSON을 생성하세요.`; +} + +/** 코드블록 스트립 + {...} 추출 후 파싱. 실패 시 null */ +export function parseStoryJson(raw: string): MusicStory | null { + let text = raw.trim().replace(/^```(json)?/i, '').replace(/```$/, '').trim(); + const first = text.indexOf('{'); + const last = text.lastIndexOf('}'); + if (first >= 0 && last > first) text = text.slice(first, last + 1); + try { + return JSON.parse(text) as MusicStory; + } catch { + return null; + } +} + +/** 스키마 검증. 통과 못하면 사유 문자열, 통과면 null */ +export function validateStory(obj: unknown): string | null { + if (!obj || typeof obj !== 'object') return 'not an object'; + const o = obj as Record; + for (const k of ['title', 'lyrics', 'style', 'mood']) { + if (typeof o[k] !== 'string' || !(o[k] as string).trim()) return `${k} 누락`; + } + return null; +}