Files
jaengseung-made/docs/superpowers/plans/2026-05-02-mypage-phase2-nas-downloads.md
gahusb 0c6a86d96d docs(plan): mypage Phase 2 — NAS 다운로드 자동화 implementation plan (15 tasks, 2 repos)
4 phase 구성:
- Phase A (web-backend repo, 6 tasks): packs-lab 스캐폴드 + HMAC 인증 + DSM client + routes + tests + docker/nginx
- Phase B (jaengseung-made, 6 tasks): supabase migration + lib helpers + pack-assets 마이그레이션 + 3 API routes
- Phase C (jaengseung-made, 3 tasks): admin packs 페이지 + sidebar + mypage 다운로드 활성화
- Phase D (2 tasks): 통합 smoke test (수동) + 메모리 갱신

부록 A: 단계별 빌드 안전성 분석 (B3-C3 사이 빌드 깨짐 → C3 commit 직후 push로 일괄 배포)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:52:41 +09:00

79 KiB

mypage Phase 2 — NAS 자료 다운로드 자동화 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: Music 팩 구매자가 mypage에서 자료를 자동 다운로드할 수 있게 한다 — admin이 /admin/packs에서 자료 업로드, order.status='completed' 마킹 후 사용자가 [다운로드] 클릭 시 4시간 만료 DSM 공유 링크 발급.

Architecture: 두 repo 작업. web-backend/packs-lab/ (FastAPI, NAS) 신규 lab — DSM API 호출 + 5GB multipart 업로드 수신. jaengseung-made (Vercel/Next.js) — supabase 인증/권한 검증 + admin UI + mypage 다운로드. Vercel function body limit(4.5MB) 우회: admin 업로드는 Vercel이 일회성 HMAC 토큰만 발급 → 브라우저가 web-backend로 직접 multipart POST.

Tech Stack: Next.js 16 App Router + TS + Tailwind v4 + Supabase / FastAPI + httpx + pytest / Docker Compose + nginx / Synology DSM 7.x SYNO.FileStation.Sharing v3.

Spec: docs/superpowers/specs/2026-05-02-mypage-phase2-nas-downloads-design.md

Two repos:

  • C:\Users\jaeoh\Desktop\workspace\jaengseung-made (this repo, Vercel)
  • C:\Users\jaeoh\Desktop\workspace\web-backend (separate repo, Gitea webhook → NAS auto-deploy)

File Structure

web-backend (NAS) — 신규 packs-lab 라이프

web-backend/packs-lab/
├── Dockerfile
├── app/
│   ├── __init__.py
│   ├── main.py             # FastAPI app, CORS, healthcheck, router 마운트
│   ├── auth.py             # HMAC 검증 (Vercel ↔ backend, 일회성 upload 토큰)
│   ├── dsm_client.py       # DSM API wrapper — login/logout/Sharing.create
│   ├── routes.py           # 4 endpoint: upload / sign-link / list / delete
│   ├── models.py           # pydantic 요청·응답 스키마
│   └── requirements.txt
└── tests/
    ├── __init__.py
    ├── test_auth.py        # HMAC 검증 단위 테스트
    └── test_routes.py      # 라우트 통합 테스트 (DSM mock)

