feat(phase3a): TopNav 음악 + 마이페이지 AI기록 음악 통합 + CLAUDE.md

- TopNav LINKS에 /music(음악) 추가 — 외주/소프트웨어/제작사례/사주/타로/음악 6링크
- mypage AI 기록 탭에 음악 트랙 병합: MusicTrackRow 타입 + /api/studio/tracks 로드
  + MusicAiCard(제목·스토리 요약·<audio controls>) + 빈 상태 CTA에 /music 추가
  (기존 사주·타로 렌더·로직은 미변경)
- CLAUDE.md: /music 공개 전환 반영(숨김 서비스 표에서 제거), api/studio/{story,tracks,callback}
  · lib/music/story-prompt.ts 파일 구조 반영, /mypage 5탭 서술(AI 기록: 사주·타로·음악)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:29:51 +09:00
parent 39025fc57b
commit 468ee84687
3 changed files with 84 additions and 11 deletions

View File

@@ -21,10 +21,11 @@
| `/showcase` | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 | | `/showcase` | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 |
| `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) | | `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) |
| `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) | | `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) |
| `/music` | 공개 음악 — 스토리→음악 AI 스튜디오 (studio·samples, 로그인 시 생성·저장) |
| `/track/[token]` | 비회원 의뢰 진행 추적 | | `/track/[token]` | 비회원 의뢰 진행 추적 |
| `/quote/[token]` | 공개 견적 — 고객 수락/거절 | | `/quote/[token]` | 공개 견적 — 고객 수락/거절 |
| `/login` | 로그인 (`?next=` 리다이렉트 지원) | | `/login` | 로그인 (`?next=` 리다이렉트 지원) |
| `/mypage` | 4탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 | | `/mypage` | 5탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 / AI 기록(사주·타로·음악 병합) |
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 | | `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
## 숨김 서비스 (admin_token 세션 전용) ## 숨김 서비스 (admin_token 세션 전용)
@@ -33,7 +34,6 @@ admin/services 패널에서 ON/OFF 전환 가능.
| 경로 | 서비스 | | 경로 | 서비스 |
|------|--------| |------|--------|
| `/music/*` | 음악 팩 (단, `/music/packs``/products`로 308 리다이렉트) |
| `/gyeol` | CONTOUR PMF 설문 | | `/gyeol` | CONTOUR PMF 설문 |
## 기술 스택 ## 기술 스택
@@ -93,9 +93,12 @@ app/
saju/analyze/route.ts — 사주 AI 분석 (Gemini) saju/analyze/route.ts — 사주 AI 분석 (Gemini)
tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한) tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한)
tarot/readings/route.ts — 타로 리딩 저장·조회 (tarot_readings) tarot/readings/route.ts — 타로 리딩 저장·조회 (tarot_readings)
studio/story/route.ts — POST: 스토리→가사 생성 (Gemini, 로그인 필요)
studio/tracks/route.ts — GET/POST: 음악 트랙 저장·조회 (music_tracks, 본인 것만)
studio/callback/route.ts — POST: Suno webhook 수신용 최소 엔드포인트
work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일) work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일)
tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석) tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석)
music/ — 숨김: 음악 팩 (packs는 /products로 308) music/ — 공개: 스토리→음악 AI 스튜디오 (studio·samples, packs는 /products로 308)
gyeol/ — 숨김: CONTOUR PMF 설문 gyeol/ — 숨김: CONTOUR PMF 설문
lib/ lib/
@@ -116,6 +119,8 @@ lib/
shuffle.ts — 셔플·3카드 드로우 로직 shuffle.ts — 셔플·3카드 드로우 로직
reference.ts — 카드 의미 레퍼런스 reference.ts — 카드 의미 레퍼런스
prompt.ts — AI 해석 프롬프트 prompt.ts — AI 해석 프롬프트
music/
story-prompt.ts — 스토리→가사 AI 프롬프트 (시스템 프롬프트·JSON 파싱·검증)
``` ```
--- ---

View File

@@ -12,6 +12,7 @@ const LINKS = [
{ href: '/showcase', label: '제작 사례' }, { href: '/showcase', label: '제작 사례' },
{ href: '/work/saju', label: '사주' }, { href: '/work/saju', label: '사주' },
{ href: '/tarot', label: '타로' }, { href: '/tarot', label: '타로' },
{ href: '/music', label: '음악' },
]; ];
export default function TopNav() { export default function TopNav() {

View File

@@ -68,10 +68,20 @@ type SajuRecordRow = {
is_paid: boolean; is_paid: boolean;
}; };
// AI 기록 탭 — 사주·타로 병합 렌더용 판별 유니언 // AI 기록 탭 — 음악 트랙 (app/api/studio/tracks 응답)
type MusicTrackRow = {
id: string;
title: string | null;
story: string | null;
audio_url: string | null;
created_at: string;
};
// AI 기록 탭 — 사주·타로·음악 병합 렌더용 판별 유니언
type AiRecordItem = type AiRecordItem =
| { kind: 'saju'; data: SajuRecordRow } | { kind: 'saju'; data: SajuRecordRow }
| { kind: 'tarot'; data: TarotReadingRow }; | { kind: 'tarot'; data: TarotReadingRow }
| { kind: 'music'; data: MusicTrackRow };
interface Payment { interface Payment {
id: string; id: string;
@@ -152,9 +162,10 @@ function MyPageContent() {
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>(''); const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
const [showTelegramGuide, setShowTelegramGuide] = useState(false); const [showTelegramGuide, setShowTelegramGuide] = useState(false);
// AI 기록 탭 — 타로 리딩 / 사주 기록 // AI 기록 탭 — 타로 리딩 / 사주 기록 / 음악 트랙
const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]); const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]);
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]); const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
const [musicTracks, setMusicTracks] = useState<MusicTrackRow[]>([]);
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set()); const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set());
const loadProjects = useCallback(async () => { const loadProjects = useCallback(async () => {
@@ -166,7 +177,7 @@ function MyPageContent() {
} catch { /* 미로그인/네트워크 — 무시 */ } } catch { /* 미로그인/네트워크 — 무시 */ }
}, []); }, []);
// 사주·타로 결과 통합 로드 — 다 실패해도 서로 영향 없이 무시(best-effort) // 사주·타로·음악 결과 통합 로드 — 다 실패해도 서로 영향 없이 무시(best-effort)
const loadAiRecords = useCallback(async () => { const loadAiRecords = useCallback(async () => {
try { try {
const tr = await fetch('/api/tarot/readings'); const tr = await fetch('/api/tarot/readings');
@@ -184,6 +195,10 @@ function MyPageContent() {
setSajuRecords(data ?? []); setSajuRecords(data ?? []);
} }
} catch { /* 무시 */ } } catch { /* 무시 */ }
try {
const mt = await fetch('/api/studio/tracks');
if (mt.ok) setMusicTracks((await mt.json()).tracks ?? []);
} catch { /* 무시 */ }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -369,10 +384,11 @@ function MyPageContent() {
// 입금 확인 대기 중인 주문 (orders 테이블 pending) // 입금 확인 대기 중인 주문 (orders 테이블 pending)
const pendingOrders = productOrders.filter((o) => o.status === 'pending'); const pendingOrders = productOrders.filter((o) => o.status === 'pending');
// AI 기록 탭 — 사주·타로 결과를 created_at 기준 내림차순 병합 // AI 기록 탭 — 사주·타로·음악 결과를 created_at 기준 내림차순 병합
const aiRecords: AiRecordItem[] = [ const aiRecords: AiRecordItem[] = [
...sajuRecords.map((r): AiRecordItem => ({ kind: 'saju', data: r })), ...sajuRecords.map((r): AiRecordItem => ({ kind: 'saju', data: r })),
...tarotReadings.map((r): AiRecordItem => ({ kind: 'tarot', data: r })), ...tarotReadings.map((r): AiRecordItem => ({ kind: 'tarot', data: r })),
...musicTracks.map((r): AiRecordItem => ({ kind: 'music', data: r })),
].sort((a, b) => new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime()); ].sort((a, b) => new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime());
const tabs: { key: Tab; label: string; count?: number }[] = [ const tabs: { key: Tab; label: string; count?: number }[] = [
@@ -380,7 +396,7 @@ function MyPageContent() {
{ key: 'requests', label: '발주·진행', count: orders.length || undefined }, { key: 'requests', label: '발주·진행', count: orders.length || undefined },
{ key: 'products', label: '내 제품', count: productGroups.length || undefined }, { key: 'products', label: '내 제품', count: productGroups.length || undefined },
{ key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined }, { key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined },
{ key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length) || undefined }, { key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length + musicTracks.length) || undefined },
]; ];
function selectTab(key: Tab) { function selectTab(key: Tab) {
@@ -910,7 +926,7 @@ function MyPageContent() {
AI AI
</div> </div>
<div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}> <div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
· . · · .
</div> </div>
<div className="flex flex-wrap items-center justify-center gap-3"> <div className="flex flex-wrap items-center justify-center gap-3">
<Link <Link
@@ -927,6 +943,13 @@ function MyPageContent() {
> >
</Link> </Link>
<Link
href="/music"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-colors hover:bg-[var(--jsm-surface-alt)]"
style={{ color: 'var(--jsm-ink)', border: '1px solid var(--jsm-line)' }}
>
</Link>
</div> </div>
</div> </div>
) : ( ) : (
@@ -934,13 +957,15 @@ function MyPageContent() {
{aiRecords.map((item) => {aiRecords.map((item) =>
item.kind === 'saju' ? ( item.kind === 'saju' ? (
<SajuAiCard key={`saju-${item.data.id}`} record={item.data} /> <SajuAiCard key={`saju-${item.data.id}`} record={item.data} />
) : ( ) : item.kind === 'tarot' ? (
<TarotAiCard <TarotAiCard
key={`tarot-${item.data.id}`} key={`tarot-${item.data.id}`}
reading={item.data} reading={item.data}
expanded={expandedAiCards.has(item.data.id)} expanded={expandedAiCards.has(item.data.id)}
onToggle={() => toggleAiCard(item.data.id)} onToggle={() => toggleAiCard(item.data.id)}
/> />
) : (
<MusicAiCard key={`music-${item.data.id}`} track={item.data} />
) )
)} )}
</div> </div>
@@ -1569,6 +1594,48 @@ function TarotAiCard({
); );
} }
// AI 기록 — 음악 카드. 제목·날짜 + 스토리 요약 + 오디오 플레이어(생성 완료 시).
function MusicAiCard({ track }: { track: MusicTrackRow }) {
return (
<Card compact>
<div className="flex items-center justify-between gap-3 mb-3">
<span
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
</span>
<span className="text-xs flex-shrink-0" style={{ color: 'var(--jsm-ink-faint)' }}>
{new Date(track.created_at).toLocaleDateString('ko-KR')}
</span>
</div>
<div className="text-sm font-semibold mb-2 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{track.title || '제목 없는 트랙'}
</div>
{track.story && (
<p
className="text-sm line-clamp-2 break-keep mb-3"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{track.story}
</p>
)}
{track.audio_url ? (
<audio controls className="w-full" style={{ height: 36 }}>
<source src={track.audio_url} />
</audio>
) : (
<p className="text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
. .
</p>
)}
</Card>
);
}
function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) { function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
return ( return (
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden> <svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>