Compare commits

...

9 Commits

Author SHA1 Message Date
9af71ddd9a docs(phase3b): CLAUDE.md — 음악 영상화 발주 플로우 반영 2026-07-09 23:21:45 +09:00
65a8680adc feat(phase3b): admin 영상화 납품 — 연결 트랙 표시 + 영상 URL 입력
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-09 23:15:55 +09:00
6b6f1dbe0e feat(phase3b): 마이페이지 음악 영상화 신청·상태 + 모달 트랙 연결
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-09 23:05:17 +09:00
c5c9487874 fix(phase3b): orders 영상화 트랙 update·조회 error 로깅(파일 내 규약 일치) 2026-07-09 22:59:27 +09:00
c9b8d2df5c feat(phase3b): orders에 영상화(music_video) 발주 + video-product 조회 2026-07-09 22:53:02 +09:00
853f96b405 feat(phase3b): music_video 제품 시드 + music_tracks video 컬럼 마이그레이션
Phase 3b Task 1: DB 마이그레이션
- products에 'music_video' 제품 생성 (30000원)
- music_tracks에 video_status/video_url/video_order_id 컬럼 추가

의존성: 2026-07-03-phase3a-music.sql 적용 후 실행

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-09 12:56:37 +09:00
1949f5c692 @
docs(phase3b): 음악 영상화 발주 구현 플랜 (5 Task)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
@
2026-07-09 12:54:14 +09:00
bbbc61f402 docs(phase3b): 음악 영상화 유료 발주 설계 + 향후 로드맵 기록 (WS1~5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-09 12:43:01 +09:00
6a67a9d812 @
chore(music): /music 카피 무료 스토리→음악 포지셔닝으로 정리 — 구 유료 팩 카피·packs 카드 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
@
2026-07-04 01:26:09 +09:00
13 changed files with 818 additions and 63 deletions

View File

@@ -94,8 +94,9 @@ app/
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/story/route.ts — POST: 스토리→가사 생성 (Gemini, 로그인 필요)
studio/tracks/route.ts — GET/POST: 음악 트랙 저장·조회 (music_tracks, 본인 것만) studio/tracks/route.ts — GET/POST: 음악 트랙 저장·조회 (music_tracks, 본인 것만·video 상태 포함)
studio/callback/route.ts — POST: Suno webhook 수신용 최소 엔드포인트 studio/callback/route.ts — POST: Suno webhook 수신용 최소 엔드포인트
studio/video-product/route.ts — GET: 음악 영상화(music_video) 제품 조회 (로그인, 모달 가격 표시용)
work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일) work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일)
tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석) tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석)
music/ — 공개: 스토리→음악 AI 스튜디오 (studio·samples, packs는 /products로 308) music/ — 공개: 스토리→음악 AI 스튜디오 (studio·samples, packs는 /products로 308)
@@ -167,6 +168,28 @@ lib/
- `lib/product-access.ts`: orders 기반 접근 + music tier 하위 호환 - `lib/product-access.ts`: orders 기반 접근 + music tier 하위 호환
### 음악 영상화 발주 (music_video, 유료 계좌이체) — Phase 3b
```
회원이 저장한 음악 트랙 (마이페이지 → AI 기록 탭 음악 카드)
→ "영상화 신청" → GET /api/studio/video-product (고정가 ₩30,000)
→ BankTransferModal (musicTrackId 전달) → POST /api/orders {productId:'music_video', musicTrackId}
→ orders 생성 (metadata.music_track_id) + music_tracks.video_status='requested'
→ 입금 안내 메일 (기존 계좌이체 흐름 재사용)
관리자 납품 (/admin/orders)
→ music_video 주문에 연결 트랙(title·audio) 표시
→ 영상 URL 입력 + "납품 완료" → PATCH {id, status:'paid', videoUrl}
→ music_tracks.video_url 세팅 + video_status='delivered'
→ (선택) "제작 중" → PATCH {id, videoStatus:'in_progress'}
고객 확인 (마이페이지 음악 카드)
→ video_status 뱃지(requested/in_progress) → delivered 시 <video> 재생 + 다운로드
```
- `music_tracks` video 컬럼: `video_status`(none|requested|in_progress|delivered) · `video_url` · `video_order_id`
- 기존 orders/계좌이체/admin/메일 인프라 재사용 — 일반 상품 발주 경로 무변경
--- ---
## 개발 규칙 ## 개발 규칙

View File

