docs(phase3b): 음악 영상화 발주 구현 플랜 (5 Task) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ @
15 KiB
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 selectid,user_id,product_id,amount,status,metadata,created_at; PATCH{id, status∈pending|paid|cancelled}, paid 시sendOrderPaidEmailBankTransferModal({ 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), RLSmusic_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:
-- 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 buildPASS.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/ordersbody에 optionalmusicTrackId— 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 이전에:
// 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면 트랙 단위로
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 포함:
metadata: {
method: 'bank_transfer',
depositor_name: depositorName,
...(musicTrackId ? { music_track_id: musicTrackId } : {}),
},
insert 성공(orderId 확보) 후, 메일 발송 전/후에:
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:
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 추가:
.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를 조건부 병합:
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/보라/이모지 금지)
-
페이지 하단에
BankTransferModal1개 마운트: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가 있을 때만:
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_tracksupdatevideo_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: 최종 검증
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 일반 분기 무변 |