기타:

  • web-backend/docker-compose.yml: 신규 service packs-lab 추가
  • web-backend/nginx/default.conf: /api/packs/* 라우팅 + /api/packs/uploadclient_max_body_size 5G
  • NAS 운영 .env: DSM_HOST, DSM_USER, DSM_PASS, BACKEND_HMAC_SECRET, SUPABASE_URL, SUPABASE_SERVICE_KEY 추가

jaengseung-made (Vercel) — 추가/수정

supabase/migrations/
└── 2026-05-02-create-pack-files.sql   # CREATE TABLE pack_files + index + RLS

lib/
├── pack-assets.ts                     # MODIFY: PACK_ASSETS.files 제거, PACK_TIER_NAMES export 추가
├── supabase/pack-files.ts             # CREATE: 쿼리 헬퍼 + tier hierarchy
└── web-backend.ts                     # CREATE: web-backend HMAC 클라이언트 (signLink, mintUploadToken)

app/api/packs/sign-link/route.ts       # CREATE: 사용자 다운로드 권한 검증 + 프록시
app/api/admin/packs/upload-url/route.ts # CREATE: admin 일회성 업로드 토큰 발급
app/api/admin/packs/route.ts           # CREATE: 목록(GET) + 편집(PATCH) + 삭제(DELETE)

app/admin/packs/page.tsx               # CREATE: admin 자료 관리 UI
app/admin/components/AdminSidebar.tsx  # MODIFY: "팩 자료" 메뉴 추가
app/mypage/page.tsx                    # MODIFY: 다운로드 버튼 활성화 + status 분기

검증 인프라

  • web-backend: pytest 사용 가능 (web-backend/personal/에서 패턴 확인됨). 새 lab tests/도 pytest로.
  • jaengseung-made: jest/vitest 미설치 → npx eslint + npm run build + 시각/integration 수동.

Task 순서 (의존성)

Phase A (Backend foundation, web-backend)
  A1 → A2 → A3 → A4 → A5 → A6
Phase B (DB + Vercel API, jaengseung-made)
  B1 (DB) → B2, B3 (lib) → B4, B5, B6 (API routes)
Phase C (Frontend, jaengseung-made)
  C1 → C2 → C3
Phase D (Integration)
  D1 (smoke test) → D2 (memory 갱신)

Phase A를 먼저 끝낸 후 Phase B 시작 (B의 API가 A의 backend 호출). Phase C는 B 완료 후. Phase D는 마지막.


Phase A — Backend Foundation (web-backend)

Task A1: packs-lab 스캐폴드

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\Dockerfile

  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\app\__init__.py (빈 파일)

  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\app\requirements.txt

  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\app\main.py

  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\app\models.py

  • Step 1: Dockerfile (lotto/Dockerfile 패턴 그대로)

FROM python:3.12-slim

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates curl \
  && rm -rf /var/lib/apt/lists/*

COPY app/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt

COPY app /app/app

ENV PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  • Step 2: requirements.txt
fastapi==0.115.6
uvicorn[standard]==0.32.1
httpx==0.28.1
python-multipart==0.0.20
supabase==2.12.0
pydantic==2.10.4
  • Step 3: app/init.py — 빈 파일
# packs-lab package
  • Step 4: app/models.py — pydantic 스키마
"""Pydantic schemas for packs API."""
from datetime import datetime
from typing import Literal

from pydantic import BaseModel, Field

PackTier = Literal["starter", "pro", "master"]


class SignLinkRequest(BaseModel):
    """Vercel → backend: 사용자 다운로드 링크 발급 요청."""
    file_path: str = Field(..., description="NAS 절대 경로 — pack_files.file_path 그대로")
    expires_in_seconds: int = Field(default=14400, description="공유 링크 만료 (기본 4시간)")


class SignLinkResponse(BaseModel):
    url: str
    expires_at: datetime


class UploadResponse(BaseModel):
    file_id: str  # uuid
    file_path: str
    filename: str
    size_bytes: int
    min_tier: PackTier
    label: str
    uploaded_at: datetime
  • Step 5: app/main.py — FastAPI 앱 + healthcheck
"""packs-lab FastAPI application.

NAS 자료 다운로드 자동화 — DSM 공유 링크 발급 + 5GB 멀티파트 업로드 수신.
모든 Vercel 호출은 HMAC 인증. 사용자 다운로드는 Vercel이 supabase 인증 후 프록시.
"""
import logging
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s %(message)s")
logger = logging.getLogger("packs-lab")


@asynccontextmanager
async def lifespan(app: FastAPI):
    # DSM credentials presence check
    for key in ("DSM_HOST", "DSM_USER", "DSM_PASS", "BACKEND_HMAC_SECRET"):
        if not os.getenv(key):
            logger.warning("환경변수 %s 미설정 — packs-lab 일부 기능 작동 안 함", key)
    logger.info("packs-lab 시작")
    yield


app = FastAPI(lifespan=lifespan, title="packs-lab", version="1.0.0")


@app.get("/health")
def health():
    return {"status": "ok", "service": "packs-lab"}
  • Step 6: 빌드 검증 (Dockerfile syntax)

이 task는 배포 X. 다음 task에서 통합 빌드.

  • Step 7: 커밋 (web-backend repo)
cd /c/Users/jaeoh/Desktop/workspace/web-backend
git add packs-lab/
git commit -m "feat(packs-lab): 스캐폴드 — Dockerfile + main.py + models.py"

Task A2: HMAC 인증 모듈

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\app\auth.py
  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\tests\__init__.py (빈 파일)
  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\tests\test_auth.py

이 task는 TDD로 — 테스트 먼저, 그 다음 구현.

  • Step 1: tests/init.py 생성

빈 파일.

  • Step 2: tests/test_auth.py — 실패 테스트 작성
"""HMAC 인증 단위 테스트.

- verify_request_hmac: Vercel ↔ backend 요청 시그니처 검증
- verify_upload_token: 일회성 업로드 토큰 검증 (jti 단발성)
"""
import json
import os
import time
import uuid

import pytest
from fastapi import HTTPException

# 환경변수 먼저 세팅 (auth import 전)
os.environ["BACKEND_HMAC_SECRET"] = "test-secret-32-bytes-XXXXXXXXXXXX"

from app import auth  # noqa: E402


def test_verify_request_hmac_valid():
    body = b'{"file_path":"/x"}'
    ts = str(int(time.time()))
    sig = auth._sign(ts.encode() + b"." + body)
    auth.verify_request_hmac(body, ts, sig)  # 예외 X


def test_verify_request_hmac_expired():
    body = b'{}'
    old_ts = str(int(time.time()) - 600)  # 10분 전
    sig = auth._sign(old_ts.encode() + b"." + body)
    with pytest.raises(HTTPException) as exc:
        auth.verify_request_hmac(body, old_ts, sig)
    assert exc.value.status_code == 401


def test_verify_request_hmac_wrong_sig():
    body = b'{}'
    ts = str(int(time.time()))
    with pytest.raises(HTTPException):
        auth.verify_request_hmac(body, ts, "deadbeef")


def test_upload_token_roundtrip():
    payload = {
        "tier": "master",
        "label": "샘플 영상",
        "filename": "sample.mp4",
        "size_bytes": 1024,
        "expires_at": int(time.time()) + 600,
        "jti": uuid.uuid4().hex,
    }
    token = auth.mint_upload_token(payload)
    decoded = auth.verify_upload_token(token)
    assert decoded["filename"] == "sample.mp4"
    assert decoded["jti"] == payload["jti"]


def test_upload_token_replay_rejected():
    payload = {
        "tier": "starter",
        "label": "x",
        "filename": "y.pdf",
        "size_bytes": 1,
        "expires_at": int(time.time()) + 600,
        "jti": uuid.uuid4().hex,
    }
    token = auth.mint_upload_token(payload)
    auth.verify_upload_token(token)  # 첫 사용 OK
    with pytest.raises(HTTPException) as exc:
        auth.verify_upload_token(token)  # 재사용
    assert "이미" in exc.value.detail or "used" in exc.value.detail.lower()


def test_upload_token_expired():
    payload = {
        "tier": "starter",
        "label": "x",
        "filename": "y.pdf",
        "size_bytes": 1,
        "expires_at": int(time.time()) - 100,
        "jti": uuid.uuid4().hex,
    }
    token = auth.mint_upload_token(payload)
    with pytest.raises(HTTPException):
        auth.verify_upload_token(token)
  • Step 3: 테스트 실행 (실패 확인)
cd /c/Users/jaeoh/Desktop/workspace/web-backend/packs-lab
python -m pytest tests/test_auth.py -v

Expected: ImportError 또는 ModuleNotFoundError (auth.py 없음).

  • Step 4: app/auth.py — 구현
"""HMAC 인증.

- verify_request_hmac: Vercel ↔ backend 요청 검증.
  Vercel이 X-Timestamp + X-Signature 헤더로 보냄. signature = HMAC(timestamp.body, secret).
  요청은 5분 이내여야 함 (replay 방어).

- mint_upload_token / verify_upload_token: admin 5GB 업로드 일회성 토큰.
  Vercel이 발급, browser가 web-backend에 직접 multipart POST 시 Authorization: Bearer <token>.
  JTI 단발성으로 재사용 차단.
"""
import base64
import hashlib
import hmac
import json
import os
import time
from threading import Lock

from fastapi import HTTPException

_SECRET = os.getenv("BACKEND_HMAC_SECRET", "")
REQUEST_MAX_AGE_SEC = 300  # 5분

# JTI 단발성 set (in-memory, 단일 컨테이너 가정)
_used_jti: set[str] = set()
_jti_lock = Lock()


def _sign(payload: bytes) -> str:
    if not _SECRET:
        raise HTTPException(status_code=503, detail="BACKEND_HMAC_SECRET 미설정")
    return hmac.new(_SECRET.encode(), payload, hashlib.sha256).hexdigest()


def verify_request_hmac(body: bytes, timestamp: str, signature: str) -> None:
    """Vercel → backend 요청 시그니처 검증."""
    if not timestamp or not signature:
        raise HTTPException(status_code=401, detail="HMAC 헤더 누락")
    try:
        ts = int(timestamp)
    except ValueError:
        raise HTTPException(status_code=401, detail="잘못된 timestamp")
    age = abs(int(time.time()) - ts)
    if age > REQUEST_MAX_AGE_SEC:
        raise HTTPException(status_code=401, detail="요청이 만료됨")
    expected = _sign(timestamp.encode() + b"." + body)
    if not hmac.compare_digest(expected, signature):
        raise HTTPException(status_code=401, detail="HMAC 시그니처 불일치")


def mint_upload_token(payload: dict) -> str:
    """일회성 업로드 토큰 발급. payload는 expires_at + jti 포함해야 함."""
    body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
    sig = _sign(body)
    return base64.urlsafe_b64encode(body).decode() + "." + sig


def verify_upload_token(token: str) -> dict:
    """업로드 토큰 검증 + jti 사용 마킹."""
    try:
        b64, sig = token.split(".", 1)
        body = base64.urlsafe_b64decode(b64.encode())
    except Exception:
        raise HTTPException(status_code=401, detail="잘못된 토큰 포맷")

    expected = _sign(body)
    if not hmac.compare_digest(expected, sig):
        raise HTTPException(status_code=401, detail="토큰 시그니처 불일치")

    payload = json.loads(body)
    expires_at = payload.get("expires_at", 0)
    if int(time.time()) > expires_at:
        raise HTTPException(status_code=401, detail="토큰 만료")

    jti = payload.get("jti")
    if not jti:
        raise HTTPException(status_code=401, detail="jti 누락")

    with _jti_lock:
        if jti in _used_jti:
            raise HTTPException(status_code=409, detail="이미 사용된 토큰")
        _used_jti.add(jti)

    return payload
  • Step 5: pytest.ini 또는 pyproject.toml로 import path 셋업

packs-lab/pytest.ini 생성:

[pytest]
testpaths = tests
pythonpath = .
  • Step 6: 테스트 재실행 (전부 통과)
cd /c/Users/jaeoh/Desktop/workspace/web-backend/packs-lab
pip install pytest httpx fastapi pydantic
python -m pytest tests/test_auth.py -v

Expected: 6 tests passed.

  • Step 7: 커밋
cd /c/Users/jaeoh/Desktop/workspace/web-backend
git add packs-lab/app/auth.py packs-lab/tests/__init__.py packs-lab/tests/test_auth.py packs-lab/pytest.ini
git commit -m "feat(packs-lab): HMAC 인증 모듈 + 단위 테스트 (request HMAC + upload token)"

Task A3: DSM 클라이언트

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\app\dsm_client.py

이 task는 외부 시스템(DSM) 의존이라 mock 기반 단위 테스트는 제한적. 통합 테스트는 D1에서 실 NAS로.

  • Step 1: dsm_client.py — 구현
"""Synology DSM 7.x API 클라이언트.

각 호출 = login → 작업 → logout (세션 풀링은 v1.1+에서). 단일 컨테이너 + 동시성 낮음 가정.

- create_share_link(file_path, expires_in_sec) -> share URL
"""
import logging
import os
from datetime import datetime, timedelta, timezone

import httpx

logger = logging.getLogger("packs-lab.dsm")

DSM_HOST = os.getenv("DSM_HOST", "")  # 예: https://gahusb.synology.me:5001
DSM_USER = os.getenv("DSM_USER", "")
DSM_PASS = os.getenv("DSM_PASS", "")

API_AUTH = "/webapi/auth.cgi"
API_SHARE = "/webapi/entry.cgi"


class DSMError(RuntimeError):
    pass


async def _login(client: httpx.AsyncClient) -> str:
    """DSM 세션 sid 반환."""
    if not all([DSM_HOST, DSM_USER, DSM_PASS]):
        raise DSMError("DSM 환경변수 미설정")
    r = await client.get(
        f"{DSM_HOST}{API_AUTH}",
        params={
            "api": "SYNO.API.Auth",
            "version": "7",
            "method": "login",
            "account": DSM_USER,
            "passwd": DSM_PASS,
            "session": "FileStation",
            "format": "sid",
        },
        timeout=15.0,
    )
    r.raise_for_status()
    data = r.json()
    if not data.get("success"):
        raise DSMError(f"DSM login 실패: {data.get('error')}")
    return data["data"]["sid"]


async def _logout(client: httpx.AsyncClient, sid: str) -> None:
    try:
        await client.get(
            f"{DSM_HOST}{API_AUTH}",
            params={
                "api": "SYNO.API.Auth",
                "version": "7",
                "method": "logout",
                "session": "FileStation",
                "_sid": sid,
            },
            timeout=10.0,
        )
    except Exception as e:
        logger.warning("DSM logout 실패: %s", e)


async def create_share_link(file_path: str, expires_in_sec: int = 14400) -> tuple[str, datetime]:
    """파일 공유 링크 생성. 반환: (URL, expires_at).

    file_path: NAS 절대경로 (예: /volume1/docker/webpage/media/packs/master/x.mp4)
    expires_in_sec: 만료 (기본 4시간)
    """
    expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in_sec)
    expire_time_ms = int(expires_at.timestamp() * 1000)

    async with httpx.AsyncClient(verify=True) as client:
        sid = await _login(client)
        try:
            r = await client.get(
                f"{DSM_HOST}{API_SHARE}",
                params={
                    "api": "SYNO.FileStation.Sharing",
                    "version": "3",
                    "method": "create",
                    "path": file_path,
                    "date_expired": expire_time_ms,
                    "_sid": sid,
                },
                timeout=15.0,
            )
            r.raise_for_status()
            data = r.json()
            if not data.get("success"):
                raise DSMError(f"DSM Sharing.create 실패: {data.get('error')}")
            links = data["data"]["links"]
            if not links:
                raise DSMError("Sharing 응답에 링크 없음")
            url = links[0]["url"]
            return url, expires_at
        finally:
            await _logout(client, sid)
  • Step 2: import sanity check
cd /c/Users/jaeoh/Desktop/workspace/web-backend/packs-lab
python -c "from app import dsm_client; print('OK', dsm_client.create_share_link.__name__)"

Expected: OK create_share_link

  • Step 3: 커밋
cd /c/Users/jaeoh/Desktop/workspace/web-backend
git add packs-lab/app/dsm_client.py
git commit -m "feat(packs-lab): DSM 7.x API client — Sharing.create wrapper (login/action/logout)"

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\app\routes.py

  • Modify: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\app\main.py (router 마운트)

  • Modify: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\app\models.py (UploadResponse는 A1에 이미 있음, ListItem 추가)

  • Step 1: models.py에 ListItem 추가

기존 models.py 파일 끝에 추가:

class PackFileItem(BaseModel):
    id: str
    min_tier: PackTier
    label: str
    file_path: str
    filename: str
    size_bytes: int
    sort_order: int
    uploaded_at: datetime
  • Step 2: routes.py — 구현
"""packs-lab API 엔드포인트.

- POST /api/packs/sign-link — Vercel HMAC 인증 → DSM 공유 링크
- POST /api/packs/upload — 일회성 토큰 인증 → multipart 저장 + supabase INSERT
- GET /api/packs/list — Vercel HMAC 인증 → pack_files 전체 조회
- DELETE /api/packs/{file_id} — Vercel HMAC 인증 → soft delete + DSM 공유 정리
"""
import logging
import os
import re
import uuid
from pathlib import Path

from fastapi import APIRouter, File, Form, HTTPException, Header, Request, UploadFile
from supabase import Client, create_client

from .auth import verify_request_hmac, verify_upload_token
from .dsm_client import DSMError, create_share_link
from .models import (
    PackFileItem,
    SignLinkRequest,
    SignLinkResponse,
    UploadResponse,
)

logger = logging.getLogger("packs-lab.routes")
router = APIRouter(prefix="/api/packs")

PACK_BASE_DIR = Path("/volume1/docker/webpage/media/packs")
ALLOWED_EXT = {"pdf", "zip", "mp4", "mov", "mkv", "wav", "m4a", "mp3", "png", "jpg", "jpeg", "webp", "prj"}
MAX_BYTES = 5 * 1024 * 1024 * 1024  # 5GB
SAFE_FILENAME = re.compile(r"^[\w가-힣\-\.\(\)\s]+$")


def _supabase() -> Client:
    url = os.getenv("SUPABASE_URL", "")
    key = os.getenv("SUPABASE_SERVICE_KEY", "")
    if not url or not key:
        raise HTTPException(status_code=503, detail="Supabase 설정 미완료")
    return create_client(url, key)


def _check_filename(filename: str) -> str:
    if not SAFE_FILENAME.match(filename):
        raise HTTPException(status_code=400, detail="파일명에 허용되지 않은 문자가 포함되어 있습니다")
    if "/" in filename or "\\" in filename or filename.startswith("."):
        raise HTTPException(status_code=400, detail="잘못된 파일명")
    ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
    if ext not in ALLOWED_EXT:
        raise HTTPException(status_code=400, detail=f"허용되지 않은 확장자: {ext}")
    return filename


@router.post("/sign-link", response_model=SignLinkResponse)
async def sign_link(
    request: Request,
    x_timestamp: str = Header(""),
    x_signature: str = Header(""),
):
    body = await request.body()
    verify_request_hmac(body, x_timestamp, x_signature)
    payload = SignLinkRequest.model_validate_json(body)

    # 경로 안전: PACK_BASE_DIR 하위인지 확인
    abs_path = Path(payload.file_path).resolve()
    if not str(abs_path).startswith(str(PACK_BASE_DIR)):
        raise HTTPException(status_code=400, detail="허용된 경로 외부")

    try:
        url, expires_at = await create_share_link(str(abs_path), payload.expires_in_seconds)
    except DSMError as e:
        logger.error("DSM 오류: %s", e)
        raise HTTPException(status_code=502, detail=f"DSM 오류: {e}")

    return SignLinkResponse(url=url, expires_at=expires_at)


@router.post("/upload", response_model=UploadResponse)
async def upload(
    file: UploadFile = File(...),
    authorization: str = Header(""),
):
    if not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Authorization 헤더 누락")
    token = authorization[len("Bearer "):]
    payload = verify_upload_token(token)

    tier = payload["tier"]
    label = payload["label"]
    filename = _check_filename(payload["filename"])
    expected_size = int(payload["size_bytes"])

    tier_dir = PACK_BASE_DIR / tier
    tier_dir.mkdir(parents=True, exist_ok=True)
    target = tier_dir / filename
    if target.exists():
        raise HTTPException(status_code=409, detail="이미 존재하는 파일명입니다. 다른 이름으로 업로드하거나 기존 파일을 먼저 삭제하세요")

    # multipart 스트림 저장 + 크기 검증
    written = 0
    with target.open("wb") as f:
        while True:
            chunk = await file.read(1024 * 1024)
            if not chunk:
                break
            written += len(chunk)
            if written > MAX_BYTES:
                f.close()
                target.unlink(missing_ok=True)
                raise HTTPException(status_code=413, detail="파일 크기 5GB 초과")
            f.write(chunk)

    if written != expected_size:
        target.unlink(missing_ok=True)
        raise HTTPException(status_code=400, detail=f"실제 크기({written})와 토큰 크기({expected_size}) 불일치")

    # supabase INSERT
    sb = _supabase()
    file_id = str(uuid.uuid4())
    res = sb.table("pack_files").insert({
        "id": file_id,
        "min_tier": tier,
        "label": label,
        "file_path": str(target),
        "filename": filename,
        "size_bytes": written,
    }).execute()
    if not res.data:
        target.unlink(missing_ok=True)
        raise HTTPException(status_code=500, detail="DB INSERT 실패")

    return UploadResponse(
        file_id=file_id,
        file_path=str(target),
        filename=filename,
        size_bytes=written,
        min_tier=tier,
        label=label,
        uploaded_at=res.data[0]["uploaded_at"],
    )


@router.get("/list", response_model=list[PackFileItem])
async def list_files(
    request: Request,
    x_timestamp: str = Header(""),
    x_signature: str = Header(""),
):
    body = await request.body()
    verify_request_hmac(body, x_timestamp, x_signature)
    sb = _supabase()
    res = (
        sb.table("pack_files")
        .select("*")
        .is_("deleted_at", "null")
        .order("min_tier")
        .order("sort_order")
        .execute()
    )
    return [PackFileItem(**r) for r in (res.data or [])]


@router.delete("/{file_id}")
async def delete_file(
    file_id: str,
    request: Request,
    x_timestamp: str = Header(""),
    x_signature: str = Header(""),
):
    body = await request.body()
    verify_request_hmac(body, x_timestamp, x_signature)
    sb = _supabase()
    from datetime import datetime, timezone
    res = sb.table("pack_files").update({
        "deleted_at": datetime.now(timezone.utc).isoformat(),
    }).eq("id", file_id).execute()
    if not res.data:
        raise HTTPException(status_code=404, detail="파일을 찾을 수 없습니다")
    return {"ok": True}
  • Step 3: main.py에 router 마운트

web-backend/packs-lab/app/main.py 마지막 (@app.get("/health") 다음)에 추가:

from . import routes  # noqa: E402

app.include_router(routes.router)
  • Step 4: import sanity check
cd /c/Users/jaeoh/Desktop/workspace/web-backend/packs-lab
python -c "from app.main import app; print('routes:', [r.path for r in app.routes])"

Expected 출력에 /api/packs/sign-link, /api/packs/upload, /api/packs/list, /api/packs/{file_id} 포함.

  • Step 5: 커밋
cd /c/Users/jaeoh/Desktop/workspace/web-backend
git add packs-lab/app/routes.py packs-lab/app/main.py packs-lab/app/models.py
git commit -m "feat(packs-lab): 4 라우트 — sign-link, upload, list, delete (HMAC + supabase)"

Task A5: Routes 통합 테스트 (mock DSM)

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\web-backend\packs-lab\tests\test_routes.py

  • Step 1: 통합 테스트 작성

"""routes.py 통합 테스트 (DSM, supabase는 mock)."""
import os
import time
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi.testclient import TestClient

# 테스트용 환경변수 (auth import 전)
os.environ["BACKEND_HMAC_SECRET"] = "test-secret-32-bytes-XXXXXXXXXXXX"
os.environ["DSM_HOST"] = "https://test.synology.me:5001"
os.environ["DSM_USER"] = "test"
os.environ["DSM_PASS"] = "test"
os.environ["SUPABASE_URL"] = "https://placeholder.supabase.co"
os.environ["SUPABASE_SERVICE_KEY"] = "placeholder-key"

from app import auth  # noqa: E402
from app.main import app  # noqa: E402

client = TestClient(app)


def _signed(body: bytes) -> dict:
    ts = str(int(time.time()))
    sig = auth._sign(ts.encode() + b"." + body)
    return {"X-Timestamp": ts, "X-Signature": sig, "Content-Type": "application/json"}


def test_health():
    r = client.get("/health")
    assert r.status_code == 200
    assert r.json()["service"] == "packs-lab"


@patch("app.routes.create_share_link", new_callable=AsyncMock)
def test_sign_link_success(mock_share):
    mock_share.return_value = ("https://test.synology.me:5001/d/s/abc", datetime.now(timezone.utc))
    body = b'{"file_path":"/volume1/docker/webpage/media/packs/master/x.mp4","expires_in_seconds":14400}'
    r = client.post("/api/packs/sign-link", content=body, headers=_signed(body))
    assert r.status_code == 200
    assert "url" in r.json()


def test_sign_link_no_hmac():
    r = client.post("/api/packs/sign-link", json={"file_path": "/x"})
    assert r.status_code == 401


def test_sign_link_path_outside_base():
    body = b'{"file_path":"/etc/passwd","expires_in_seconds":14400}'
    r = client.post("/api/packs/sign-link", content=body, headers=_signed(body))
    assert r.status_code == 400


def test_upload_invalid_token():
    r = client.post(
        "/api/packs/upload",
        files={"file": ("x.pdf", b"abc", "application/pdf")},
        headers={"Authorization": "Bearer invalid"},
    )
    assert r.status_code == 401


def test_upload_no_auth():
    r = client.post(
        "/api/packs/upload",
        files={"file": ("x.pdf", b"abc", "application/pdf")},
    )
    assert r.status_code == 401


@patch("app.routes._supabase")
def test_list_success(mock_sb):
    mock_table = MagicMock()
    mock_table.select.return_value = mock_table
    mock_table.is_.return_value = mock_table
    mock_table.order.return_value = mock_table
    mock_table.execute.return_value = MagicMock(data=[
        {
            "id": str(uuid.uuid4()),
            "min_tier": "starter",
            "label": "테스트",
            "file_path": "/volume1/.../x.pdf",
            "filename": "x.pdf",
            "size_bytes": 100,
            "sort_order": 0,
            "uploaded_at": "2026-05-02T12:00:00+00:00",
        }
    ])
    mock_sb.return_value.table.return_value = mock_table

    body = b''
    r = client.get("/api/packs/list", headers=_signed(body))
    assert r.status_code == 200
    assert len(r.json()) == 1
  • Step 2: 테스트 실행
cd /c/Users/jaeoh/Desktop/workspace/web-backend/packs-lab
python -m pytest tests/ -v

Expected: 11 tests passed (auth 6 + routes 5 신규 — list 1, sign-link 3, upload 2, health 1 = 7). 실제 카운트: test_auth.py 6 + test_routes.py 7 = 13 passed.

  • Step 3: 커밋
cd /c/Users/jaeoh/Desktop/workspace/web-backend
git add packs-lab/tests/test_routes.py
git commit -m "test(packs-lab): routes 통합 테스트 (DSM·supabase mock)"

Task A6: docker-compose + nginx + .env 변수

Files:

  • Modify: C:\Users\jaeoh\Desktop\workspace\web-backend\docker-compose.yml

  • Modify: C:\Users\jaeoh\Desktop\workspace\web-backend\nginx\default.conf

  • Step 1: docker-compose.yml에 packs-lab 서비스 추가

기존 personal: 블록 다음에 추가 (포트 18910, 그 외 personal과 동일 패턴):

  packs-lab:
    build:
      context: ./packs-lab
    container_name: packs-lab
    restart: unless-stopped
    ports:
      - "18910:8000"
    environment:
      DSM_HOST: ${DSM_HOST}
      DSM_USER: ${DSM_USER}
      DSM_PASS: ${DSM_PASS}
      BACKEND_HMAC_SECRET: ${BACKEND_HMAC_SECRET}
      SUPABASE_URL: ${SUPABASE_URL}
      SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_KEY}
    volumes:
      - ${RUNTIME_PATH:-.}/media/packs:/volume1/docker/webpage/media/packs
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
      interval: 30s
      timeout: 5s
      retries: 3
  • Step 2: nginx/default.conf에 라우팅 추가

기존 location 블록 옆에 추가 (예: personal location 다음):

  # packs-lab — 사용자 다운로드 + admin upload
  location /api/packs/upload {
    resolver 127.0.0.11 valid=10s;
    set $packs_backend packs-lab:8000;
    client_max_body_size 5G;
    proxy_request_buffering off;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_pass http://$packs_backend$request_uri;
    proxy_read_timeout 1800s;
    proxy_send_timeout 1800s;
  }

  location /api/packs/ {
    resolver 127.0.0.11 valid=10s;
    set $packs_backend packs-lab:8000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_pass http://$packs_backend$request_uri;
  }
  • Step 3: 운영 .env에 변수 추가 안내 (실제 NAS .env는 git 미관리)

이 step은 코드 변경 X. 운영자(CEO)가 NAS의 /volume1/docker/webpage/.env에 다음 추가:

DSM_HOST=https://gahusb.synology.me:5001
DSM_USER=web-packs-admin
DSM_PASS=<생성 후 입력>
BACKEND_HMAC_SECRET=<openssl rand -hex 32 결과>
SUPABASE_URL=https://<project>.supabase.co
SUPABASE_SERVICE_KEY=<supabase service role key>

또한 RUNTIME_PATH 환경변수가 정의되어 있는지 확인. 미정의면 docker-compose.yml의 ${RUNTIME_PATH:-.}. 폴백 → /volume1/docker/webpage/media/packs/ 디렉토리 미리 생성 필요.

CEO 절차 (수동):

ssh gahusb.synology.me
sudo mkdir -p /volume1/docker/webpage/media/packs/{starter,pro,master}
sudo chmod 755 /volume1/docker/webpage/media/packs
# DSM 신규 사용자 web-packs-admin 생성 (DSM Web UI > 제어판 > 사용자) — 권한: File Station 읽기/쓰기 + Sharing
# .env 편집 후
docker-compose up -d packs-lab
docker logs -f packs-lab  # healthcheck OK 확인
  • Step 4: 로컬 빌드 확인 (Docker Desktop)

CEO 수동 — NAS 환경 그대로는 로컬 빌드 어렵지만 syntax 검증:

cd /c/Users/jaeoh/Desktop/workspace/web-backend
docker compose config | grep packs-lab  # YAML 파싱 OK 확인
nginx -t -c nginx/default.conf 2>&1 | head -5 || true  # 로컬에 nginx 있으면
  • Step 5: 커밋
cd /c/Users/jaeoh/Desktop/workspace/web-backend
git add docker-compose.yml nginx/default.conf
git commit -m "feat(packs-lab): docker-compose 서비스 + nginx 라우팅 (5GB body limit)"

배포는 Gitea push 시 webhook으로 자동. push는 Phase A 완료 후 Phase D 통합 시점에서.


Phase B — DB + Vercel API (jaengseung-made)

Task B1: Supabase migration — pack_files 테이블

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\supabase\migrations\2026-05-02-create-pack-files.sql

  • Step 1: SQL 마이그레이션 작성

-- Phase 2: pack_files 테이블 — Music 팩 자료 메타데이터 SSOT

create table public.pack_files (
  id uuid primary key default gen_random_uuid(),
  min_tier text not null check (min_tier in ('starter', 'pro', 'master')),
  label text not null,
  file_path text not null unique,
  filename text not null,
  size_bytes bigint not null,
  sort_order int not null default 0,
  uploaded_at timestamptz not null default now(),
  deleted_at timestamptz
);

create index idx_pack_files_tier on public.pack_files(min_tier, sort_order)
  where deleted_at is null;

alter table public.pack_files enable row level security;

-- 일반 사용자 직접 SELECT 차단. /api/packs/* 라우트가 service role로 조회.
-- (RLS enabled + 정책 미정의 → 모든 anon/authenticated SELECT 거부)
  • Step 2: supabase 콘솔에서 적용 (수동)

CEO 수동 단계 — supabase 대시보드 SQL Editor에 위 SQL 붙여넣기 → Run. 또는 supabase CLI:

supabase db push

이 step은 코드 변경 X — 매뉴얼.

  • Step 3: 커밋 (jaengseung-made repo)
cd /c/Users/jaeoh/Desktop/workspace/jaengseung-made
git add supabase/migrations/2026-05-02-create-pack-files.sql
git commit -m "$(cat <<'EOF'
feat(db): pack_files 테이블 마이그레이션 — Phase 2 자료 다운로드 SSOT

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task B2: lib helpers — pack-files.ts + web-backend.ts

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\lib\supabase\pack-files.ts

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\lib\web-backend.ts

  • Step 1: lib/supabase/pack-files.ts

import type { SupabaseClient } from '@supabase/supabase-js';
import type { PackTier } from '../pack-assets';

export interface PackFile {
  id: string;
  min_tier: PackTier;
  label: string;
  file_path: string;
  filename: string;
  size_bytes: number;
  sort_order: number;
  uploaded_at: string;
  deleted_at: string | null;
}

const TIER_HIERARCHY: Record<PackTier, PackTier[]> = {
  starter: ['starter'],
  pro: ['starter', 'pro'],
  master: ['starter', 'pro', 'master'],
};

export function tierIncludes(userTier: PackTier): PackTier[] {
  return TIER_HIERARCHY[userTier];
}

export async function getPackFilesForTiers(
  supabase: SupabaseClient,
  tiers: PackTier[],
): Promise<PackFile[]> {
  if (tiers.length === 0) return [];
  const { data, error } = await supabase
    .from('pack_files')
    .select('*')
    .in('min_tier', tiers)
    .is('deleted_at', null)
    .order('min_tier')
    .order('sort_order');
  if (error) throw error;
  return (data ?? []) as PackFile[];
}

export async function getPackFileById(
  supabase: SupabaseClient,
  id: string,
): Promise<PackFile | null> {
  const { data, error } = await supabase
    .from('pack_files')
    .select('*')
    .eq('id', id)
    .is('deleted_at', null)
    .maybeSingle();
  if (error) throw error;
  return (data as PackFile) ?? null;
}
  • Step 2: lib/web-backend.ts
import crypto from 'crypto';

const BACKEND_BASE = process.env.WEB_BACKEND_BASE ?? 'https://gahusb.synology.me';
const SECRET = process.env.BACKEND_HMAC_SECRET ?? '';

if (!SECRET && process.env.NODE_ENV === 'production') {
  console.warn('BACKEND_HMAC_SECRET 미설정 — web-backend 호출 실패할 것임');
}

function sign(payload: Buffer): string {
  return crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
}

interface SignLinkPayload {
  file_path: string;
  expires_in_seconds: number;
}

export async function signLink(payload: SignLinkPayload): Promise<{ url: string; expires_at: string }> {
  const body = Buffer.from(JSON.stringify(payload));
  const ts = String(Math.floor(Date.now() / 1000));
  const sig = sign(Buffer.concat([Buffer.from(`${ts}.`), body]));

  const res = await fetch(`${BACKEND_BASE}/api/packs/sign-link`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Timestamp': ts,
      'X-Signature': sig,
    },
    body,
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`web-backend sign-link 실패 (${res.status}): ${text}`);
  }
  return res.json();
}

interface UploadTokenPayload {
  tier: 'starter' | 'pro' | 'master';
  label: string;
  filename: string;
  size_bytes: number;
}

export function mintUploadToken(input: UploadTokenPayload): { token: string; uploadUrl: string; expiresAt: number } {
  const expiresAt = Math.floor(Date.now() / 1000) + 15 * 60;  // 15분
  const payload: Record<string, unknown> = {
    ...input,
    expires_at: expiresAt,
    jti: crypto.randomUUID(),
  };
  // web-backend 의 verify_upload_token 이 sort_keys=True + separators=(",", ":") 로 검증하므로
  // 키 알파벳 순서로 정렬한 객체를 JSON.stringify (JS 기본도 공백 없는 직렬화 → 호환)
  const orderedPayload = Object.keys(payload).sort().reduce((acc, k) => {
    acc[k] = payload[k];
    return acc;
  }, {} as Record<string, unknown>);
  const body = Buffer.from(JSON.stringify(orderedPayload));
  const sig = sign(body);
  const token = body.toString('base64url') + '.' + sig;
  return {
    token,
    uploadUrl: `${BACKEND_BASE}/api/packs/upload`,
    expiresAt,
  };
}

export async function listPackFilesViaBackend(): Promise<unknown[]> {
  const body = Buffer.alloc(0);
  const ts = String(Math.floor(Date.now() / 1000));
  const sig = sign(Buffer.concat([Buffer.from(`${ts}.`), body]));
  const res = await fetch(`${BACKEND_BASE}/api/packs/list`, {
    method: 'GET',
    headers: { 'X-Timestamp': ts, 'X-Signature': sig },
  });
  if (!res.ok) throw new Error('list 실패');
  return res.json();
}

export async function deletePackFileViaBackend(id: string): Promise<void> {
  const body = Buffer.alloc(0);
  const ts = String(Math.floor(Date.now() / 1000));
  const sig = sign(Buffer.concat([Buffer.from(`${ts}.`), body]));
  const res = await fetch(`${BACKEND_BASE}/api/packs/${id}`, {
    method: 'DELETE',
    headers: { 'X-Timestamp': ts, 'X-Signature': sig },
  });
  if (!res.ok) throw new Error('delete 실패');
}
  • Step 3: 린트 통과
npx eslint lib/supabase/pack-files.ts lib/web-backend.ts

Expected: exit 0.

  • Step 4: 커밋
git add lib/supabase/pack-files.ts lib/web-backend.ts
git commit -m "$(cat <<'EOF'
feat(packs): lib helpers — pack-files supabase 쿼리 + web-backend HMAC 클라이언트

- pack-files.ts: tier hierarchy + getPackFilesForTiers + getPackFileById
- web-backend.ts: signLink (HMAC sig) + mintUploadToken (일회성 jti, 15분 만료)
  + listPackFilesViaBackend + deletePackFileViaBackend

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task B3: lib/pack-assets.ts — files 폐기 + PACK_TIER_NAMES export

Files:

  • Modify: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\lib\pack-assets.ts

  • Step 1: 현재 파일 백업 위해 git 상태 확인

git log --oneline lib/pack-assets.ts | head -3
  • Step 2: 파일 전체 재작성

lib/pack-assets.ts 를 다음으로 교체:

export type PackTier = 'starter' | 'pro' | 'master';

/**
 * Tier 키 → 한국어 표시명 SSOT.
 * `app/services/music/page.tsx`의 TIERS와 일치 유지 필요 (현재 입문/프로/마스터).
 */
