Files
jaengseung-made/docs/superpowers/plans/2026-07-09-phase3b-music-video-order.md
gahusb 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

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은 다른 상품도 사용 — musicTrackIdoptional, 미전달 시 기존 동작 불변
  • 인증→검증→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: productsmusic_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 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 이전에:

// 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-productvideoProduct 로드 후 videoModalTrackId=track.idBankTransferModal 오픈(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가 있을 때만:

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: 최종 검증

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 일반 분기 무변