@@ -2,6 +2,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
interface MusicTrack {
id: string;
title: string | null;
audio_url: string | null;
video_status: string;
video_url: string | null;
}
interface Order { interface Order {
id: string; id: string;
user_id: string | null; user_id: string | null;
@@ -12,8 +20,16 @@ interface Order {
created_at: string; created_at: string;
product_name: string | null; product_name: string | null;
customer_email: string | null; customer_email: string | null;
musicTrack?: MusicTrack | null;
} }
const VIDEO_STATUS_LABELS: Record<string, { label: string; color: string }> = {
none: { label: '미신청', color: 'bg-slate-700/60 text-slate-500' },
requested: { label: '신청됨', color: 'bg-yellow-900/40 text-yellow-400' },
in_progress: { label: '제작 중', color: 'bg-blue-900/40 text-blue-400' },
delivered: { label: '납품 완료', color: 'bg-green-900/40 text-green-400' },
};
const STATUS_LABELS: Record<string, { label: string; color: string }> = { const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '입금 대기', color: 'bg-yellow-900/40 text-yellow-400' }, pending: { label: '입금 대기', color: 'bg-yellow-900/40 text-yellow-400' },
paid: { label: '완료', color: 'bg-green-900/40 text-green-400' }, paid: { label: '완료', color: 'bg-green-900/40 text-green-400' },
@@ -33,6 +49,7 @@ export default function AdminOrdersPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('all'); const [filterStatus, setFilterStatus] = useState<string>('all');
const [updating, setUpdating] = useState<string | null>(null); const [updating, setUpdating] = useState<string | null>(null);
const [videoUrls, setVideoUrls] = useState<Record<string, string>>({});
async function loadOrders() { async function loadOrders() {
try { try {
@@ -82,6 +99,73 @@ export default function AdminOrdersPage() {
} }
} }
async function deliverVideo(order: Order) {
const videoUrl = (videoUrls[order.id] ?? '').trim();
if (!videoUrl) {
alert('영상 URL을 입력해주세요.');
return;
}
const ok = confirm('영상 납품을 완료 처리하시겠습니까? 고객에게 다운로드 활성화 메일이 발송됩니다.');
if (!ok) return;
setUpdating(order.id);
try {
const res = await fetch('/api/admin/orders', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: order.id, status: 'paid', videoUrl }),
});
if (res.ok) {
setOrders((prev) =>
prev.map((o) =>
o.id === order.id
? {
...o,
status: 'paid',
musicTrack: o.musicTrack
? { ...o.musicTrack, video_status: 'delivered', video_url: videoUrl }
: o.musicTrack,
}
: o
)
);
} else {
alert('납품 처리에 실패했습니다.');
}
} catch (e) {
console.error(e);
alert('네트워크 오류가 발생했습니다.');
} finally {
setUpdating(null);
}
}
async function markVideoInProgress(orderId: string) {
setUpdating(orderId);
try {
const res = await fetch('/api/admin/orders', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: orderId, videoStatus: 'in_progress' }),
});
if (res.ok) {
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, musicTrack: o.musicTrack ? { ...o.musicTrack, video_status: 'in_progress' } : o.musicTrack }
: o
)
);
} else {
alert('상태 변경에 실패했습니다.');
}
} catch (e) {
console.error(e);
alert('네트워크 오류가 발생했습니다.');
} finally {
setUpdating(null);
}
}
const filtered = orders.filter((o) => filterStatus === 'all' || o.status === filterStatus); const filtered = orders.filter((o) => filterStatus === 'all' || o.status === filterStatus);
const pendingCount = orders.filter((o) => o.status === 'pending').length; const pendingCount = orders.filter((o) => o.status === 'pending').length;
@@ -212,6 +296,55 @@ export default function AdminOrdersPage() {
)} )}
</div> </div>
</div> </div>
{order.product_id === 'music_video' && (
<div className="mt-3 pt-3 border-t border-slate-800 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-slate-500 flex-shrink-0"> :</span>
<span className="text-sm text-white truncate">
{order.musicTrack?.title ?? '(트랙 정보 없음)'}
</span>
{order.musicTrack?.audio_url && (
<audio controls src={order.musicTrack.audio_url} className="h-8" />
)}
{order.musicTrack && (
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${
VIDEO_STATUS_LABELS[order.musicTrack.video_status]?.color ?? 'bg-slate-700/60 text-slate-500'
}`}
>
{VIDEO_STATUS_LABELS[order.musicTrack.video_status]?.label ?? order.musicTrack.video_status}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
<input
type="text"
placeholder="영상 URL"
value={videoUrls[order.id] ?? order.musicTrack?.video_url ?? ''}
onChange={(e) =>
setVideoUrls((prev) => ({ ...prev, [order.id]: e.target.value }))
}
className="bg-slate-800 border border-slate-700 text-white text-xs rounded-lg px-2.5 py-1.5 w-56 placeholder:text-slate-500 focus:outline-none focus:border-slate-500"
/>
<button
onClick={() => markVideoInProgress(order.id)}
disabled={updating === order.id}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-blue-600/20 text-blue-400 border border-blue-500/30 hover:bg-blue-600/30 transition disabled:opacity-50"
>
</button>
<button
onClick={() => deliverVideo(order)}
disabled={updating === order.id}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-green-600/20 text-green-400 border border-green-500/30 hover:bg-green-600/30 transition disabled:opacity-50"
>
{updating === order.id ? '처리중...' : '납품 완료'}
</button>
</div>
</div>
)}
</div> </div>
); );
})} })}

View File

@@ -36,45 +36,91 @@ export async function GET() {
const productIds = [...new Set(orders.map((o) => o.product_id).filter(Boolean))] as string[]; const productIds = [...new Set(orders.map((o) => o.product_id).filter(Boolean))] as string[];
const userIds = [...new Set(orders.map((o) => o.user_id).filter(Boolean))] as string[]; const userIds = [...new Set(orders.map((o) => o.user_id).filter(Boolean))] as string[];
const [productsRes, profilesRes] = await Promise.all([ // music_video 주문의 연결 트랙 id 모으기
const trackIds = [
...new Set(
orders
.filter((o) => o.product_id === 'music_video')
.map((o) =>
typeof (o.metadata as Record<string, unknown> | null)?.music_track_id === 'string'
? ((o.metadata as Record<string, unknown>).music_track_id as string)
: null,
)
.filter((v): v is string => v !== null),
),
];
const [productsRes, profilesRes, tracksRes] = await Promise.all([
productIds.length > 0 productIds.length > 0
? supabase.from('products').select('id, name').in('id', productIds) ? supabase.from('products').select('id, name').in('id', productIds)
: Promise.resolve({ data: [] as { id: string; name: string }[] | null, error: null }), : Promise.resolve({ data: [] as { id: string; name: string }[] | null, error: null }),
userIds.length > 0 userIds.length > 0
? supabase.from('profiles').select('id, email').in('id', userIds) ? supabase.from('profiles').select('id, email').in('id', userIds)
: Promise.resolve({ data: [] as { id: string; email: string }[] | null, error: null }), : Promise.resolve({ data: [] as { id: string; email: string }[] | null, error: null }),
trackIds.length > 0
? supabase.from('music_tracks').select('id, title, audio_url, video_status, video_url').in('id', trackIds)
: Promise.resolve({
data: [] as { id: string; title: string | null; audio_url: string | null; video_status: string; video_url: string | null }[] | null,
error: null,
}),
]); ]);
const productMap = Object.fromEntries((productsRes.data ?? []).map((p) => [p.id, p.name])); const productMap = Object.fromEntries((productsRes.data ?? []).map((p) => [p.id, p.name]));
const profileMap = Object.fromEntries((profilesRes.data ?? []).map((p) => [p.id, p.email])); const profileMap = Object.fromEntries((profilesRes.data ?? []).map((p) => [p.id, p.email]));
const trackMap = Object.fromEntries((tracksRes.data ?? []).map((t) => [t.id, t]));
const enriched = orders.map((o) => ({ const enriched = orders.map((o) => {
const trackId =
o.product_id === 'music_video' && typeof (o.metadata as Record<string, unknown> | null)?.music_track_id === 'string'
? ((o.metadata as Record<string, unknown>).music_track_id as string)
: null;
return {
...o, ...o,
product_name: o.product_id ? (productMap[o.product_id] ?? null) : null, product_name: o.product_id ? (productMap[o.product_id] ?? null) : null,
customer_email: o.user_id ? (profileMap[o.user_id] ?? null) : null, customer_email: o.user_id ? (profileMap[o.user_id] ?? null) : null,
})); musicTrack: trackId ? (trackMap[trackId] ?? null) : null,
};
});
return NextResponse.json({ orders: enriched }); return NextResponse.json({ orders: enriched });
} }
// PATCH: 상태 변경 ('paid' 전환 시 고객에게 다운로드 활성화 메일) // PATCH: 상태 변경('paid' 전환 시 고객에게 다운로드 활성화 메일) + music_video 영상화 납품(videoUrl/videoStatus)
export async function PATCH(request: Request) { export async function PATCH(request: Request) {
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id, status } = await request.json(); const { id, status, videoUrl, videoStatus } = await request.json();
if (typeof id !== 'string' || !['pending', 'paid', 'cancelled'].includes(status)) {
if (typeof id !== 'string') {
return NextResponse.json({ error: 'invalid request' }, { status: 400 });
}
if (status !== undefined && !['pending', 'paid', 'cancelled'].includes(status)) {
return NextResponse.json({ error: 'invalid status' }, { status: 400 });
}
if (videoStatus !== undefined && !['none', 'requested', 'in_progress', 'delivered'].includes(videoStatus)) {
return NextResponse.json({ error: 'invalid videoStatus' }, { status: 400 });
}
if (videoUrl !== undefined && typeof videoUrl !== 'string') {
return NextResponse.json({ error: 'invalid videoUrl' }, { status: 400 });
}
if (status === undefined && videoUrl === undefined && videoStatus === undefined) {
return NextResponse.json({ error: 'invalid request' }, { status: 400 }); return NextResponse.json({ error: 'invalid request' }, { status: 400 });
} }
const supabase = createAdminClient(); const supabase = createAdminClient();
const { data: order, error } = await supabase
let order: { id: string; product_id: string | null; user_id: string | null; metadata: Record<string, unknown> | null };
if (status !== undefined) {
const { data, error } = await supabase
.from('orders') .from('orders')
.update({ status, updated_at: new Date().toISOString() }) .update({ status, updated_at: new Date().toISOString() })
.eq('id', id) .eq('id', id)
.select('id, product_id, user_id') .select('id, product_id, user_id, metadata')
.single(); .single();
if (error || !order) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 }); if (error || !data) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
order = data;
// paid 전환 시에만 메일 발송 — 실패해도 상태 변경은 이미 완료 // paid 전환 시에만 메일 발송 — 실패해도 상태 변경은 이미 완료
if (status === 'paid' && order.product_id && order.user_id) { if (status === 'paid' && order.product_id && order.user_id) {
@@ -92,6 +138,36 @@ export async function PATCH(request: Request) {
console.error('paid email failed', e); console.error('paid email failed', e);
} }
} }
} else {
// status 변경 없이 영상화 트랙만 갱신하는 경우 — 주문 조회로 product_id/metadata 확보
const { data, error } = await supabase
.from('orders')
.select('id, product_id, user_id, metadata')
.eq('id', id)
.single();
if (error || !data) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
order = data;
}
// music_video 주문의 연결 트랙 영상화 상태 갱신
if (order.product_id === 'music_video') {
const trackId = typeof order.metadata?.music_track_id === 'string' ? (order.metadata.music_track_id as string) : null;
if (trackId) {
if (status === 'paid' || videoUrl !== undefined) {
const trackUpdate: Record<string, unknown> = { video_status: 'delivered' };
if (videoUrl !== undefined) trackUpdate.video_url = videoUrl;
const { error: trackError } = await supabase.from('music_tracks').update(trackUpdate).eq('id', trackId);
if (trackError) console.error('music_tracks video delivery update failed', trackError);
} else if (videoStatus === 'in_progress') {
const { error: trackError } = await supabase
.from('music_tracks')
.update({ video_status: 'in_progress' })
.eq('id', trackId);
if (trackError) console.error('music_tracks video in_progress update failed', trackError);
}
}
}
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }

View File

@@ -70,14 +70,25 @@ export async function POST(request: Request) {
return NextResponse.json({ error: '판매 중인 상품이 아닙니다' }, { status: 404 }); return NextResponse.json({ error: '판매 중인 상품이 아닙니다' }, { status: 404 });
} }
// music_video 발주: 본인 트랙 검증 + 트랙별 중복 가드
let musicTrackId: string | null = null;
if (productId === 'music_video') {
musicTrackId = sanitizeStr((body as Record<string, unknown>).musicTrackId, 64) || null;
if (!musicTrackId) return NextResponse.json({ error: '영상화할 트랙이 필요합니다' }, { status: 400 });
const { data: track, error: trackLookupError } = await admin
.from('music_tracks').select('id').eq('id', musicTrackId).eq('user_id', user.id).maybeSingle();
if (trackLookupError) {
console.error('[Orders] music_tracks ownership lookup error:', trackLookupError);
}
if (!track) return NextResponse.json({ error: '본인 음악 트랙만 영상화할 수 있습니다' }, { status: 404 });
}
// 4) 중복 pending 방지 // 4) 중복 pending 방지
const { data: existing } = await admin const dupQuery = admin.from('orders').select('id')
.from('orders') .eq('user_id', user.id).eq('product_id', productId).eq('status', 'pending');
.select('id') const { data: existing } = productId === 'music_video' && musicTrackId
.eq('user_id', user.id) ? await dupQuery.eq('metadata->>music_track_id', musicTrackId).maybeSingle()
.eq('product_id', productId) : await dupQuery.maybeSingle();
.eq('status', 'pending')
.maybeSingle();
if (existing) { if (existing) {
return NextResponse.json({ orderId: existing.id, reused: true }); return NextResponse.json({ orderId: existing.id, reused: true });
@@ -94,6 +105,7 @@ export async function POST(request: Request) {
metadata: { metadata: {
method: 'bank_transfer', method: 'bank_transfer',
depositor_name: depositorName, depositor_name: depositorName,
...(musicTrackId ? { music_track_id: musicTrackId } : {}),
}, },
}) })
.select('id') .select('id')
@@ -106,6 +118,15 @@ export async function POST(request: Request) {
const orderId = order.id as string; const orderId = order.id as string;
if (productId === 'music_video' && musicTrackId) {
const { error: trackUpdateError } = await admin.from('music_tracks')
.update({ video_status: 'requested', video_order_id: orderId })
.eq('id', musicTrackId).eq('user_id', user.id);
if (trackUpdateError) {
console.error('[Orders] music_tracks video_status update error:', trackUpdateError);
}
}
// 6) 메일 발송 (실패해도 주문 유효) // 6) 메일 발송 (실패해도 주문 유효)
try { try {
await sendOrderReceivedEmails({ await sendOrderReceivedEmails({

View File

@@ -41,7 +41,7 @@ export async function GET() {
// 세션 클라이언트로 본인 것만(RLS music_select_own) // 세션 클라이언트로 본인 것만(RLS music_select_own)
const { data, error } = await supabase const { data, error } = await supabase
.from('music_tracks') .from('music_tracks')
.select('id, title, story, lyrics, style, audio_url, task_id, created_at') .select('id, title, story, lyrics, style, audio_url, task_id, created_at, video_status, video_url')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 }); if (error) return NextResponse.json({ error: error.message }, { status: 500 });

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { getProductById } from '@/lib/supabase/product-files';
export const runtime = 'nodejs';
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
const admin = createAdminClient();
const product = await getProductById(admin, 'music_video');
if (!product || !product.is_active) return NextResponse.json({ error: '영상화 상품이 준비 중입니다.' }, { status: 404 });
return NextResponse.json({ product: { id: product.id, name: product.name, price: product.price, is_active: product.is_active } });
}

View File

@@ -19,6 +19,7 @@ interface Props {
product: { id: string; name: string; price: number }; product: { id: string; name: string; price: number };
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
musicTrackId?: string;
} }
type AuthState = 'checking' | 'guest' | 'user'; type AuthState = 'checking' | 'guest' | 'user';
@@ -29,7 +30,7 @@ interface SuccessInfo {
reused: boolean; reused: boolean;
} }
export default function BankTransferModal({ product, isOpen, onClose }: Props) { export default function BankTransferModal({ product, isOpen, onClose, musicTrackId }: Props) {
const [authState, setAuthState] = useState<AuthState>('checking'); const [authState, setAuthState] = useState<AuthState>('checking');
const [depositorName, setDepositorName] = useState(''); const [depositorName, setDepositorName] = useState('');
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
@@ -98,7 +99,11 @@ export default function BankTransferModal({ product, isOpen, onClose }: Props) {
const res = await fetch('/api/orders', { const res = await fetch('/api/orders', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: product.id, depositorName: name }), body: JSON.stringify({
productId: product.id,
depositorName: name,
...(musicTrackId ? { musicTrackId } : {}),
}),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
@@ -122,7 +127,7 @@ export default function BankTransferModal({ product, isOpen, onClose }: Props) {
setSubmitting(false); setSubmitting(false);
} }
}, },
[depositorName, agreed, submitting, product.id], [depositorName, agreed, submitting, product.id, musicTrackId],
); );
if (!isOpen) return null; if (!isOpen) return null;

View File

@@ -1,8 +1,8 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'AI 음악 제품', title: '나의 이야기를 음악으로',
description: 'Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿 한 팩에. 1시간 만에 음악·뮤비 완성.', description: '당신의 이야기를 AI가 가사와 음악으로. 스토리를 들려주면 나만의 노래가 완성됩니다. 로그인 무료.',
}; };
export default function MusicLayout({ children }: { children: React.ReactNode }) { export default function MusicLayout({ children }: { children: React.ReactNode }) {

View File

@@ -2,28 +2,22 @@ import Link from 'next/link';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Music — AI 음악 제품', title: '음악 — 나의 이야기를 음악으로',
}; };
const CARDS = [ const CARDS = [
{ {
href: '/music/packs', href: '/music/studio',
label: '팩 상세', label: 'AI 스튜디오',
desc: '입문 ₩39,000부터 — Suno 프롬프트북 + 뮤비 워크플로우 + SEO 템플릿', desc: '스토리를 입력하면 가사·음악을 자동 생성 — 로그인 무료',
key: 'packs', key: 'studio',
}, },
{ {
href: '/music/samples', href: '/music/samples',
label: '샘플 갤러리', label: '샘플 갤러리',
desc: '실제 결과물 — 장르별 데모 + 가사 + 영상 미리보기', desc: '실제 결과물 — 장르별 데모 가사',
key: 'samples', key: 'samples',
}, },
{
href: '/music/studio',
label: 'AI 스튜디오',
desc: 'Suno API 연동 — 직접 트랙 생성 (베타)',
key: 'studio',
},
]; ];
export default function MusicHub() { export default function MusicHub() {
@@ -38,16 +32,16 @@ export default function MusicHub() {
className="kx-display text-4xl md:text-6xl font-bold mb-5 text-white" className="kx-display text-4xl md:text-6xl font-bold mb-5 text-white"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }} style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
> >
AI
</h1> </h1>
<p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed"> <p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed">
Suno + + SEO 릿. 4 1 . AI가 . .
</p> </p>
</div> </div>
</section> </section>
<section className="py-20 px-6"> <section className="py-20 px-6">
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-5"> <div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-5">
{CARDS.map((c) => ( {CARDS.map((c) => (
<Link <Link
key={c.key} key={c.key}

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js'; import type { User } from '@supabase/supabase-js';
import TelegramGuideModal from '@/app/components/TelegramGuideModal'; import TelegramGuideModal from '@/app/components/TelegramGuideModal';
import BankTransferModal from '@/app/components/BankTransferModal';
import { KAKAO_OPENCHAT_URL } from '@/lib/contact'; import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
import { findCard } from '@/lib/tarot/cards'; import { findCard } from '@/lib/tarot/cards';
import { import {
@@ -75,6 +76,8 @@ type MusicTrackRow = {
story: string | null; story: string | null;
audio_url: string | null; audio_url: string | null;
created_at: string; created_at: string;
video_status?: 'none' | 'requested' | 'in_progress' | 'delivered';
video_url?: string | null;
}; };
// AI 기록 탭 — 사주·타로·음악 병합 렌더용 판별 유니언 // AI 기록 탭 — 사주·타로·음악 병합 렌더용 판별 유니언
@@ -167,6 +170,9 @@ function MyPageContent() {
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]); const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
const [musicTracks, setMusicTracks] = useState<MusicTrackRow[]>([]); const [musicTracks, setMusicTracks] = useState<MusicTrackRow[]>([]);
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set()); const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set());
// 음악 영상화 신청 — 계좌이체 모달에 전달할 상품·대상 트랙
const [videoProduct, setVideoProduct] = useState<{ id: string; name: string; price: number } | null>(null);
const [videoModalTrackId, setVideoModalTrackId] = useState<string | null>(null);
const loadProjects = useCallback(async () => { const loadProjects = useCallback(async () => {
try { try {
@@ -202,6 +208,23 @@ function MyPageContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// 음악 트랙 영상화 신청 — 상품 정보 로드 후 계좌이체 모달 오픈
const handleRequestVideo = useCallback(async (trackId: string) => {
try {
const res = await fetch('/api/studio/video-product');
if (!res.ok) {
console.warn('영상화 상품을 불러오지 못했습니다', res.status);
return;
}
const data = await res.json();
if (!data?.product) return;
setVideoProduct(data.product);
setVideoModalTrackId(trackId);
} catch (e) {
console.warn('영상화 상품 조회 중 오류', e);
}
}, []);
useEffect(() => { useEffect(() => {
async function init() { async function init() {
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
@@ -965,7 +988,11 @@ function MyPageContent() {
onToggle={() => toggleAiCard(item.data.id)} onToggle={() => toggleAiCard(item.data.id)}
/> />
) : ( ) : (
<MusicAiCard key={`music-${item.data.id}`} track={item.data} /> <MusicAiCard
key={`music-${item.data.id}`}
track={item.data}
onRequestVideo={handleRequestVideo}
/>
) )
)} )}
</div> </div>
@@ -973,6 +1000,17 @@ function MyPageContent() {
</div> </div>
)} )}
</div> </div>
{/* 음악 영상화 신청 — 계좌이체 모달 (영상화 상품 로드 완료 시에만 오픈) */}
<BankTransferModal
isOpen={!!videoModalTrackId && !!videoProduct}
product={videoProduct ?? { id: '', name: '', price: 0 }}
musicTrackId={videoModalTrackId ?? undefined}
onClose={() => {
setVideoModalTrackId(null);
loadAiRecords();
}}
/>
</div> </div>
); );
} }
@@ -1594,8 +1632,14 @@ function TarotAiCard({
); );
} }
// AI 기록 — 음악 카드. 제목·날짜 + 스토리 요약 + 오디오 플레이어(생성 완료 시). // AI 기록 — 음악 카드. 제목·날짜 + 스토리 요약 + 오디오 플레이어(생성 완료 시) + 영상화 신청/상태.
function MusicAiCard({ track }: { track: MusicTrackRow }) { function MusicAiCard({
track,
onRequestVideo,
}: {
track: MusicTrackRow;
onRequestVideo: (trackId: string) => void;
}) {
return ( return (
<Card compact> <Card compact>
<div className="flex items-center justify-between gap-3 mb-3"> <div className="flex items-center justify-between gap-3 mb-3">
@@ -1624,14 +1668,68 @@ function MusicAiCard({ track }: { track: MusicTrackRow }) {
)} )}
{track.audio_url ? ( {track.audio_url ? (
<audio controls className="w-full" style={{ height: 36 }}> <audio controls className="w-full mb-3" style={{ height: 36 }}>
<source src={track.audio_url} /> <source src={track.audio_url} />
</audio> </audio>
) : ( ) : (
<p className="text-xs" style={{ color: 'var(--jsm-ink-faint)' }}> <p className="text-xs mb-3" style={{ color: 'var(--jsm-ink-faint)' }}>
. . . .
</p> </p>
)} )}
<div className="pt-3 border-t" style={{ borderColor: 'var(--jsm-line)' }}>
{(!track.video_status || track.video_status === 'none') && (
<button
type="button"
onClick={() => onRequestVideo(track.id)}
className="px-4 py-2 rounded-lg text-xs font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)' }}
>
</button>
)}
{track.video_status === 'requested' && (
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
·
</span>
)}
{track.video_status === 'in_progress' && (
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
</span>
)}
{track.video_status === 'delivered' && (
track.video_url ? (
<div>
<video controls src={track.video_url} className="w-full rounded-lg mb-2" />
<a
href={track.video_url}
download
className="text-xs font-semibold underline"
style={{ color: 'var(--jsm-accent)' }}
>
</a>
</div>
) : (
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
</span>
)
)}
</div>
</Card> </Card>
); );
} }

View File

@@ -0,0 +1,272 @@
# Phase 3b 음악 영상화 유료 발주 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:** 회원이 저장한 음악 트랙을 계좌이체로 "영상화 발주"하고 관리자가 URL로 완성 영상을 납품하는 흐름을 붙인다.
**Architecture:** 기존 orders(계좌이체)·admin/orders·메일 인프라를 재사용. `music_video` 제품 1행 + music_tracks의 video_status/video_url 컬럼으로 상태를 표현. 신청은 마이페이지 음악 카드, 납품은 admin/orders에서 URL 입력.
**Tech Stack:** Next.js 16 (App Router, TS), Supabase, Tailwind v4(`--jsm-*`), vitest
**Spec:** `docs/superpowers/specs/2026-07-09-phase3b-music-video-order-design.md`
## Global Constraints
- **순수 시각 태스크 없음** — 대부분 API/데이터. 신규/변경 UI는 라이트 가드레일(gradient/blur/보라/이모지 금지, `--jsm-*`만)
- orders POST의 일반 상품(products) 발주 동작은 **회귀 금지** — music_video 분기만 추가
- `BankTransferModal`은 다른 상품도 사용 — `musicTrackId`**optional**, 미전달 시 기존 동작 불변
- 인증→검증→admin insert(user_id는 세션), user_id 스푸핑 불가. 본인 트랙만 발주(music_tracks user_id 일치 검증)
- 마이그레이션은 phase3a(music_tracks) 적용 후 실행 전제 — 헤더에 명시. 기존 마이그레이션 수정 금지, 신규만
- 커밋은 스코프 파일만 — **`git add -A`·`git commit -a` 금지**, 커밋 전 `git status` 확인
- 각 Task 종료 시 `npm run build` 성공 + `npm test`(31) 유지 후 커밋
- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
## 확인된 기존 계약
- `products`: `{ id, name, price, is_active, ... }`. `getProductById(admin, id)`(`lib/supabase/product-files.ts`)
- `POST /api/orders`(`app/api/orders/route.ts`): 인증→rate-limit→`sanitizeStr(productId/depositorName)`→getProductById(active)→중복 pending 가드(user+product)→insert `{user_id, product_id, amount:product.price, status:'pending', metadata:{method:'bank_transfer', depositor_name}}`→접수 메일
- `admin/orders`(`app/api/admin/orders/route.ts`): GET select `id,user_id,product_id,amount,status,metadata,created_at`; PATCH `{id, status∈pending|paid|cancelled}`, paid 시 `sendOrderPaidEmail`
- `BankTransferModal({ product:{id,name,price}, isOpen, onClose })` → POST `{ productId, depositorName }`
- `music_tracks`: `{ id, user_id, title, story, lyrics, style, audio_url, task_id, created_at }`(Phase 3a), RLS `music_select_own`
- 마이페이지 AI기록: `MusicTrackRow = { id, title, story, audio_url, created_at }`, `MusicAiCard`
---
### Task 1: DB 마이그레이션 (music_video 제품 + music_tracks 확장)
**Files:**
- Create: `supabase/migrations/2026-07-09-phase3b-music-video.sql`
**Interfaces:**
- Produces: `products``music_video`, `music_tracks.video_status/video_url/video_order_id`. Task 2·3·4가 소비
- [ ] **Step 1: products 실제 컬럼 확인**
`005_all_products.sql` 또는 products 생성 마이그레이션을 Read해 필수(NOT NULL, default 없는) 컬럼 파악. `id,name,price,is_active` 외 NOT NULL 컬럼이 있으면 시드 INSERT에 값 포함.
- [ ] **Step 2: 마이그레이션 파일 작성**
`supabase/migrations/2026-07-09-phase3b-music-video.sql`:
```sql
-- Phase 3b (2026-07-09): 음악 영상화 발주
-- 의존성: 2026-07-03-phase3a-music.sql(music_tracks) 적용 후
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
INSERT INTO products (id, name, price, is_active)
VALUES ('music_video', '음악 영상화', 30000, true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE music_tracks
ADD COLUMN IF NOT EXISTS video_status text NOT NULL DEFAULT 'none'
CHECK (video_status IN ('none','requested','in_progress','delivered')),
ADD COLUMN IF NOT EXISTS video_url text,
ADD COLUMN IF NOT EXISTS video_order_id uuid;
```
(Step 1에서 products에 추가 NOT NULL 컬럼 발견 시 INSERT 컬럼 목록·값 확장)
- [ ] **Step 3: 검증·커밋**`npm test && npm run build` PASS. `git add supabase/migrations/2026-07-09-phase3b-music-video.sql && git commit -m "feat(phase3b): music_video 제품 시드 + music_tracks video 컬럼 마이그레이션"`
---
### Task 2: 발주 API 확장 (orders POST + video-product GET)
**Files:**
- Modify: `app/api/orders/route.ts`
- Create: `app/api/studio/video-product/route.ts`
**Interfaces:**
- Consumes: music_tracks(T1), `createAdminClient`, `getProductById`
- Produces:
- `POST /api/orders` body에 optional `musicTrackId` — music_video 발주 시 트랙 검증·연결
- `GET /api/studio/video-product` (로그인) → `{ product: { id, name, price, is_active } }` / 401 / 404
- Task 3이 소비
- [ ] **Step 1: orders POST에 musicTrackId 처리 추가**
`app/api/orders/route.ts`를 Read 후, body 파싱부에 `musicTrackId` 추출(`sanitizeStr(rawMusicTrackId, 64)`). productId 검증 이후·insert 이전에:
```typescript
// music_video 발주: 본인 트랙 검증 + 트랙별 중복 가드
let musicTrackId: string | null = null;
if (productId === 'music_video') {
musicTrackId = sanitizeStr((body as Record<string, unknown>).musicTrackId, 64) || null;
if (!musicTrackId) return NextResponse.json({ error: '영상화할 트랙이 필요합니다' }, { status: 400 });
const { data: track } = await admin
.from('music_tracks').select('id').eq('id', musicTrackId).eq('user_id', user.id).maybeSingle();
if (!track) return NextResponse.json({ error: '본인 음악 트랙만 영상화할 수 있습니다' }, { status: 404 });
}
```
중복 pending 가드를 분기: music_video면 트랙 단위로
```typescript
const dupQuery = admin.from('orders').select('id')
.eq('user_id', user.id).eq('product_id', productId).eq('status', 'pending');
const { data: existing } = productId === 'music_video' && musicTrackId
? await dupQuery.eq('metadata->>music_track_id', musicTrackId).maybeSingle()
: await dupQuery.maybeSingle();
```
insert의 metadata에 music_track_id 포함:
```typescript
metadata: {
method: 'bank_transfer',
depositor_name: depositorName,
...(musicTrackId ? { music_track_id: musicTrackId } : {}),
},
```
insert 성공(orderId 확보) 후, 메일 발송 전/후에:
```typescript
if (productId === 'music_video' && musicTrackId) {
await admin.from('music_tracks')
.update({ video_status: 'requested', video_order_id: orderId })
.eq('id', musicTrackId).eq('user_id', user.id);
}
```
(일반 상품 경로는 위 분기 밖 — 기존 동작 그대로)
- [ ] **Step 2: video-product GET**
`app/api/studio/video-product/route.ts`:
```typescript
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { getProductById } from '@/lib/supabase/product-files';
export const runtime = 'nodejs';
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
const admin = createAdminClient();
const product = await getProductById(admin, 'music_video');
if (!product || !product.is_active) return NextResponse.json({ error: '영상화 상품이 준비 중입니다.' }, { status: 404 });
return NextResponse.json({ product: { id: product.id, name: product.name, price: product.price, is_active: product.is_active } });
}
```
- [ ] **Step 3: 검증·커밋**`npm test && npm run build`(라우트 /api/studio/video-product 등장). `git add app/api/orders/route.ts app/api/studio/video-product/route.ts && git commit -m "feat(phase3b): orders에 영상화(music_video) 발주 + video-product 조회"`
---
### Task 3: BankTransferModal 확장 + 마이페이지 신청/상태
**Files:**
- Modify: `app/components/BankTransferModal.tsx`
- Modify: `app/api/studio/tracks/route.ts` (GET select에 video 컬럼 추가)
- Modify: `app/mypage/page.tsx` (MusicAiCard + MusicTrackRow)
**Interfaces:**
- Consumes: `POST /api/orders`(musicTrackId, T2), `GET /api/studio/video-product`(T2)
- Produces: 없음
- [ ] **Step 1: tracks GET에 video 컬럼**
`app/api/studio/tracks/route.ts` GET select에 `video_status, video_url` 추가:
```typescript
.select('id, title, story, lyrics, style, audio_url, task_id, created_at, video_status, video_url')
```
- [ ] **Step 2: BankTransferModal에 musicTrackId optional prop**
`app/components/BankTransferModal.tsx`: Props에 `musicTrackId?: string` 추가. POST body를 조건부 병합:
```typescript
body: JSON.stringify({ productId: product.id, depositorName: name, ...(musicTrackId ? { musicTrackId } : {}) }),
```
(useCallback deps에 `musicTrackId` 추가. 다른 로직·UI 무변경)
- [ ] **Step 3: 마이페이지 음악 카드 영상화 UI**
`app/mypage/page.tsx`:
- `MusicTrackRow` 타입에 `video_status?: 'none'|'requested'|'in_progress'|'delivered'`, `video_url?: string | null` 추가
- state: `videoProduct`({id,name,price} | null), `videoModalTrackId`(string | null)
- `MusicAiCard`에 video 영역:
- `video_status` 없음/`'none'`: "영상화 신청" 버튼 → `GET /api/studio/video-product``videoProduct` 로드 후 `videoModalTrackId=track.id``BankTransferModal` 오픈(product=videoProduct, musicTrackId=track.id, onClose로 목록 갱신 loadAiRecords)
- `'requested'`: 뱃지 "영상화 접수됨 · 입금 확인 중"
- `'in_progress'`: 뱃지 "영상 제작 중"
- `'delivered'` + `video_url`: `<video controls src={video_url} className="...">` + "영상 다운로드" 링크(`href={video_url}` download)
- 뱃지·버튼은 `--jsm-*` 토큰(gradient/보라/이모지 금지)
- 페이지 하단에 `BankTransferModal` 1개 마운트: `isOpen={!!videoModalTrackId && !!videoProduct}`, `product={videoProduct}`, `musicTrackId={videoModalTrackId}`, `onClose={() => { setVideoModalTrackId(null); loadAiRecords(); }}`
- 기존 사주·타로 카드·로직 미변경
- [ ] **Step 4: 검증·커밋**`npm test && npm run build`. `grep -nE "gradient|violet|purple|blur" app/mypage/page.tsx app/components/BankTransferModal.tsx`(신규분 0). `git add app/components/BankTransferModal.tsx app/api/studio/tracks/route.ts app/mypage/page.tsx && git commit -m "feat(phase3b): 마이페이지 음악 영상화 신청·상태 + 모달 트랙 연결"`
---
### Task 4: admin 납품 (연결 트랙 표시 + 영상 URL 입력)
**Files:**
- Modify: `app/api/admin/orders/route.ts` (GET 트랙 조인 + PATCH videoUrl/videoStatus)
- Modify: `app/admin/orders/page.tsx` (music_video 주문 UI)
**Interfaces:**
- Consumes: music_tracks(T1), orders metadata.music_track_id(T2)
- Produces: 없음
- [ ] **Step 1: admin/orders GET에 트랙 정보 조인**
`app/api/admin/orders/route.ts` GET: 주문 조회 후, `product_id==='music_video'`인 주문들의 `metadata.music_track_id`를 모아 `music_tracks`에서 `id,title,audio_url,video_status,video_url` 조회 → 각 주문에 `musicTrack` 필드로 첨부. 응답 형태에 `musicTrack?: { id, title, audio_url, video_status, video_url }` 포함.
- [ ] **Step 2: admin/orders PATCH에 videoUrl/videoStatus**
`app/api/admin/orders/route.ts` PATCH: body `{ id, status?, videoUrl?, videoStatus? }`. 기존 status 검증은 status가 있을 때만:
```typescript
const { id, status, videoUrl, videoStatus } = await request.json();
if (typeof id !== 'string') return NextResponse.json({ error: 'invalid request' }, { status: 400 });
if (status !== undefined && !['pending','paid','cancelled'].includes(status)) return NextResponse.json({ error: 'invalid status' }, { status: 400 });
if (videoStatus !== undefined && !['none','requested','in_progress','delivered'].includes(videoStatus)) return NextResponse.json({ error: 'invalid videoStatus' }, { status: 400 });
```
- status 있으면 기존 orders update + paid 메일(기존 로직 유지)
- 해당 주문이 music_video이고 `status==='paid'`이면(또는 videoUrl 제공되면): 주문 metadata.music_track_id로 `music_tracks` update `video_url=videoUrl ?? 기존, video_status='delivered'`
- `videoStatus==='in_progress'`만 오면: 트랙 `video_status='in_progress'`만 갱신(status 변경 없음)
- 주문의 metadata·product_id 조회는 update 전에 select로 확보
- [ ] **Step 3: admin/orders 페이지 UI**
`app/admin/orders/page.tsx`: `product_id==='music_video'` 주문 행에
- 연결 트랙 표시(`musicTrack.title`, `<audio src={musicTrack.audio_url}>`)
- 영상 URL `<input>` + "납품 완료" 버튼 → `PATCH { id, status:'paid', videoUrl }`
- "제작 중" 버튼 → `PATCH { id, videoStatus:'in_progress' }`
- 기존 일반 주문 행 렌더·paid/cancel 버튼 무변경. admin 다크 콘솔 관용구 유지
- [ ] **Step 4: 검증·커밋**`npm test && npm run build`. `git add app/api/admin/orders/route.ts app/admin/orders/page.tsx && git commit -m "feat(phase3b): admin 영상화 납품 — 연결 트랙 표시 + 영상 URL 입력"`
---
### Task 5: CLAUDE.md + 로드맵 메모 + 최종 검증
**Files:**
- Modify: `CLAUDE.md`
**Interfaces:**
- Consumes: Task 1~4 완료
- Produces: 문서 정합
- [ ] **Step 1: CLAUDE.md 갱신**
- 결제/발주 플로우 섹션에 "음악 영상화(music_video) — 마이페이지 음악 카드 발주 → admin URL 납품 → video_status delivered" 추가
- 파일 구조에 `api/studio/video-product` 반영
- music_tracks의 video 컬럼 언급(있으면 사주 시스템 하단 음악 관련 서술에)
- 무관 섹션 무변
- [ ] **Step 2: 최종 검증**
```bash
npm run build # /api/studio/video-product, /mypage, /admin/orders 정상
npm test # 31 PASS
grep -nE "gradient|violet|purple|blur" app/mypage/page.tsx app/components/BankTransferModal.tsx # 신규분 0
```
- [ ] **Step 3: 커밋**`git add CLAUDE.md && git commit -m "docs(phase3b): CLAUDE.md — 음악 영상화 발주 플로우 반영"`
- [ ] **Step 4: CEO 안내(보고) + 로드맵**
- 마이그레이션 `2026-07-09-phase3b-music-video.sql`을 클라우드+NAS 양쪽 적용(**phase3a 후**). 순서: phase0→1→2→3a→3b
- 수동 E2E: 마이페이지 음악 카드 "영상화 신청"→계좌이체→admin/orders에서 URL 입력·납품 완료→마이페이지 영상 재생
- 스펙 §"향후 방향 기록"의 5개 후보를 다음 방향 논의 근거로 제시
---
## 검증 요약
| 검증 | 명령 | 기대 |
|------|------|------|
| 단위 테스트 | `npm test` | 31 PASS(무변) |
| 빌드 | `npm run build` | /api/studio/video-product·/mypage·/admin/orders |
| 가드레일 | grep(신규 UI) | 0건 |
| 회귀 | 일반 상품 발주 경로 미변경 | orders POST 일반 분기 무변 |

View File

@@ -0,0 +1,104 @@
# Phase 3b 음악 영상화 유료 발주 설계
- 날짜: 2026-07-09
- 선행: Phase 3a(음악 공개화·스토리→음악) main 머지 완료(677012a), /music 카피 정리(6a67a9d)
- 배경: 음악 서비스 2번째 축 = "영상화"(유료). 회원이 저장한 음악 트랙을 계좌이체 발주하면 관리자가 수동 제작·납품. 자동 영상 생성은 범위 밖.
## 결정 사항 (CEO 확정, 2026-07-09)
| 결정 | 내용 |
|------|------|
| 영상화 방식 | **계좌이체 발주 + 관리자 수동 제작·납품** (Phase 3 스코핑서 확정) |
| 가격 | **고정가 ₩30,000**`music_video` products 1행, admin/products에서 조정 가능 |
| 주문 모델 | **기존 orders 재사용** + `metadata.music_track_id` 트랙 참조. music_tracks에 video 상태 추가 |
| 납품 | **`music_tracks.video_url`** — 관리자가 admin/orders에서 완성 영상 URL 직접 입력(별도 업로더 없음) |
| 진입점 | **마이페이지 AI기록 음악 카드**의 "영상화 신청" 버튼 |
## 확인된 기존 구조
- `products`: `{ id, name, price, is_active, ... }` (`lib/supabase/product-files.ts` ProductRow, `getProductById(admin, id)`)
- `POST /api/orders`: 인증→rate-limit→productId/depositorName 검증→`getProductById`(active)→**중복 pending 가드**(user+product)→insert `{user_id, product_id, amount:product.price, status:'pending', metadata:{method:'bank_transfer', depositor_name}}`→접수 메일
- `admin/orders PATCH`: body `{id, status∈pending|paid|cancelled}` → update; paid 전환 시 `sendOrderPaidEmail`
- `BankTransferModal({ product, isOpen, onClose })`: POST `{ productId: product.id, depositorName }`
- `music_tracks`(Phase 3a): `{ id, user_id, title, story, lyrics, style, audio_url, task_id, created_at }`, RLS `music_select_own`
- 마이페이지 AI기록 탭: 사주·타로·음악 병합, `MusicAiCard`(제목·스토리·`<audio>`)
## 워크스트림
### WS1. DB 마이그레이션 (products 시드 + music_tracks 확장)
- 신규 `supabase/migrations/2026-07-09-phase3b-music-video.sql`:
```sql
-- 의존성: 2026-07-03-phase3a-music.sql(music_tracks) 적용 후
INSERT INTO products (id, name, price, is_active)
VALUES ('music_video', '음악 영상화', 30000, true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE music_tracks
ADD COLUMN IF NOT EXISTS video_status text NOT NULL DEFAULT 'none'
CHECK (video_status IN ('none','requested','in_progress','delivered')),
ADD COLUMN IF NOT EXISTS video_url text,
ADD COLUMN IF NOT EXISTS video_order_id uuid;
```
(products 실제 컬럼 확인 후 필수 NOT NULL 컬럼 있으면 값 채워 시드. 클라우드+NAS 양쪽)
### WS2. 발주 API 확장 (orders + music_tracks 연결)
- `POST /api/orders` 확장: body에 optional `musicTrackId`(string) 허용
- `productId==='music_video'`이고 `musicTrackId` 있으면: admin으로 `music_tracks`에서 `id=musicTrackId AND user_id=user.id` 조회 → 없으면 404(본인 트랙 아님)
- metadata에 `music_track_id` 포함해 insert
- **중복 가드 확장**: music_video는 `(user_id, product_id='music_video', metadata->>music_track_id = trackId, status='pending')`로 조회(트랙별). 일반 상품은 기존 (user, product) 가드 유지
- 주문 insert 성공 후: `music_tracks` update `video_status='requested', video_order_id=order.id` where `id=musicTrackId AND user_id=user.id`
- 신규 `GET /api/studio/video-product`: 로그인, `music_video` 제품 `{ id, name, price, is_active }` 반환(모달 가격 표시용). 미존재/비활성 시 안내
### WS3. 마이페이지 신청 진입점 + 상태 표시
- `BankTransferModal` 확장: optional prop `musicTrackId?: string` → POST body에 `musicTrackId` 병합(없으면 기존 동작 그대로)
- 마이페이지 `MusicAiCard`(AI기록 탭):
- `video_status` 뱃지 — `none`: "영상화 신청" 버튼(→ `GET /api/studio/video-product`로 제품 로드 후 `BankTransferModal` 오픈, `musicTrackId` 전달) / `requested`: "영상화 접수됨(입금 확인 중)" / `in_progress`: "영상 제작 중" / `delivered`: "영상 완료"
- `delivered` + `video_url` 있으면 `<video controls src={video_url}>` + 다운로드 링크
- `MusicTrackRow` 타입에 `video_status`, `video_url` 추가(GET /api/studio/tracks가 이 컬럼 반환하도록 select 확장)
- 신청 완료 후 목록 갱신(status='requested' 반영)
### WS4. admin 납품
- `app/admin/orders/page.tsx`: `product_id==='music_video'` 주문에
- 연결 트랙 표시(metadata.music_track_id로 music_tracks 조회 — GET 응답에 트랙 title/audio_url 조인 포함)
- **영상 URL 입력 + "납품 완료" 버튼** → `PATCH /api/admin/orders` `{ id, status:'paid', videoUrl }`
- (선택) "제작 중" 버튼 → `{ id, videoStatus:'in_progress' }`
- `admin/orders GET` 확장: music_video 주문에 연결 트랙 정보(title, audio_url) 포함(admin이 metadata.music_track_id로 music_tracks 조인)
- `admin/orders PATCH` 확장:
- optional `videoUrl`, `videoStatus` 허용
- `status='paid'` + music_video 주문이면: 주문 metadata.music_track_id로 `music_tracks` update `video_url=videoUrl, video_status='delivered'`
- `videoStatus='in_progress'`만 오면 status 변경 없이 트랙 video_status만 갱신
- 기존 일반 상품 paid 흐름·메일은 무변경
### WS5. 메일 + 문서 + 검증
- 발주 접수/완료 메일 재사용(`sendOrderReceivedEmails`/`sendOrderPaidEmail`). 영상화 문구는 제품명("음악 영상화")으로 자연 반영 — 별도 템플릿 추가 없음(YAGNI)
- CLAUDE.md: 결제/발주 플로우에 영상화(music_video) 추가, music_tracks video 컬럼·admin 납품 반영
- 검증: `npm run build`·`npm test`·가드레일(신규 UI 라이트) grep, 수동 E2E(신청→admin 납품→마이페이지 완료 재생)
## 향후 방향 기록 (로드맵 — "추후 방향 재설정" 근거)
이 스펙 완료 시점 기준 전체 상태와 다음 후보를 기록한다(메모리 `saas-refactor-2026-07`에도 반영).
**완료(main·양쪽 리모트 푸시)**: Phase 0(정리) · 1(외주 코어) · 2(사주/타로) · 2.5/2.6(사주 재스킨) · 3a(음악 공개화) · /music 카피 · **3b(영상화 발주 — 이 스펙)**
**미결 운영(CEO 수동, 코드 불가)**:
- DB 마이그레이션 **5개** 순차 적용(클라우드+NAS): phase0 → phase1 → phase2 → phase3a → **phase3b**
- 운영 env: `GEMINI_API_KEY`(사주·타로·스토리), `SUNO_API_KEY`(음악), `RESEND_API_KEY`(메일)
- 수동 E2E 전 서비스 스모크
**향후 후보(다음 방향 잡기용)**:
1. **영상 자동 생성 연동** — 수동 제작 → sora/veo/kling 등 API(비용·게이트웨이 설계 필요, NAS video-lab 참고)
2. **NAS 풀 self-host 컷오버** — Vercel/클라우드 Supabase → NAS(진행 중 project [[nas-selfhost-transition]])
3. **운영 대시보드·정산** — 서비스별 매출/사용량 통합(admin/analytics 확장), AI 사용량·Suno 비용 집계
4. **회원 등급/구독 재도입** — Phase 0에서 제거한 subscription 인프라 재설계(SaaS 피벗 시)
5. **사주·타로·음악 크로스 프로모션**, 마케팅 채널(admin 광고 관리) 활용 캠페인
## 범위 밖 (Phase 3b)
- 영상 자동 생성, 영상 편집 UI, 관리자 파일 업로더(URL 문자열로 충분)
- 결제 PG(계좌이체 단일 소스 유지)
## 리스크·주의
- orders POST의 중복 가드를 music_video만 트랙 단위로 분기 — 일반 상품 가드 회귀 없도록 주의
- `metadata->>music_track_id` jsonb 쿼리 문법(Supabase) 정확히
- BankTransferModal은 다른 상품(products)도 쓰므로 musicTrackId는 optional, 미전달 시 기존 동작 불변
- video_url은 관리자 신뢰 입력(공개 재생) — 값 검증은 최소(문자열 길이), XSS는 `<video src>` 특성상 낮으나 URL 형식 권장
- admin/orders에 music_video 조인 추가 시 기존 일반 주문 렌더 회귀 없도록

View File

@@ -0,0 +1,13 @@
-- Phase 3b (2026-07-09): 음악 영상화 발주
-- 의존성: 2026-07-03-phase3a-music.sql(music_tracks) 적용 후
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
INSERT INTO products (id, name, price, category, is_active)
VALUES ('music_video', '음악 영상화', 30000, 'software', true)
ON CONFLICT (id) DO NOTHING;
ALTER TABLE music_tracks
ADD COLUMN IF NOT EXISTS video_status text NOT NULL DEFAULT 'none'
CHECK (video_status IN ('none','requested','in_progress','delivered')),
ADD COLUMN IF NOT EXISTS video_url text,
ADD COLUMN IF NOT EXISTS video_order_id uuid;