export const TIER_LABEL: Record<PackTier, string> = {
  starter: '입문',
  pro: '프로',
  master: '마스터',
};

const LABEL_TO_TIER: Record<string, PackTier> = Object.fromEntries(
  Object.entries(TIER_LABEL).map(([tier, label]) => [label, tier as PackTier])
);

/**
 * Tier 키 → 팩 이름 (mypage 표시용).
 * 자료 파일 리스트는 pack_files DB 테이블이 SSOT.
 */
export const PACK_TIER_NAMES: Record<PackTier, string> = {
  starter: `AI 음악 마스터 팩 (${TIER_LABEL.starter})`,
  pro: `AI 음악 마스터 팩 (${TIER_LABEL.pro})`,
  master: `AI 음악 마스터 팩 (${TIER_LABEL.master})`,
};

/**
 * orders.service ("구매 신청: AI 음악 마스터 팩 · 프로") → tier key.
 * 매칭 안 되면 null 반환 (Music 팩 외 의뢰).
 *
 * NOTE: service 문자열은 U+00B7 MIDDLE DOT (·) 사용.
 */
export function extractPackTier(service: string): PackTier | null {
  if (!service.startsWith('구매 신청:')) return null;
  const dotIdx = service.lastIndexOf('·');
  if (dotIdx === -1) return null;
  const tierName = service.slice(dotIdx + 1).trim();
  return LABEL_TO_TIER[tierName] ?? null;
}

