@
docs(phase3b): 음악 영상화 발주 구현 플랜 (5 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:
272
docs/superpowers/plans/2026-07-09-phase3b-music-video-order.md
Normal file
272
docs/superpowers/plans/2026-07-09-phase3b-music-video-order.md
Normal 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 일반 분기 무변 |
|
||||
Reference in New Issue
Block a user