@
docs(phase3a): 음악 공개화 구현 플랜 (7 Task) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ @
This commit is contained in:
436
docs/superpowers/plans/2026-07-03-phase3a-music-public.md
Normal file
436
docs/superpowers/plans/2026-07-03-phase3a-music-public.md
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
# Phase 3a 음악 서비스 공개화 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 숨김 상태의 Suno 음악 스튜디오를 공개·무료화하고 "스토리→음악"(Gemini) 흐름·회원 저장·라이트 디자인을 붙인다.
|
||||||
|
|
||||||
|
**Architecture:** 사주·타로의 "공개+무료+로그인 저장+일일제한" 패턴을 음악에 적용. Gemini가 스토리→가사/스타일 변환, Suno가 음악 생성(폴링), 완료 시 회원 저장. 음악 페이지는 --jsm 라이트로 재스킨.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 (App Router, TS), Tailwind v4(`--jsm-*`), Supabase, @google/generative-ai, Suno API, vitest
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-03-phase3a-music-public-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **순수 시각 변경 태스크**에서는 로직 라인 미변경(className/style만); **API 태스크**에서는 인증→제한→호출→(성공)recordUsage 순서 준수
|
||||||
|
- 신규 색 토큰 금지 — 11개 `--jsm-*`만. 음악 신규/재스킨 파일에서 `gradient`/`violet`/`purple`/`blur`/이모지 **0건**
|
||||||
|
- 일일 제한: `MUSIC_DAILY_LIMIT = 1`. 생성(Suno) 성공 시에만 `recordUsage('music')`. story(Gemini) 단계는 인증만, 미집계
|
||||||
|
- GEMINI_API_KEY/SUNO_API_KEY 미설정 시 각각 503(예시 폴백 금지)
|
||||||
|
- `ai_usage_log` CHECK ALTER는 **phase2-saju-tarot 마이그 DB 적용 후** 실행 전제(플랜/CEO 안내에 명시)
|
||||||
|
- next.config.ts 수정 금지, 기존 supabase/migrations/ 파일 수정 금지(신규만)
|
||||||
|
- 커밋은 스코프 파일만 — **`git add -A`·`git commit -a` 금지**, 커밋 전 `git status` 확인
|
||||||
|
- 각 Task 종료 시 `npm run build` 성공 + `npm test`(30→) 유지 후 커밋
|
||||||
|
- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
||||||
|
|
||||||
|
## 확인된 기존 계약
|
||||||
|
|
||||||
|
- `POST /api/studio/generate`(무인증): body `{mode:'simple'|'custom', prompt?, title?, lyrics?, tags?, make_instrumental?, model?}` → Suno `/api/v1/generate` → `{ ok, data }`. `callBackUrl=${origin}/api/studio/callback`(부재)
|
||||||
|
- `GET /api/studio/status?taskId=`(무인증) → Suno record-info `{ ok, data }`
|
||||||
|
- `lib/ai-usage.ts`: `AiService='saju'|'tarot'`, `kstDayStartISO`, `getTodayUsage(admin,userId,service)`, `recordUsage(admin,userId,service)`
|
||||||
|
- supabase 헬퍼: `createClient()`(세션·RLS), `createAdminClient()`(service role). 타로 prompt 방어 패턴: `app/api/tarot/interpret/route.ts`, `lib/tarot/prompt.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: ai-usage 확장 + DB 마이그레이션
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/ai-usage.ts`
|
||||||
|
- Create: `supabase/migrations/2026-07-03-phase3a-music.sql`
|
||||||
|
- Modify: `lib/__tests__/ai-usage.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `AiService = 'saju'|'tarot'|'music'`, `MUSIC_DAILY_LIMIT = 1`, `music_tracks` 테이블. Task 3·4·6이 소비
|
||||||
|
|
||||||
|
- [ ] **Step 1: 테스트에 music 상수 추가**
|
||||||
|
|
||||||
|
`lib/__tests__/ai-usage.test.ts`의 상수 검증 `it`에 추가(기존 테스트 유지):
|
||||||
|
```typescript
|
||||||
|
import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT, MUSIC_DAILY_LIMIT } from '../ai-usage';
|
||||||
|
// ... 기존 KST 테스트 유지 ...
|
||||||
|
it('음악 일일 제한 상수', () => {
|
||||||
|
expect(MUSIC_DAILY_LIMIT).toBe(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인** — `npx vitest run lib/__tests__/ai-usage.test.ts` → FAIL(MUSIC_DAILY_LIMIT 없음)
|
||||||
|
|
||||||
|
- [ ] **Step 3: ai-usage.ts 확장**
|
||||||
|
|
||||||
|
`lib/ai-usage.ts`:
|
||||||
|
```typescript
|
||||||
|
export const SAJU_DAILY_LIMIT = 1;
|
||||||
|
export const TAROT_DAILY_LIMIT = 3;
|
||||||
|
export const MUSIC_DAILY_LIMIT = 1;
|
||||||
|
export type AiService = 'saju' | 'tarot' | 'music';
|
||||||
|
```
|
||||||
|
(getTodayUsage/recordUsage/kstDayStartISO 본문 무변경 — AiService 타입만 확장되어 'music' 허용)
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** — `npx vitest run lib/__tests__/ai-usage.test.ts` → PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: 마이그레이션 파일**
|
||||||
|
|
||||||
|
`supabase/migrations/2026-07-03-phase3a-music.sql`:
|
||||||
|
```sql
|
||||||
|
-- Phase 3a (2026-07-03): 음악 회원 저장 + 사용량 로그 확장 + 음악 숨김 해제
|
||||||
|
-- 의존성: 2026-07-02-phase2-saju-tarot.sql(ai_usage_log 생성) 적용 후 실행
|
||||||
|
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS music_tracks (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
title text,
|
||||||
|
story text,
|
||||||
|
lyrics text,
|
||||||
|
style text,
|
||||||
|
audio_url text,
|
||||||
|
task_id text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
ALTER TABLE music_tracks ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY music_select_own ON music_tracks FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ai_usage_log CHECK에 'music' 추가 (phase2의 인라인 CHECK auto-name 제거 후 재정의)
|
||||||
|
ALTER TABLE ai_usage_log DROP CONSTRAINT IF EXISTS ai_usage_log_service_check;
|
||||||
|
ALTER TABLE ai_usage_log ADD CONSTRAINT ai_usage_log_service_check CHECK (service IN ('saju','tarot','music'));
|
||||||
|
|
||||||
|
DELETE FROM service_settings WHERE id = 'music';
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 검증·커밋** — `npm test && npm run build` PASS. `git add lib/ai-usage.ts lib/__tests__/ai-usage.test.ts supabase/migrations/2026-07-03-phase3a-music.sql && git commit -m "feat(phase3a): ai-usage에 music 추가 + music_tracks·CHECK 마이그레이션"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 음악 공개화 (가드 제거)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/music/layout.tsx`
|
||||||
|
- Modify: `lib/service-visibility.ts`
|
||||||
|
- Modify: `app/api/admin/services/route.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: 없음
|
||||||
|
- Produces: `/music*` 공개
|
||||||
|
|
||||||
|
- [ ] **Step 1: layout 가드 제거**
|
||||||
|
|
||||||
|
`app/music/layout.tsx`: `isServiceVisible`/`notFound` import·호출 제거, metadata 있으면 유지, 단순 `return <>{children}</>`. (파일 먼저 Read — metadata 유무 확인)
|
||||||
|
|
||||||
|
- [ ] **Step 2: HideableService에서 music 제거**
|
||||||
|
|
||||||
|
`lib/service-visibility.ts`: `export type HideableService = 'gyeol' | 'lotto';`
|
||||||
|
|
||||||
|
- [ ] **Step 3: DEFAULT_SERVICES music 행 제거**
|
||||||
|
|
||||||
|
`app/api/admin/services/route.ts` DEFAULT_SERVICES에서 `{ id: 'music', ... }` 한 줄 삭제(gyeol/lotto 유지). (service_settings music DELETE는 Task 1 마이그레이션이 담당)
|
||||||
|
|
||||||
|
- [ ] **Step 4: 검증·커밋** — `npm test && npm run build`(빌드 라우트에 /music이 static/공개로 등장). `git add app/music/layout.tsx lib/service-visibility.ts app/api/admin/services/route.ts && git commit -m "feat(phase3a): 음악 서비스 공개화 — 가드·HideableService·DEFAULT_SERVICES 정리"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 스토리→음악 (story-prompt + story API + generate 인증/제한 + callback)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/music/story-prompt.ts`
|
||||||
|
- Create: `app/api/studio/story/route.ts`
|
||||||
|
- Create: `app/api/studio/callback/route.ts`
|
||||||
|
- Modify: `app/api/studio/generate/route.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `getTodayUsage`/`recordUsage`/`MUSIC_DAILY_LIMIT`(T1), `createClient`/`createAdminClient`
|
||||||
|
- Produces:
|
||||||
|
- `type MusicStory = { title: string; lyrics: string; style: string; mood: string }`
|
||||||
|
- `POST /api/studio/story` (로그인) → 200 `{ story: MusicStory }` / 401 / 503
|
||||||
|
- `POST /api/studio/generate` (로그인+제한) → 기존 `{ ok, data }` / 401 / 429 / 503
|
||||||
|
- `POST /api/studio/callback` → `{ ok: true }`
|
||||||
|
- Task 6이 소비
|
||||||
|
|
||||||
|
- [ ] **Step 1: story-prompt 모듈** (타로 prompt.ts 방어 패턴 포팅)
|
||||||
|
|
||||||
|
`lib/music/story-prompt.ts`:
|
||||||
|
```typescript
|
||||||
|
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을 생성하세요.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateStory(obj: unknown): string | null {
|
||||||
|
if (!obj || typeof obj !== 'object') return 'not an object';
|
||||||
|
const o = obj as Record<string, unknown>;
|
||||||
|
for (const k of ['title', 'lyrics', 'style', 'mood']) {
|
||||||
|
if (typeof o[k] !== 'string' || !(o[k] as string).trim()) return `${k} 누락`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: story API** (타로 interpret의 Gemini 폴백·45s 가드·reroll 패턴)
|
||||||
|
|
||||||
|
`app/api/studio/story/route.ts` — `app/api/tarot/interpret/route.ts`를 참고해 동일 SDK 사용법으로:
|
||||||
|
```typescript
|
||||||
|
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';
|
||||||
|
export const maxDuration = 60;
|
||||||
|
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
|
||||||
|
|
||||||
|
const MODELS = [{ id: 'gemini-2.5-pro', maxTokens: 8192 }, { id: 'gemini-2.5-flash', maxTokens: 8192 }, { id: 'gemini-2.0-flash', maxTokens: 8192 }];
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
const TIME_BUDGET_MS = 45_000;
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
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 });
|
||||||
|
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY;
|
||||||
|
if (!apiKey) return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
|
||||||
|
const genAI = new GoogleGenerativeAI(apiKey);
|
||||||
|
const userMsg = buildStoryUserMessage(story);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let attempts = 0; let feedback = '';
|
||||||
|
for (const m of MODELS) {
|
||||||
|
for (let retry = 0; retry < 2; retry += 1) {
|
||||||
|
if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) break;
|
||||||
|
attempts += 1;
|
||||||
|
try {
|
||||||
|
const model = genAI.getGenerativeModel({ model: m.id, systemInstruction: STORY_SYSTEM_PROMPT, generationConfig: { temperature: 0.9, topP: 0.95, maxOutputTokens: m.maxTokens } });
|
||||||
|
const prompt = feedback ? `${userMsg}\n\n[이전 오류: ${feedback}] 스키마를 지켜 다시 출력하세요.` : userMsg;
|
||||||
|
const res = await model.generateContent(prompt);
|
||||||
|
const parsed = parseStoryJson(res.response.text());
|
||||||
|
const invalid = parsed ? validateStory(parsed) : 'JSON 파싱 실패';
|
||||||
|
if (parsed && !invalid) return NextResponse.json({ story: parsed });
|
||||||
|
feedback = invalid ?? 'JSON 파싱 실패';
|
||||||
|
} catch (e) { feedback = e instanceof Error ? e.message : 'model error'; break; }
|
||||||
|
}
|
||||||
|
if (attempts >= MAX_ATTEMPTS) break;
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: '가사 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' }, { status: 502 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(작성 전 `app/api/tarot/interpret/route.ts`를 Read해 실제 SDK 시그니처와 일치시킬 것)
|
||||||
|
|
||||||
|
- [ ] **Step 3: callback 최소 라우트**
|
||||||
|
|
||||||
|
`app/api/studio/callback/route.ts`:
|
||||||
|
```typescript
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
// Suno webhook 수신용 최소 엔드포인트. 회원 저장은 폴링+클라 트리거(/api/studio/tracks)가 담당하므로 여기선 200만.
|
||||||
|
export async function POST() { return NextResponse.json({ ok: true }); }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: generate에 인증 + 일일제한**
|
||||||
|
|
||||||
|
`app/api/studio/generate/route.ts` POST 최상단(Suno 키 체크 전 또는 직후)에 인증·제한 추가:
|
||||||
|
```typescript
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
|
import { getTodayUsage, recordUsage, MUSIC_DAILY_LIMIT } from '@/lib/ai-usage';
|
||||||
|
// POST 시작부:
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||||
|
const admin = createAdminClient();
|
||||||
|
if ((await getTodayUsage(admin, user.id, 'music')) >= MUSIC_DAILY_LIMIT) {
|
||||||
|
return NextResponse.json({ error: `오늘 음악 생성을 모두 사용했습니다. (${MUSIC_DAILY_LIMIT}회/일)` }, { status: 429 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
그리고 Suno task 생성이 성공 반환(`return NextResponse.json({ ok: true, data })`)되기 **직전**에 `await recordUsage(admin, user.id, 'music');` 추가. (503/502/400 실패 경로엔 넣지 않음)
|
||||||
|
|
||||||
|
- [ ] **Step 5: 검증·커밋** — `npm test && npm run build`(라우트 /api/studio/story·/callback 등장). `git add lib/music/story-prompt.ts app/api/studio/story/route.ts app/api/studio/callback/route.ts app/api/studio/generate/route.ts && git commit -m "feat(phase3a): 스토리→가사(Gemini) + generate 인증·일일제한 + callback 정리"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 음악 저장·조회 API
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/api/studio/tracks/route.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `createClient`/`createAdminClient`, `music_tracks`(T1)
|
||||||
|
- Produces:
|
||||||
|
- `POST /api/studio/tracks` (로그인) body `{ title?, story?, lyrics?, style?, audio_url?, task_id? }` → `{ id, created_at }` / 401
|
||||||
|
- `GET /api/studio/tracks` (로그인) → `{ tracks: [{ id, title, story, lyrics, style, audio_url, task_id, created_at }] }` / 401
|
||||||
|
- Task 6이 소비
|
||||||
|
|
||||||
|
- [ ] **Step 1: 구현** (타로 readings 패턴)
|
||||||
|
|
||||||
|
`app/api/studio/tracks/route.ts`:
|
||||||
|
```typescript
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try { body = await request.json(); } catch { return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 }); }
|
||||||
|
const str = (k: string) => (typeof body[k] === 'string' ? (body[k] as string) : null);
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { data, error } = await admin.from('music_tracks').insert({
|
||||||
|
user_id: user.id,
|
||||||
|
title: str('title'), story: str('story'), lyrics: str('lyrics'),
|
||||||
|
style: str('style'), audio_url: str('audio_url'), task_id: str('task_id'),
|
||||||
|
}).select('id, created_at').single();
|
||||||
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('music_tracks')
|
||||||
|
.select('id, title, story, lyrics, style, audio_url, task_id, created_at')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
return NextResponse.json({ tracks: data ?? [] });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 검증·커밋** — `npm test && npm run build`. `git add app/api/studio/tracks/route.ts && git commit -m "feat(phase3a): 음악 트랙 저장·조회 API (user_id + RLS)"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: music/page·samples 라이트 재스킨
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/music/page.tsx` (72줄)
|
||||||
|
- Modify: `app/music/samples/page.tsx` (102줄)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: 없음
|
||||||
|
- Produces: 없음
|
||||||
|
|
||||||
|
- [ ] **Step 1: 두 파일 Read 후 --jsm 치환**
|
||||||
|
|
||||||
|
색상 매핑(사주 재스킨과 동일): 다크 hex/`gradient`/`violet`/`purple`/`blur`/amber → `--jsm-navy`(밴드 플랫)/`accent`/`accent-soft`/`surface`/`line`/`ink`. 이모지 있으면 제거 또는 인라인 SVG. **로직·데이터 조회·JSX 구조 미변경.** navy 밴드=무테두리 flat + 흰 CTA 관용구.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 게이트·검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -nE "gradient|violet|purple|blur" app/music/page.tsx app/music/samples/page.tsx # 0
|
||||||
|
npm run build && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋** — `git add app/music/page.tsx app/music/samples/page.tsx && git commit -m "feat(phase3a): 음악 랜딩·샘플 라이트 재스킨"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: studio 라이트 재스킨 + 스토리 UI 흐름
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/music/studio/page.tsx` (543줄)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `/api/studio/story`(T3), `/api/studio/generate`(T3), `/api/studio/status`(기존), `/api/studio/tracks`(T4)
|
||||||
|
- Produces: 없음
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read 후 라이트 재스킨 + 스토리 흐름 재구성**
|
||||||
|
|
||||||
|
`app/music/studio/page.tsx` 전체 Read. 두 가지 동시:
|
||||||
|
1. **라이트 재스킨**: 다크/gradient/violet/purple/amber/이모지 → --jsm 토큰(사주 스튜디오 아님 — 신규 라이트). 폼 필드는 라이트 관용구(`bg-white`+`border-[var(--jsm-line)]`+`focus:border-[var(--jsm-accent)]`)
|
||||||
|
2. **스토리 UI 흐름**(기존 prompt/lyrics 직접입력 → 스토리 우선 흐름으로 확장, 기존 custom/simple 모드는 "직접 입력" 탭으로 보존 가능):
|
||||||
|
- ①스토리 textarea + "가사 만들기" → `POST /api/studio/story` → 401이면 로그인 CTA(`/login?next=/music/studio`), 503/502면 안내
|
||||||
|
- ②반환된 `{title, lyrics, style, mood}` 미리보기(편집 가능한 필드)
|
||||||
|
- ③"음악 만들기" → `POST /api/studio/generate`(custom 모드, title/lyrics/tags=style) → 429면 제한 안내
|
||||||
|
- ④기존 `status` 폴링 로직 유지 → 완료 시 오디오 URL 표시(플레이어)
|
||||||
|
- ⑤완료+로그인 시 `POST /api/studio/tracks`로 자동 저장(best-effort, 실패해도 재생 유지)
|
||||||
|
- 디자인 가드레일: gradient/blur/보라/이모지 0건
|
||||||
|
|
||||||
|
- [ ] **Step 2: 게이트·검증**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -nE "gradient|violet|purple|blur" app/music/studio/page.tsx # 0
|
||||||
|
npm run build && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 커밋** — `git add app/music/studio/page.tsx && git commit -m "feat(phase3a): 음악 스튜디오 라이트 재스킨 + 스토리→음악 흐름"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: TopNav + 마이페이지 AI기록 음악 + CLAUDE.md + 최종 검증
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/TopNav.tsx`
|
||||||
|
- Modify: `app/mypage/page.tsx`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `/api/studio/tracks`(T4)
|
||||||
|
- Produces: 문서·네비 정합
|
||||||
|
|
||||||
|
- [ ] **Step 1: TopNav 링크**
|
||||||
|
|
||||||
|
`app/components/TopNav.tsx` LINKS에 `{ href: '/music', label: '음악' }` 추가(외주/소프트웨어/제작사례/사주/타로/음악 = 6링크). 다른 항목 무변.
|
||||||
|
|
||||||
|
- [ ] **Step 2: 마이페이지 AI 기록 탭에 음악 통합**
|
||||||
|
|
||||||
|
`app/mypage/page.tsx`의 'AI 기록' 탭(사주·타로 병합 리스트)에 음악 트랙 추가:
|
||||||
|
- 타입 `type MusicTrackRow = { id: string; title: string | null; story: string | null; audio_url: string | null; created_at: string }` 추가
|
||||||
|
- state·로드: `loadAiRecords`에 `fetch('/api/studio/tracks')` 추가(try/catch, `{ tracks }`)
|
||||||
|
- 렌더: 병합 내림차순에 음악 카드 추가(제목·스토리 요약·오디오 링크/`<audio>`), 빈 상태 CTA에 `/music` 추가
|
||||||
|
- **기존 사주·타로 렌더·로직 미변경, 음악만 추가**
|
||||||
|
|
||||||
|
- [ ] **Step 3: CLAUDE.md 갱신**
|
||||||
|
- 핵심 IA 표에 `/music`(공개 음악 — 스토리→음악) 추가
|
||||||
|
- 숨김 서비스 표에서 `/music/*` 행 제거
|
||||||
|
- 파일 구조에 `lib/music/`, `api/studio/{story,tracks,callback}` 반영
|
||||||
|
- `/mypage` 탭 서술에 음악 포함(AI 기록: 사주·타로·음악)
|
||||||
|
|
||||||
|
- [ ] **Step 4: 최종 검증**
|
||||||
|
```bash
|
||||||
|
grep -rnE "gradient|violet|purple|blur" app/music --include="*.tsx" # 0
|
||||||
|
npm run build # /music·/music/studio·/music/samples·/api/studio/{story,tracks,callback} 라우트 존재
|
||||||
|
npm test # 30+ PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 커밋** — `git add app/components/TopNav.tsx app/mypage/page.tsx CLAUDE.md && git commit -m "feat(phase3a): TopNav 음악 + 마이페이지 AI기록 음악 통합 + CLAUDE.md"`
|
||||||
|
|
||||||
|
- [ ] **Step 6: CEO 안내(보고)**
|
||||||
|
- 마이그레이션 `2026-07-03-phase3a-music.sql`을 클라우드+NAS 양쪽 적용(**phase2 마이그 적용 후**)
|
||||||
|
- `SUNO_API_KEY`·`GEMINI_API_KEY` 운영 설정 확인(미설정 시 각 503)
|
||||||
|
- 수동 E2E: 비로그인 /music/studio → 스토리 입력→가사→로그인 CTA→로그인 후 생성(일1회)→저장→마이페이지 AI기록 음악
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 요약
|
||||||
|
|
||||||
|
| 검증 | 명령 | 기대 |
|
||||||
|
|------|------|------|
|
||||||
|
| 음악 가드레일 | `grep -rnE "gradient\|violet\|purple\|blur" app/music --include="*.tsx"` | 0건 |
|
||||||
|
| 단위 테스트 | `npm test` | ai-usage music 포함 전체 PASS |
|
||||||
|
| 빌드 | `npm run build` | /music·studio·samples·api/studio/{story,tracks,callback} |
|
||||||
Reference in New Issue
Block a user