변경 사항:

  • PackAsset interface 제거 (files 사라짐)

  • PACK_ASSETS const 제거

  • PACK_TIER_NAMES 신규 export

  • TIER_LABEL, extractPackTier 그대로

  • Phase 2 migration note 코멘트 제거 (이제 마이그레이션 완료)

  • Step 3: 사용처 grep — mypage가 PACK_ASSETS 참조 중인지

grep -n "PACK_ASSETS\|PackAsset" app/ lib/ --include="*.ts" --include="*.tsx" -r

mypage page.tsx 가 PACK_ASSETS[tier] 사용 중이면 Task C3에서 정리. 일단 Task B3 단독으로는 빌드 안 됨 — Task B3은 commit하되 빌드 통과는 Task C3까지 합쳐야 함.

이 task는 빌드를 깨므로 Task C3 직전 또는 Task C3와 묶어서 처리 필요. 이 plan은 Task B3 단독 commit 후 Task C3 commit으로 빌드 복구한다.

  • Step 4: 린트만 통과
npx eslint lib/pack-assets.ts

Expected: exit 0.

  • Step 5: 커밋
git add lib/pack-assets.ts
git commit -m "$(cat <<'EOF'
refactor(packs): PACK_ASSETS.files 폐기 → DB SSOT

Phase 2 시작 — pack_files 테이블이 자료 리스트 source of truth.
PACK_TIER_NAMES export 신규 추가 (mypage가 카드 제목용으로 참조).
TIER_LABEL, extractPackTier 변경 없음.

Note: 이 commit 단독으로는 mypage 빌드 깨짐. Task C3 (mypage page.tsx 수정)
에서 PACK_ASSETS 참조 제거 + 새 데이터 흐름 적용 후 빌드 복구.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\api\packs\sign-link\route.ts

  • Step 1: route.ts 작성

import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { extractPackTier, type PackTier } from '@/lib/pack-assets';
import { tierIncludes, getPackFileById } from '@/lib/supabase/pack-files';
import { signLink } from '@/lib/web-backend';

export const runtime = 'nodejs';

const EXPIRES_IN_SEC = 4 * 60 * 60;  // 4시간

export async function POST(request: Request) {
  const { fileId } = await request.json();
  if (!fileId || typeof fileId !== 'string') {
    return NextResponse.json({ error: 'fileId 필요' }, { status: 400 });
  }

  // 1) 사용자 인증
  const cookieStore = await cookies();
  const supabase = createServerClient(cookieStore);
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // 2) orders 조회 — completed Music 팩 구매 확인
  const admin = createAdminClient();
  const { data: orders } = await admin
    .from('contact_requests')
    .select('service, status')
    .eq('user_id', user.id)
    .eq('status', 'completed');

  const tiers = new Set<PackTier>();
  for (const o of (orders ?? [])) {
    const t = extractPackTier(o.service);
    if (t) tierIncludes(t).forEach((x) => tiers.add(x));
  }
  if (tiers.size === 0) {
    return NextResponse.json({ error: '구매 내역이 없거나 결제 미완료입니다' }, { status: 403 });
  }

  // 3) 파일 조회 + tier 매칭
  const file = await getPackFileById(admin, fileId);
  if (!file) {
    return NextResponse.json({ error: '파일을 찾을 수 없습니다' }, { status: 404 });
  }
  if (!tiers.has(file.min_tier)) {
    return NextResponse.json({ error: '구매 등급에서 접근할 수 없는 파일입니다' }, { status: 403 });
  }

  // 4) web-backend 호출 → DSM 공유 링크
  try {
    const { url, expires_at } = await signLink({
      file_path: file.file_path,
      expires_in_seconds: EXPIRES_IN_SEC,
    });
    return NextResponse.json({ url, expiresAt: expires_at });
  } catch (e) {
    const msg = e instanceof Error ? e.message : 'unknown';
    return NextResponse.json({ error: '링크 발급 실패', detail: msg }, { status: 502 });
  }
}
  • Step 2: createServerClient 헬퍼 확인
ls lib/supabase/

server.ts 가 있어야 함. 없으면 cookieStore에서 supabase 만드는 패턴은 다른 어떤 라우트가 사용하는지 확인:

grep -rn "createServerClient\|cookies" app/api/saju/ | head

만약 server.ts 없으면 client.ts와 admin.ts 패턴 보고 추가 필요. 이 plan은 server.ts 존재 가정 — 없으면 Task B4 첫 step에 server.ts 생성 추가.

대안: 이 라우트에서 createServerClient 대신 admin client + 쿠키에서 직접 user 조회 방식 사용:

// 대안 — server.ts 없으면 이 패턴
import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr';

const cookieStore = await cookies();
const supabase = createSSRClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  {
    cookies: {
      getAll: () => cookieStore.getAll(),
      setAll: () => {},  // route handler에서 set 불필요
    },
  },
);

→ 위 패턴을 그대로 라우트에 인라인. (Phase 2의 다른 라우트도 비슷한 패턴 — lib/supabase/server.ts 신설은 별도 cleanup task로 미루기.)

라우트 코드의 createServerClient import 부분을 위 패턴으로 교체:

import { createServerClient as createSSRClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

// ...

const cookieStore = await cookies();
const supabase = createSSRClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  {
    cookies: {
      getAll: () => cookieStore.getAll(),
      setAll: () => {},
    },
  },
);
const { data: { user } } = await supabase.auth.getUser();
  • Step 3: 빌드 통과 확인
npm run build 2>&1 | tail -20

Expected: 라우트 빌드 성공. mypage 가 Task B3 변경으로 깨져있을 수 있으나 Task C3까지 의도된 흐름.

  • Step 4: 커밋
git add app/api/packs/sign-link/route.ts
git commit -m "$(cat <<'EOF'
feat(api): /api/packs/sign-link — 사용자 다운로드 권한 검증 + DSM 링크 발급

- supabase auth → user
- contact_requests.status='completed' 인 Music 팩 구매 확인
- extractPackTier로 tier 도출, hierarchy 매핑
- pack_files.min_tier 매칭 검증
- web-backend signLink 호출 → 4시간 만료 URL 반환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task B5: /api/admin/packs/upload-url 라우트 (HMAC 토큰 발급)

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\api\admin\packs\upload-url\route.ts

  • Step 1: route.ts

import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { mintUploadToken } from '@/lib/web-backend';
import type { PackTier } from '@/lib/pack-assets';

export const runtime = 'nodejs';

async function checkAuth() {
  const cookieStore = await cookies();
  const token = cookieStore.get('admin_token')?.value;
  return token && verifyAdminTokenNode(token);
}

const VALID_TIERS = new Set<PackTier>(['starter', 'pro', 'master']);
const MAX_BYTES = 5 * 1024 * 1024 * 1024;
const ALLOWED_EXT = new Set(['pdf', 'zip', 'mp4', 'mov', 'mkv', 'wav', 'm4a', 'mp3', 'png', 'jpg', 'jpeg', 'webp', 'prj']);

export async function POST(request: Request) {
  if (!(await checkAuth())) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { tier, label, filename, sizeBytes } = await request.json();

  if (!VALID_TIERS.has(tier)) {
    return NextResponse.json({ error: 'tier 유효하지 않음' }, { status: 400 });
  }
  if (!label || typeof label !== 'string' || label.length > 200) {
    return NextResponse.json({ error: 'label 필요 (1-200자)' }, { status: 400 });
  }
  if (!filename || typeof filename !== 'string') {
    return NextResponse.json({ error: 'filename 필요' }, { status: 400 });
  }
  const ext = filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : '';
  if (!ALLOWED_EXT.has(ext)) {
    return NextResponse.json({ error: `허용되지 않은 확장자: ${ext}` }, { status: 400 });
  }
  if (typeof sizeBytes !== 'number' || sizeBytes <= 0 || sizeBytes > MAX_BYTES) {
    return NextResponse.json({ error: '파일 크기 0-5GB' }, { status: 400 });
  }

  const { token, uploadUrl, expiresAt } = mintUploadToken({
    tier,
    label,
    filename,
    size_bytes: sizeBytes,
  });

  return NextResponse.json({ token, uploadUrl, expiresAt });
}
  • Step 2: 빌드 통과
npm run build 2>&1 | tail -10
  • Step 3: 커밋
git add app/api/admin/packs/upload-url/route.ts
git commit -m "$(cat <<'EOF'
feat(api): /api/admin/packs/upload-url — admin 일회성 HMAC 업로드 토큰 발급

15분 만료 + jti 단발성. 브라우저는 이 토큰을 web-backend /api/packs/upload에
직접 multipart POST 시 Authorization Bearer 헤더로 전달 → Vercel function body
limit 우회 (5GB 업로드).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task B6: /api/admin/packs 라우트 (목록/편집/삭제)

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\api\admin\packs\route.ts

  • Step 1: route.ts

import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { deletePackFileViaBackend } from '@/lib/web-backend';
import type { PackTier } from '@/lib/pack-assets';

export const runtime = 'nodejs';

async function checkAuth() {
  const cookieStore = await cookies();
  const token = cookieStore.get('admin_token')?.value;
  return token && verifyAdminTokenNode(token);
}

const VALID_TIERS = new Set<PackTier>(['starter', 'pro', 'master']);

export async function GET() {
  if (!(await checkAuth())) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  const supabase = createAdminClient();
  const { data, error } = await supabase
    .from('pack_files')
    .select('*')
    .is('deleted_at', null)
    .order('min_tier')
    .order('sort_order');
  if (error) return NextResponse.json({ error: error.message }, { status: 500 });
  return NextResponse.json({ files: data ?? [] });
}

export async function PATCH(request: Request) {
  if (!(await checkAuth())) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  const { id, label, sort_order, min_tier } = await request.json();
  if (!id) return NextResponse.json({ error: 'id 필요' }, { status: 400 });

  const updates: Record<string, unknown> = {};
  if (typeof label === 'string') updates.label = label;
  if (typeof sort_order === 'number') updates.sort_order = sort_order;
  if (typeof min_tier === 'string' && VALID_TIERS.has(min_tier as PackTier)) {
    updates.min_tier = min_tier;
  }
  if (Object.keys(updates).length === 0) {
    return NextResponse.json({ error: '변경할 필드 없음' }, { status: 400 });
  }

  const supabase = createAdminClient();
  const { error } = await supabase.from('pack_files').update(updates).eq('id', id);
  if (error) return NextResponse.json({ error: error.message }, { status: 500 });
  return NextResponse.json({ success: true });
}

export async function DELETE(request: Request) {
  if (!(await checkAuth())) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  const url = new URL(request.url);
  const id = url.searchParams.get('id');
  if (!id) return NextResponse.json({ error: 'id 필요' }, { status: 400 });

  // web-backend가 soft delete 담당 (DSM 정리도 backend가 향후 추가 예정)
  try {
    await deletePackFileViaBackend(id);
  } catch (e) {
    const msg = e instanceof Error ? e.message : 'unknown';
    return NextResponse.json({ error: 'backend delete 실패', detail: msg }, { status: 502 });
  }
  return NextResponse.json({ success: true });
}
  • Step 2: 빌드 통과
npm run build 2>&1 | tail -10
  • Step 3: 커밋
git add app/api/admin/packs/route.ts
git commit -m "$(cat <<'EOF'
feat(api): /api/admin/packs — admin 파일 목록/편집/삭제

- GET: pack_files 목록 (deleted_at IS NULL)
- PATCH: { id, label?, sort_order?, min_tier? } 인라인 편집
- DELETE: web-backend 통한 soft delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Phase C — Frontend (jaengseung-made)

Task C1: /admin/packs 페이지

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\admin\packs\page.tsx

  • Step 1: 페이지 작성

'use client';

import { useEffect, useState } from 'react';

type PackTier = 'starter' | 'pro' | 'master';

interface PackFile {
  id: string;
  min_tier: PackTier;
  label: string;
  filename: string;
  size_bytes: number;
  sort_order: number;
  uploaded_at: string;
}

const TIER_LABEL: Record<PackTier, string> = {
  starter: '입문',
  pro: '프로',
  master: '마스터',
};

function formatSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}

export default function AdminPacksPage() {
  const [files, setFiles] = useState<PackFile[]>([]);
  const [loading, setLoading] = useState(true);

  // 업로드 form state
  const [tier, setTier] = useState<PackTier>('starter');
  const [label, setLabel] = useState('');
  const [file, setFile] = useState<File | null>(null);
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState<string | null>(null);

  async function loadFiles() {
    setLoading(true);
    try {
      const res = await fetch('/api/admin/packs');
      const data = await res.json();
      setFiles(data.files ?? []);
    } catch (e) {
      console.error(e);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => { loadFiles(); }, []);

  async function handleUpload(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    if (!file || !label) return;
    setUploading(true);
    setProgress(0);

    try {
      // 1) Vercel API에서 일회성 토큰 발급
      const tokenRes = await fetch('/api/admin/packs/upload-url', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          tier,
          label,
          filename: file.name,
          sizeBytes: file.size,
        }),
      });
      if (!tokenRes.ok) {
        const err = await tokenRes.json();
        throw new Error(err.error ?? '토큰 발급 실패');
      }
      const { token, uploadUrl } = await tokenRes.json();

      // 2) 브라우저가 web-backend에 직접 multipart POST (XHR로 진행률 추적)
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open('POST', uploadUrl);
        xhr.setRequestHeader('Authorization', `Bearer ${token}`);
        xhr.upload.onprogress = (ev) => {
          if (ev.lengthComputable) {
            setProgress(Math.round((ev.loaded / ev.total) * 100));
          }
        };
        xhr.onload = () => {
          if (xhr.status >= 200 && xhr.status < 300) resolve();
          else {
            try {
              const { detail } = JSON.parse(xhr.responseText);
              reject(new Error(detail ?? `HTTP ${xhr.status}`));
            } catch {
              reject(new Error(`HTTP ${xhr.status}`));
            }
          }
        };
        xhr.onerror = () => reject(new Error('네트워크 오류'));
        const fd = new FormData();
        fd.append('file', file);
        xhr.send(fd);
      });

      // 3) 리스트 갱신
      setFile(null);
      setLabel('');
      setProgress(0);
      await loadFiles();
    } catch (e) {
      setError(e instanceof Error ? e.message : '업로드 실패');
    } finally {
      setUploading(false);
    }
  }

  async function handleDelete(id: string, label: string) {
    if (!confirm(`"${label}" 자료를 삭제하시겠습니까?`)) return;
    try {
      const res = await fetch(`/api/admin/packs?id=${id}`, { method: 'DELETE' });
      if (!res.ok) throw new Error('삭제 실패');
      await loadFiles();
    } catch (e) {
      alert(e instanceof Error ? e.message : '삭제 실패');
    }
  }

  async function handlePatch(id: string, updates: Partial<PackFile>) {
    try {
      await fetch('/api/admin/packs', {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ id, ...updates }),
      });
      await loadFiles();
    } catch (e) {
      console.error(e);
    }
  }

  const grouped: Record<PackTier, PackFile[]> = {
    starter: files.filter((f) => f.min_tier === 'starter'),
    pro: files.filter((f) => f.min_tier === 'pro'),
    master: files.filter((f) => f.min_tier === 'master'),
  };

  return (
    <div className="p-6 max-w-6xl mx-auto">
      <div className="mb-6">
        <h1 className="text-white text-2xl font-bold"> 자료 관리</h1>
        <p className="text-slate-400 text-sm mt-0.5">
          NAS 자료 업로드 + 다운로드 활성화. 최대 5GB / 4시간 만료 공유 링크.
        </p>
      </div>

      {/* 업로드 폼 */}
      <form onSubmit={handleUpload} className="bg-slate-900 rounded-xl border border-slate-700 p-5 mb-8">
        <h2 className="text-white font-bold mb-4"> 자료 업로드</h2>
        <div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
          <select
            value={tier}
            onChange={(e) => setTier(e.target.value as PackTier)}
            disabled={uploading}
            className="bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
          >
            <option value="starter">{TIER_LABEL.starter}</option>
            <option value="pro">{TIER_LABEL.pro}</option>
            <option value="master">{TIER_LABEL.master}</option>
          </select>
          <input
            type="text"
            value={label}
            onChange={(e) => setLabel(e.target.value)}
            disabled={uploading}
            placeholder="자료 라벨 (예: Suno 프롬프트 북 PDF)"
            className="bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 md:col-span-2"
          />
        </div>
        <input
          type="file"
          onChange={(e) => setFile(e.target.files?.[0] ?? null)}
          disabled={uploading}
          className="text-slate-300 mb-3 block"
        />
        {file && (
          <p className="text-slate-400 text-xs mb-3">
            선택됨: {file.name} ({formatSize(file.size)})
          </p>
        )}
        {uploading && (
          <div className="mb-3">
            <div className="bg-slate-800 rounded h-2 overflow-hidden">
              <div className="bg-violet-500 h-full transition-all" style={{ width: `${progress}%` }} />
            </div>
            <p className="text-slate-400 text-xs mt-1">{progress}% 업로드 ... 페이지를 닫지 마세요</p>
          </div>
        )}
        {error && <p className="text-red-400 text-sm mb-3">{error}</p>}
        <button
          type="submit"
          disabled={uploading || !file || !label}
          className="bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 disabled:cursor-not-allowed text-white font-bold px-5 py-2 rounded"
        >
          {uploading ? '업로드 중...' : '업로드'}
        </button>
      </form>

      {/* 자료 리스트 */}
      {loading ? (
        <p className="text-slate-400">불러오는 ...</p>
      ) : (
        (['starter', 'pro', 'master'] as PackTier[]).map((t) => (
          <div key={t} className="mb-6">
            <h3 className="text-white font-bold mb-2">
              {TIER_LABEL[t]} ({grouped[t].length})
            </h3>
            {grouped[t].length === 0 ? (
              <p className="text-slate-500 text-sm pl-2">자료 없음</p>
            ) : (
              <div className="space-y-2">
                {grouped[t].map((f) => (
                  <div key={f.id} className="bg-slate-900 border border-slate-700 rounded-lg p-3 flex items-center gap-3">
                    <input
                      type="text"
                      defaultValue={f.label}
                      onBlur={(e) => {
                        if (e.target.value !== f.label) handlePatch(f.id, { label: e.target.value });
                      }}
                      className="flex-1 bg-transparent text-white border-b border-transparent focus:border-slate-500 px-1 py-1"
                    />
                    <span className="text-slate-400 text-xs">{f.filename}</span>
                    <span className="text-slate-500 text-xs">{formatSize(f.size_bytes)}</span>
                    <button
                      onClick={() => handleDelete(f.id, f.label)}
                      className="text-red-400 hover:text-red-300 text-sm px-2"
                    >
                      삭제
                    </button>
                  </div>
                ))}
              </div>
            )}
          </div>
        ))
      )}
    </div>
  );
}
  • Step 2: 린트
npx eslint app/admin/packs/page.tsx
  • Step 3: 빌드
npm run build 2>&1 | tail -10
  • Step 4: 커밋
git add app/admin/packs/page.tsx
git commit -m "$(cat <<'EOF'
feat(admin): /admin/packs — 자료 업로드 + 인라인 편집 + 삭제 UI

- 업로드: tier 선택 + label + 파일 → /api/admin/packs/upload-url 토큰 발급
  → XHR로 web-backend에 직접 multipart POST (진행률 추적)
- 리스트: tier별 그룹 + label inline 편집 (blur 시 PATCH)
- 삭제: confirm 후 DELETE → soft delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task C2: AdminSidebar에 "팩 자료" 메뉴

Files:

  • Modify: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\admin\components\AdminSidebar.tsx

  • Step 1: 현재 메뉴 구조 확인

grep -n "href" app/admin/components/AdminSidebar.tsx | head -10

기존 메뉴 패턴 보고 새 항목 추가 위치 결정.

  • Step 2: "팩 자료" 항목 추가

기존 메뉴 array에 (예: documents 다음) 추가:

{ href: '/admin/packs', label: '팩 자료', icon: '📦' },

(실제 패턴은 AdminSidebar.tsx 의 menu 정의 형식을 따름. icon이 string emoji 또는 SVG component 등 — 기존 항목 형태 그대로.)

  • Step 3: 빌드 + 시각
npm run build 2>&1 | tail -5
  • Step 4: 커밋
git add app/admin/components/AdminSidebar.tsx
git commit -m "$(cat <<'EOF'
feat(admin): AdminSidebar에 "팩 자료" 메뉴 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Task C3: mypage — 다운로드 버튼 활성화 + status 분기

Files:

  • Modify: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\mypage\page.tsx

⚠️ 이 task가 Task B3에서 깨진 빌드를 복구한다. PACK_ASSETS 참조를 모두 제거하고 pack_files DB 데이터로 대체.

  • Step 1: import 변경

app/mypage/page.tsx 상단의 pack-assets import를 다음으로 변경:

// 기존:
import { PACK_ASSETS, extractPackTier, type PackTier } from '@/lib/pack-assets';

// 변경 후:
import { PACK_TIER_NAMES, extractPackTier, type PackTier } from '@/lib/pack-assets';
import { tierIncludes, type PackFile } from '@/lib/supabase/pack-files';
  • Step 2: state + fetch 추가

mypage 컴포넌트 안의 useState 영역에 추가:

const [packFiles, setPackFiles] = useState<PackFile[]>([]);
const [downloading, setDownloading] = useState<string | null>(null);

init() 함수에 packFiles fetch 추가 (orders fetch 다음):

// pack_files: 사용자가 구매한 모든 tier의 합집합으로 fetch
const allTiers = new Set<PackTier>();
for (const o of (ord || [])) {
  const t = extractPackTier(o.service);
  if (t && o.status === 'completed') {
    tierIncludes(t).forEach((x) => allTiers.add(x));
  }
}
if (allTiers.size > 0) {
  const { data: pf } = await supabase
    .from('pack_files')
    .select('*')
    .in('min_tier', Array.from(allTiers))
    .is('deleted_at', null)
    .order('min_tier').order('sort_order');
  setPackFiles((pf as PackFile[]) ?? []);
}

⚠️ 주의: 일반 사용자는 RLS로 pack_files SELECT 차단됨 (spec §4.3). 따라서 위 fetch는 작동 안 함. 수정: pack_files를 mypage에서 직접 fetch 대신, /api/packs/list-mine 같은 사용자용 라우트 신설하거나 또는 RLS 정책에 "auth.uid()로 contact_requests를 조회 → completed 인 tier 매칭 → pack_files SELECT 허용" 추가 필요.

선택: 간단함을 위해 /api/packs/list-mine 라우트 신설 (다음 sub-step). RLS는 그대로 차단 유지.

/api/packs/list-mine 라우트 신설 (app/api/packs/list-mine/route.ts):

import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin';
import { extractPackTier, type PackTier } from '@/lib/pack-assets';
import { tierIncludes, getPackFilesForTiers } from '@/lib/supabase/pack-files';

export const runtime = 'nodejs';

export async function GET() {
  const cookieStore = await cookies();
  const supabase = createSSRClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: () => {},
      },
    },
  );
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return NextResponse.json({ files: [] });

  const admin = createAdminClient();
  const { data: orders } = await admin
    .from('contact_requests')
    .select('service, status')
    .eq('user_id', user.id)
    .eq('status', 'completed');

  const tiers = new Set<PackTier>();
  for (const o of (orders ?? [])) {
    const t = extractPackTier(o.service);
    if (t) tierIncludes(t).forEach((x) => tiers.add(x));
  }

  const files = await getPackFilesForTiers(admin, Array.from(tiers));
  return NextResponse.json({ files });
}

mypage init() 의 fetch는:

const filesRes = await fetch('/api/packs/list-mine');
if (filesRes.ok) {
  const { files } = await filesRes.json();
  setPackFiles(files ?? []);
}
  • Step 3: 다운로드 핸들러

mypage 함수 본문에 추가:

async function handleDownload(fileId: string) {
  setDownloading(fileId);
  try {
    const res = await fetch('/api/packs/sign-link', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ fileId }),
    });
    const data = await res.json();
    if (!res.ok || !data.url) {
      throw new Error(data.error ?? '링크 발급 실패');
    }
    window.location.href = data.url;
  } catch (e) {
    alert(e instanceof Error ? e.message : '다운로드 준비 중 오류가 발생했습니다');
  } finally {
    setDownloading(null);
  }
}
  • Step 4: "구매한 팩" 탭 JSX 변경

기존 tab === 'packs' 블록의 카드 렌더링 부분 — 자료 패키지 리스트와 disabled 버튼 부분을 다음으로 교체:

{/* 자료 리스트 — DB가 SSOT */}
{(() => {
  const filesForTier = packFiles.filter((pf) => {
    if (tier === 'starter') return pf.min_tier === 'starter';
    if (tier === 'pro') return pf.min_tier === 'starter' || pf.min_tier === 'pro';
    return true;  // master
  });

  return (
    <div className="border-t border-slate-100 pt-4">
      <div className="text-sm font-semibold text-slate-700 mb-3">
        📦 자료 패키지 ({filesForTier.length})
      </div>
      {filesForTier.length === 0 ? (
        <p className="text-xs text-slate-500">자료 준비 . 카톡 1:1로 문의해주세요.</p>
      ) : (
        <ul className="space-y-2 mb-3">
          {filesForTier.map((f) => (
            <li key={f.id} className="flex items-center justify-between gap-2 text-sm">
              <span className="text-slate-700 flex-1">{f.label}</span>
              {order.status === 'completed' ? (
                <button
                  onClick={() => handleDownload(f.id)}
                  disabled={downloading === f.id}
                  className="px-3 py-1.5 rounded-lg text-xs font-bold bg-violet-600 hover:bg-violet-500 disabled:bg-slate-300 text-white transition"
                >
                  {downloading === f.id ? '준비중...' : '다운로드'}
                </button>
              ) : (
                <span className="text-xs text-slate-400">대기 </span>
              )}
            </li>
          ))}
        </ul>
      )}

      {order.status === 'completed' && filesForTier.length > 0 && (
        <p className="text-xs text-slate-500 leading-relaxed">
           다운로드 링크는 4시간 동안 유효합니다.
        </p>
      )}

      {order.status !== 'completed' && (
        <p className="text-xs text-slate-500 mt-2 text-center leading-relaxed">
          {order.status === 'in_progress' ? '결제 처리 중. 자료는 결제 확인 후 활성화됩니다.' : '입금 대기 중. 카톡 1:1로 안내드립니다.'}
          <br />
          <a
            href="https://open.kakao.com/o/s9stoNvb"
            target="_blank"
            rel="noopener noreferrer"
            className="text-violet-600 hover:underline font-semibold"
          >
            카톡 오픈채팅 
          </a>
        </p>
      )}
    </div>
  );
})()}
  • Step 5: PACK_ASSETS 모든 사용처 제거
grep -n "PACK_ASSETS" app/mypage/page.tsx

기존 Phase 1 mypage의 packs 카드 안에 다음 패턴이 존재할 것:

const asset = PACK_ASSETS[tier];
// ...
<div className="font-bold text-slate-900 text-base">{asset.name}</div>
// ...
{asset.files.map((file, i) => (
  <li key={i}>...</li>
))}

각각 다음으로 치환:

원본 치환
const asset = PACK_ASSETS[tier]; (제거)
{asset.name} {PACK_TIER_NAMES[tier]}
📦 자료 패키지 ({asset.files.length}개) 📦 자료 패키지 ({filesForTier.length}개) (step 4의 IIFE 안에서)
{asset.files.map(...)} step 4의 filesForTier.map(...) 로 대체됨

최종 grep 결과:

grep -n "PACK_ASSETS\|asset\." app/mypage/page.tsx

Expected: 빈 결과.

  • Step 6: 빌드 + 린트 통과
npx eslint app/mypage/page.tsx app/api/packs/list-mine/route.ts
npm run build
  • Step 7: 커밋
git add app/mypage/page.tsx app/api/packs/list-mine/route.ts
git commit -m "$(cat <<'EOF'
feat(mypage): 다운로드 버튼 활성화 (Phase 2) + status 분기

- packFiles state + /api/packs/list-mine fetch (RLS 우회 위해 admin client 라우트)
- handleDownload: /api/packs/sign-link 호출 → window.location 이동
- 카드: 자료 리스트 DB SSOT (PACK_ASSETS.files 폐기)
- order.status === 'completed' 만 다운로드 활성, 그 외는 Phase 1 placeholder 유지
- 4시간 만료 안내 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

Phase D — Integration

Task D1: 통합 smoke test (수동)

이 task는 코드 변경 없음. 사용자(CEO) 수동 검증.

  • Step 1: 사전 점검

  • NAS DSM 7.x — web-packs-admin 사용자 생성 (File Station 읽기/쓰기 + Sharing 권한)

  • NAS /volume1/docker/webpage/.env — DSM_HOST/USER/PASS, BACKEND_HMAC_SECRET, SUPABASE_URL/SERVICE_KEY 6개 변수 추가

  • NAS /volume1/docker/webpage/media/packs/{starter,pro,master}/ 디렉토리 생성

  • supabase migration 적용 (대시보드 SQL Editor에서 마이그레이션 SQL 실행)

  • Vercel env — BACKEND_HMAC_SECRET (NAS .env와 동일), WEB_BACKEND_BASE(=https://gahusb.synology.me) 추가

  • Step 2: web-backend 배포

cd /c/Users/jaeoh/Desktop/workspace/web-backend
git push  # Gitea webhook 자동 배포
# NAS에서 확인:
# ssh gahusb.synology.me
# docker logs -f packs-lab

healthcheck 통과 + /health 응답 OK 확인.

  • Step 3: jaengseung-made 배포
cd /c/Users/jaeoh/Desktop/workspace/jaengseung-made
git push origin main  # Vercel 자동 배포
  • Step 4: admin 업로드 테스트
  1. /admin 로그인
  2. 사이드바에서 "팩 자료" 진입
  3. tier 'starter' 선택, label "테스트 PDF" 입력, 작은 PDF 선택, 업로드
  4. 진행률 100% + 리스트에 추가됨 확인
  5. NAS File Station에서 /volume1/docker/webpage/media/packs/starter/ 에 파일 존재 확인
  6. supabase 대시보드에서 pack_files 테이블에 row 추가 확인
  • Step 5: 사용자 다운로드 테스트
  1. 테스트용 사용자 계정으로 로그인 (또는 본인 계정)
  2. PurchaseAgreementModal로 "AI 음악 마스터 팩 · 입문" 구매 신청 (실제 입금은 안 함)
  3. /admin/contacts 진입 → 해당 row status: pending → completed 로 변경
  4. 일반 사용자 로그인 → mypage → "구매한 팩" 탭
  5. 자료 리스트 + [다운로드] 버튼 활성화 확인
  6. 다운로드 클릭 → DSM 공유 URL로 redirect → 파일 다운로드 시작 확인
  7. 4시간 후 동일 URL 접속 시 만료 확인 (또는 DSM 공유 관리에서 만료 일시 확인)
  • Step 6: 보안 케이스 검증

  • 다른 tier 파일 ID로 sign-link 호출 시 → 403

  • 비로그인 sign-link → 401

  • order.status='pending'인 사용자가 sign-link → 403

  • 잘못된 admin token으로 upload-url → 401

  • 동일 jti 재사용 → 409

curl/Postman으로 확인.

이 task는 코드 변경/커밋 없음.


Task D2: 메모리·운영 매뉴얼 갱신

Files:

  • Modify: C:\Users\jaeoh\.claude\projects\C--Users-jaeoh-Desktop-workspace-jaengseung-made\memory\MEMORY.md

  • Create: C:\Users\jaeoh\.claude\projects\C--Users-jaeoh-Desktop-workspace-jaengseung-made\memory\project_phase2_packs.md

  • Step 1: 새 메모리 파일 생성

Path: C:\Users\jaeoh\.claude\projects\C--Users-jaeoh-Desktop-workspace-jaengseung-made\memory\project_phase2_packs.md

---
name: Phase 2 NAS 자료 다운로드 (2026-05-02 완료)
description: Music 팩 자료 다운로드 자동화 — admin 업로드 + DSM 공유 링크 4시간 만료
type: project
---

# Phase 2 NAS 자료 다운로드

- **완료일**: 2026-05-02
- **spec**: docs/superpowers/specs/2026-05-02-mypage-phase2-nas-downloads-design.md
- **plan**: docs/superpowers/plans/2026-05-02-mypage-phase2-nas-downloads.md

## 운영 절차 (CEO)

### 새 자료 업로드
1. /admin/packs 진입
2. tier 선택 + label + 파일 (5GB 이내) → 업로드
3. 진행률 완료 → 자동으로 리스트 추가
4. 사용자에게 즉시 노출 (별도 publish 없음)

### 신규 구매 처리
1. PurchaseAgreementModal로 신청 도착 → contact_requests pending
2. 카톡 입금 안내
3. 입금 확인 → /admin/contacts → status pending → in_progress → completed
4. 사용자 mypage → 다운로드 버튼 활성

### 다운로드 동작
- 클릭 → /api/packs/sign-link → 사용자/tier 검증 → DSM Sharing.create (4시간 만료)
- URL은 https://gahusb.synology.me:5001/d/s/<id>
- 4시간 후 만료 → 사용자 재클릭 시 새 URL

## 인프라

- NAS DSM 7.x SYNO.FileStation.Sharing v3
- DSM 전용 계정: web-packs-admin (File Station + Sharing 권한만)
- NAS 자료 경로: /volume1/docker/webpage/media/packs/{starter,pro,master}/
- web-backend packs-lab 컨테이너 (port 18910), nginx /api/packs/* 라우팅
- /api/packs/upload 만 client_max_body_size 5G

## 보안 모델

- Vercel ↔ web-backend: HMAC sha256 (timestamp.body, BACKEND_HMAC_SECRET 32 byte)
- admin 업로드 토큰: 일회성 jti, 15분 만료
- DSM 공유 링크: 4시간 만료
- pack_files RLS: 사용자 직접 SELECT 차단, /api/packs/list-mine 만 통과

**Why:** 자동화로 CEO 운영 부담 ↓, 사용자 즉시 다운로드 → UX ↑.
**How to apply:** 자료 추가/교체는 admin/packs에서. 신규 구매는 admin/contacts status='completed' 설정 후 자동 활성.
  • Step 2: MEMORY.md 인덱스에 한 줄 추가
- [Phase 2 NAS 자료 다운로드](./project_phase2_packs.md) — Music 팩 자동 다운로드 (2026-05-02 완료)
  • Step 3: 메모리는 git에 commit 안 됨 (별도 디렉토리)

이 step은 단순히 메모리 파일 작성 + 업데이트만. git commit X.

  • Step 4: Phase 2 완료 확인
cd /c/Users/jaeoh/Desktop/workspace/jaengseung-made
git log --oneline 03b3ae8..HEAD | wc -l

기대: B + C + D 작업 commits ~10개 (B1 1, B2 1, B3 1, B4 1, B5 1, B6 1, C1 1, C2 1, C3 1 = 9개. D는 코드 commit 없음).

cd /c/Users/jaeoh/Desktop/workspace/web-backend
git log --oneline | head -7

기대: A 작업 commits 6개 (A1, A2, A3, A4, A5, A6).

이 task는 코드 변경 없음. 메모리만 갱신.


부록 A. 안전성 분석 — 단계별 깨짐 가능성

시점 jaengseung-made 빌드 web-backend 작동 사용자 영향
Phase A 모두 완료 후 영향 X packs-lab 컨테이너 작동 (DSM 호출 가능) 영향 X (UI 미연결)
B1 후 OK OK 영향 X (마이그레이션만)
B2 후 OK OK 영향 X
B3 후 빌드 깨짐 (mypage 가 PACK_ASSETS 참조) OK 영향 X (배포 안 함)
B4-B6 후 깨짐 유지 OK 영향 X
C1 후 깨짐 유지 OK 영향 X
C2 후 깨짐 유지 OK 영향 X
C3 후 빌드 복구 OK UI 변경 노출

→ B3-C3 까지는 로컬 commit만, push 안 함. C3 commit 직후 push로 일괄 배포.

부록 B. 다음 plan 후속 (Phase 3+)

이 plan 종료 후 자연스럽게 따라올 항목:

  1. ZIP 일괄 다운로드 — 사용자 multi-file UX 개선
  2. 다운로드 횟수 제한 — DSM share에 max_count 설정
  3. soft-deleted 파일 hard delete cron — 30일 후 NAS에서도 제거
  4. DSM 세션 풀링 — packs-lab dsm_client 에 캐시
  5. 워터마크 — PDF에 사용자 이메일 임베드
  6. multipart 분할 업로드 — 5GB 초과 파일 대응

각각 별도 spec/plan으로.