Compare commits

..

7 Commits

Author SHA1 Message Date
jaehwang db853e6604 Merge branch 'main' into prompt 2026-01-28 23:43:11 +00:00
jaehwang e32e795c73 프롬프트 아웃풋 description 추가 2026-01-28 23:41:33 +00:00
Dohyun Lim 247a9f3322 merge with my-archive 2026-01-28 19:41:18 +09:00
Dohyun Lim c07a2f6dae finish 2026-01-28 19:23:46 +09:00
Dohyun Lim 32ae5530b6 uuid7으로 필드 및 처리관련 수정 2026-01-28 16:35:08 +09:00
Dohyun Lim aa8d9d7c14 사용자의 uuid -> ksuid로 변경, 테이블 구조 변경 2026-01-28 14:05:15 +09:00
Dohyun Lim dc7351d0f9 merge main 2026-01-28 10:49:27 +09:00
34 changed files with 3452 additions and 377 deletions

View File

@ -0,0 +1,337 @@
"""
Archive API 라우터
사용자의 아카이브(완료된 영상 목록) 관련 엔드포인트를 제공합니다.
"""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.archive.worker.archive_task import soft_delete_by_task_id
from app.database.session import get_session
from app.dependencies.pagination import PaginationParams, get_pagination_params
from app.home.models import Project
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse
from app.video.models import Video
from app.video.schemas.video_schema import VideoListItem
logger = get_logger(__name__)
router = APIRouter(prefix="/archive", tags=["Archive"])
@router.get(
"/videos/",
summary="완료된 영상 목록 조회",
description="""
## 개요
완료된(status='completed') 영상 목록을 페이지네이션하여 반환합니다.
## 쿼리 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 10, 최대: 100)
## 반환 정보
- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at)
- **total**: 전체 데이터
- **page**: 현재 페이지
- **page_size**: 페이지당 데이터
- **total_pages**: 전체 페이지
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시
```
GET /archive/videos/?page=1&page_size=10
```
## 참고
- **본인이 소유한 프로젝트의 영상만 반환됩니다.**
- status가 'completed' 영상만 반환됩니다.
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
- created_at 기준 내림차순 정렬됩니다.
""",
response_model=PaginatedResponse[VideoListItem],
responses={
200: {"description": "영상 목록 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
500: {"description": "조회 실패"},
},
)
async def get_videos(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[VideoListItem]:
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
logger.info(
f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}"
)
logger.debug(f"[get_videos] current_user.user_uuid: {current_user.user_uuid}")
try:
offset = (pagination.page - 1) * pagination.page_size
# DEBUG: 각 조건별 데이터 수 확인
# 1) 전체 Video 수
all_videos_result = await session.execute(select(func.count(Video.id)))
all_videos_count = all_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - 전체 Video 수: {all_videos_count}")
# 2) completed 상태 Video 수
completed_videos_result = await session.execute(
select(func.count(Video.id)).where(Video.status == "completed")
)
completed_videos_count = completed_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - completed 상태 Video 수: {completed_videos_count}")
# 3) is_deleted=False인 Video 수
not_deleted_videos_result = await session.execute(
select(func.count(Video.id)).where(Video.is_deleted == False)
)
not_deleted_videos_count = not_deleted_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - is_deleted=False Video 수: {not_deleted_videos_count}")
# 4) 전체 Project 수 및 user_uuid 값 확인
all_projects_result = await session.execute(
select(Project.id, Project.user_uuid, Project.is_deleted)
)
all_projects = all_projects_result.all()
logger.debug(f"[get_videos] DEBUG - 전체 Project 수: {len(all_projects)}")
for p in all_projects:
logger.debug(
f"[get_videos] DEBUG - Project: id={p.id}, user_uuid={p.user_uuid}, "
f"user_uuid_type={type(p.user_uuid)}, is_deleted={p.is_deleted}"
)
# 4-1) 현재 사용자 UUID 타입 확인
logger.debug(
f"[get_videos] DEBUG - current_user.user_uuid={current_user.user_uuid}, "
f"type={type(current_user.user_uuid)}"
)
# 4-2) 현재 사용자 소유 Project 수
user_projects_result = await session.execute(
select(func.count(Project.id)).where(
Project.user_uuid == current_user.user_uuid,
Project.is_deleted == False,
)
)
user_projects_count = user_projects_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - 현재 사용자 소유 Project 수: {user_projects_count}")
# 5) 현재 사용자 소유 + completed + 미삭제 Video 수
user_completed_videos_result = await session.execute(
select(func.count(Video.id))
.join(Project, Video.project_id == Project.id)
.where(
Project.user_uuid == current_user.user_uuid,
Video.status == "completed",
Video.is_deleted == False,
Project.is_deleted == False,
)
)
user_completed_videos_count = user_completed_videos_result.scalar() or 0
logger.debug(
f"[get_videos] DEBUG - 현재 사용자 소유 + completed + 미삭제 Video 수: {user_completed_videos_count}"
)
# 기본 조건: 현재 사용자 소유, completed 상태, 미삭제
base_conditions = [
Project.user_uuid == current_user.user_uuid,
Video.status == "completed",
Video.is_deleted == False,
Project.is_deleted == False,
]
# 쿼리 1: 전체 개수 조회 (task_id 기준 고유 개수)
count_query = (
select(func.count(func.distinct(Video.task_id)))
.join(Project, Video.project_id == Project.id)
.where(*base_conditions)
)
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - task_id 기준 고유 개수 (total): {total}")
# 서브쿼리: task_id별 최신 Video의 id 조회
subquery = (
select(func.max(Video.id).label("max_id"))
.join(Project, Video.project_id == Project.id)
.where(*base_conditions)
.group_by(Video.task_id)
.subquery()
)
# DEBUG: 서브쿼리 결과 확인
subquery_debug_result = await session.execute(select(subquery.c.max_id))
subquery_ids = [row[0] for row in subquery_debug_result.all()]
logger.debug(f"[get_videos] DEBUG - 서브쿼리 결과 (max_id 목록): {subquery_ids}")
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회
query = (
select(Video, Project)
.join(Project, Video.project_id == Project.id)
.where(Video.id.in_(select(subquery.c.max_id)))
.order_by(Video.created_at.desc())
.offset(offset)
.limit(pagination.page_size)
)
result = await session.execute(query)
rows = result.all()
logger.debug(f"[get_videos] DEBUG - 최종 조회 결과 수: {len(rows)}")
# VideoListItem으로 변환 (JOIN 결과에서 바로 추출)
items = []
for video, project in rows:
item = VideoListItem(
store_name=project.store_name,
region=project.region,
task_id=video.task_id,
result_movie_url=video.result_movie_url,
created_at=video.created_at,
)
items.append(item)
response = PaginatedResponse.create(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
logger.info(
f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, "
f"page_size: {pagination.page_size}, items_count: {len(items)}"
)
return response
except Exception as e:
logger.error(f"[get_videos] EXCEPTION - error: {e}")
raise HTTPException(
status_code=500,
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
)
@router.delete(
"/videos/delete/{task_id}",
summary="아카이브 영상 소프트 삭제",
description="""
## 개요
task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트 삭제합니다.
(is_deleted=True로 설정, 실제 데이터는 DB에 유지)
## 소프트 삭제 대상 테이블
1. **Video**: 동일 task_id의 모든 영상
2. **SongTimestamp**: 관련 노래의 타임스탬프 (suno_audio_id 기준)
3. **Song**: 동일 task_id의 모든 노래
4. **Lyric**: 동일 task_id의 모든 가사
5. **Image**: 동일 task_id의 모든 이미지
6. **Project**: task_id에 해당하는 프로젝트
## 경로 파라미터
- **task_id**: 삭제할 프로젝트의 task_id (UUID7 형식)
## 참고
- 본인이 소유한 프로젝트만 삭제할 있습니다.
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
- 백그라운드에서 비동기로 처리됩니다.
""",
responses={
200: {"description": "삭제 요청 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
403: {"description": "삭제 권한 없음"},
404: {"description": "프로젝트를 찾을 수 없음"},
500: {"description": "삭제 실패"},
},
)
async def delete_video(
task_id: str,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
"""task_id에 해당하는 프로젝트와 관련 데이터를 소프트 삭제합니다."""
logger.info(f"[delete_video] START - task_id: {task_id}, user: {current_user.user_uuid}")
logger.debug(f"[delete_video] DEBUG - current_user.user_uuid: {current_user.user_uuid}")
try:
# DEBUG: task_id로 조회 가능한 모든 Project 확인 (is_deleted 무관)
all_projects_result = await session.execute(
select(Project).where(Project.task_id == task_id)
)
all_projects = all_projects_result.scalars().all()
logger.debug(
f"[delete_video] DEBUG - task_id로 조회된 모든 Project 수: {len(all_projects)}"
)
for p in all_projects:
logger.debug(
f"[delete_video] DEBUG - Project: id={p.id}, task_id={p.task_id}, "
f"user_uuid={p.user_uuid}, is_deleted={p.is_deleted}"
)
# 프로젝트 조회
result = await session.execute(
select(Project).where(
Project.task_id == task_id,
Project.is_deleted == False,
)
)
project = result.scalar_one_or_none()
logger.debug(f"[delete_video] DEBUG - 조회된 Project (is_deleted=False): {project}")
if project is None:
logger.warning(f"[delete_video] NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=404,
detail="프로젝트를 찾을 수 없습니다.",
)
logger.debug(
f"[delete_video] DEBUG - Project 상세: id={project.id}, "
f"user_uuid={project.user_uuid}, store_name={project.store_name}"
)
# 소유권 검증
if project.user_uuid != current_user.user_uuid:
logger.warning(
f"[delete_video] FORBIDDEN - task_id: {task_id}, "
f"owner: {project.user_uuid}, requester: {current_user.user_uuid}"
)
raise HTTPException(
status_code=403,
detail="삭제 권한이 없습니다.",
)
# DEBUG: 삭제 대상 데이터 수 미리 확인
video_count_result = await session.execute(
select(func.count(Video.id)).where(
Video.task_id == task_id, Video.is_deleted == False
)
)
video_count = video_count_result.scalar() or 0
logger.debug(f"[delete_video] DEBUG - 삭제 대상 Video 수: {video_count}")
# 백그라운드 태스크로 소프트 삭제 실행
background_tasks.add_task(soft_delete_by_task_id, task_id)
logger.info(f"[delete_video] ACCEPTED - task_id: {task_id}, soft delete scheduled")
return {
"message": "삭제 요청이 접수되었습니다. 백그라운드에서 처리됩니다.",
"task_id": task_id,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[delete_video] EXCEPTION - task_id: {task_id}, error: {e}")
raise HTTPException(
status_code=500,
detail=f"삭제에 실패했습니다: {str(e)}",
)

View File

@ -0,0 +1,185 @@
"""
Archive Worker 모듈
아카이브 관련 백그라운드 작업을 처리합니다.
- 소프트 삭제 (is_deleted=True 설정)
"""
from sqlalchemy import func, select, update
from app.database.session import BackgroundSessionLocal
from app.home.models import Image, Project
from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp
from app.utils.logger import get_logger
from app.video.models import Video
logger = get_logger(__name__)
async def soft_delete_by_task_id(task_id: str) -> dict:
"""
task_id에 해당하는 모든 관련 데이터를 소프트 삭제합니다.
대상 테이블 (refresh_token, social_account, user 제외):
- Project
- Image
- Lyric
- Song
- SongTimestamp (suno_audio_id 기준)
- Video
Args:
task_id: 삭제할 프로젝트의 task_id
Returns:
dict: 테이블별 업데이트된 레코드
"""
logger.info(f"[soft_delete_by_task_id] START - task_id: {task_id}")
logger.debug(f"[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작")
result = {
"task_id": task_id,
"project": 0,
"image": 0,
"lyric": 0,
"song": 0,
"song_timestamp": 0,
"video": 0,
}
try:
async with BackgroundSessionLocal() as session:
# DEBUG: 삭제 전 각 테이블의 데이터 수 확인
video_before = await session.execute(
select(func.count(Video.id)).where(
Video.task_id == task_id, Video.is_deleted == False
)
)
logger.debug(
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Video 수: {video_before.scalar() or 0}"
)
song_before = await session.execute(
select(func.count(Song.id)).where(
Song.task_id == task_id, Song.is_deleted == False
)
)
logger.debug(
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Song 수: {song_before.scalar() or 0}"
)
lyric_before = await session.execute(
select(func.count(Lyric.id)).where(
Lyric.task_id == task_id, Lyric.is_deleted == False
)
)
logger.debug(
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Lyric 수: {lyric_before.scalar() or 0}"
)
image_before = await session.execute(
select(func.count(Image.id)).where(
Image.task_id == task_id, Image.is_deleted == False
)
)
logger.debug(
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Image 수: {image_before.scalar() or 0}"
)
project_before = await session.execute(
select(func.count(Project.id)).where(
Project.task_id == task_id, Project.is_deleted == False
)
)
logger.debug(
f"[soft_delete_by_task_id] DEBUG - 삭제 전 Project 수: {project_before.scalar() or 0}"
)
# 1. Video 소프트 삭제
video_stmt = (
update(Video)
.where(Video.task_id == task_id, Video.is_deleted == False)
.values(is_deleted=True)
)
video_result = await session.execute(video_stmt)
result["video"] = video_result.rowcount
logger.info(f"[soft_delete_by_task_id] Video soft deleted - count: {result['video']}")
logger.debug(f"[soft_delete_by_task_id] DEBUG - Video rowcount: {video_result.rowcount}")
# 2. SongTimestamp 소프트 삭제 (Song의 suno_audio_id 기준, 서브쿼리 사용)
suno_subquery = (
select(Song.suno_audio_id)
.where(
Song.task_id == task_id,
Song.suno_audio_id.isnot(None),
)
.scalar_subquery()
)
timestamp_stmt = (
update(SongTimestamp)
.where(
SongTimestamp.suno_audio_id.in_(suno_subquery),
SongTimestamp.is_deleted == False,
)
.values(is_deleted=True)
)
timestamp_result = await session.execute(timestamp_stmt)
result["song_timestamp"] = timestamp_result.rowcount
logger.info(
f"[soft_delete_by_task_id] SongTimestamp soft deleted - count: {result['song_timestamp']}"
)
# 3. Song 소프트 삭제
song_stmt = (
update(Song)
.where(Song.task_id == task_id, Song.is_deleted == False)
.values(is_deleted=True)
)
song_result = await session.execute(song_stmt)
result["song"] = song_result.rowcount
logger.info(f"[soft_delete_by_task_id] Song soft deleted - count: {result['song']}")
# 4. Lyric 소프트 삭제
lyric_stmt = (
update(Lyric)
.where(Lyric.task_id == task_id, Lyric.is_deleted == False)
.values(is_deleted=True)
)
lyric_result = await session.execute(lyric_stmt)
result["lyric"] = lyric_result.rowcount
logger.info(f"[soft_delete_by_task_id] Lyric soft deleted - count: {result['lyric']}")
# 5. Image 소프트 삭제
image_stmt = (
update(Image)
.where(Image.task_id == task_id, Image.is_deleted == False)
.values(is_deleted=True)
)
image_result = await session.execute(image_stmt)
result["image"] = image_result.rowcount
logger.info(f"[soft_delete_by_task_id] Image soft deleted - count: {result['image']}")
# 6. Project 소프트 삭제
project_stmt = (
update(Project)
.where(Project.task_id == task_id, Project.is_deleted == False)
.values(is_deleted=True)
)
project_result = await session.execute(project_stmt)
result["project"] = project_result.rowcount
logger.info(f"[soft_delete_by_task_id] Project soft deleted - count: {result['project']}")
await session.commit()
logger.info(
f"[soft_delete_by_task_id] SUCCESS - task_id: {task_id}, "
f"deleted: project={result['project']}, image={result['image']}, "
f"lyric={result['lyric']}, song={result['song']}, "
f"song_timestamp={result['song_timestamp']}, video={result['video']}"
)
return result
except Exception as e:
logger.error(f"[soft_delete_by_task_id] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
raise

View File

@ -72,18 +72,32 @@ async def create_db_tables():
import asyncio
# 모델 import (테이블 메타데이터 등록용)
# 주의: User를 먼저 import해야 UserProject가 User를 참조할 수 있음
from app.user.models import User # noqa: F401
from app.home.models import Image, Project, UserProject # noqa: F401
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
from app.home.models import Image, Project # noqa: F401
from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song # noqa: F401
from app.song.models import Song, SongTimestamp # noqa: F401
from app.video.models import Video # noqa: F401
# 생성할 테이블 목록
tables_to_create = [
User.__table__,
RefreshToken.__table__,
SocialAccount.__table__,
Project.__table__,
Image.__table__,
Lyric.__table__,
Song.__table__,
SongTimestamp.__table__,
Video.__table__,
]
logger.info("Creating database tables...")
async with asyncio.timeout(10):
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
await connection.run_sync(
lambda conn: Base.metadata.create_all(conn, tables=tables_to_create)
)
# FastAPI 의존성용 세션 제너레이터

View File

@ -1,6 +1,6 @@
from sqladmin import ModelView
from app.home.models import Image, Project, UserProject
from app.home.models import Image, Project
class ProjectAdmin(ModelView, model=Project):
@ -100,44 +100,3 @@ class ImageAdmin(ModelView, model=Image):
"img_url": "이미지 URL",
"created_at": "생성일시",
}
class UserProjectAdmin(ModelView, model=UserProject):
name = "사용자-프로젝트"
name_plural = "사용자-프로젝트 목록"
icon = "fa-solid fa-link"
category = "프로젝트 관리"
page_size = 20
column_list = [
"id",
"user_id",
"project_id",
]
column_details_list = [
"id",
"user_id",
"project_id",
"user",
"project",
]
column_searchable_list = [
UserProject.user_id,
UserProject.project_id,
]
column_sortable_list = [
UserProject.id,
UserProject.user_id,
UserProject.project_id,
]
column_labels = {
"id": "ID",
"user_id": "사용자 ID",
"project_id": "프로젝트 ID",
"user": "사용자",
"project": "프로젝트",
}

View File

@ -12,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session, AsyncSessionLocal
from app.home.models import Image
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.home.schemas.home_schema import (
AutoCompleteRequest,
CrawlingRequest,
@ -500,6 +502,7 @@ async def upload_images(
files: Optional[list[UploadFile]] = File(
default=None, description="이미지 바이너리 파일 목록"
),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> ImageUploadResponse:
"""이미지 업로드 (URL + 바이너리 파일)"""
@ -653,6 +656,9 @@ async def upload_images(
이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다.
바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 요청 방식
multipart/form-data 형식으로 전송합니다.
@ -679,11 +685,13 @@ jpg, jpeg, png, webp, heic, heif
```bash
# 바이너리 파일만 업로드
curl -X POST "http://localhost:8000/image/upload/blob" \\
-H "Authorization: Bearer {access_token}" \\
-F "files=@/path/to/image1.jpg" \\
-F "files=@/path/to/image2.png"
# URL + 바이너리 파일 동시 업로드
curl -X POST "http://localhost:8000/image/upload/blob" \\
-H "Authorization: Bearer {access_token}" \\
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
-F "files=@/path/to/local_image.jpg"
```
@ -706,6 +714,7 @@ curl -X POST "http://localhost:8000/image/upload/blob" \\
responses={
200: {"description": "이미지 업로드 성공"},
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
401: {"description": "인증 실패 (토큰 없음/만료)"},
},
tags=["Image-Blob"],
openapi_extra={
@ -728,6 +737,7 @@ async def upload_images_blob(
default=None,
description="이미지 바이너리 파일 목록",
),
current_user: User = Depends(get_current_user),
) -> ImageUploadResponse:
"""이미지 업로드 (URL + Azure Blob Storage)
@ -806,7 +816,7 @@ async def upload_images_blob(
img_order = len(url_images) # URL 이미지 다음 순서부터 시작
if valid_files_data:
uploader = AzureBlobUploader(task_id=task_id)
uploader = AzureBlobUploader(user_uuid=current_user.user_uuid, task_id=task_id)
total_files = len(valid_files_data)
for idx, (original_name, ext, file_content) in enumerate(valid_files_data):

View File

@ -4,13 +4,12 @@ Home 모듈 SQLAlchemy 모델 정의
모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
- Project: 프로젝트(사용자 입력 이력) 관리
- Image: 업로드된 이미지 URL 관리
- UserProject: User와 Project M:N 관계 중계 테이블
"""
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -21,122 +20,6 @@ if TYPE_CHECKING:
from app.user.models import User
from app.video.models import Video
# =============================================================================
# User-Project M:N 관계 중계 테이블
# =============================================================================
#
# 설계 의도:
# - User와 Project는 다대다(M:N) 관계입니다.
# - 한 사용자는 여러 프로젝트에 참여할 수 있습니다.
# - 한 프로젝트에는 여러 사용자가 참여할 수 있습니다.
#
# 중계 테이블 역할:
# - UserProject 테이블이 두 테이블 간의 관계를 연결합니다.
# - 각 레코드는 특정 사용자와 특정 프로젝트의 연결을 나타냅니다.
# - 추가 속성(role, joined_at)으로 관계의 메타데이터를 저장합니다.
#
# 외래키 설정:
# - user_id: User 테이블의 id를 참조 (ON DELETE CASCADE)
# - project_id: Project 테이블의 id를 참조 (ON DELETE CASCADE)
# - CASCADE 설정으로 부모 레코드 삭제 시 중계 레코드도 자동 삭제됩니다.
#
# 관계 방향:
# - User.projects → UserProject → Project (사용자가 참여한 프로젝트 목록)
# - Project.users → UserProject → User (프로젝트에 참여한 사용자 목록)
# =============================================================================
class UserProject(Base):
"""
User-Project M:N 관계 중계 테이블
사용자와 프로젝트 간의 다대다 관계를 관리합니다.
사용자는 여러 프로젝트에 참여할 있고,
프로젝트에는 여러 사용자가 참여할 있습니다.
Attributes:
id: 고유 식별자 (자동 증가)
user_id: 사용자 외래키 (User.id 참조)
project_id: 프로젝트 외래키 (Project.id 참조)
role: 프로젝트 사용자 역할 (owner: 소유자, member: 멤버, viewer: 조회자)
joined_at: 프로젝트 참여 일시
외래키 제약조건:
- user_id user.id (ON DELETE CASCADE)
- project_id project.id (ON DELETE CASCADE)
유니크 제약조건:
- (user_id, project_id) 조합은 유일해야 (중복 참여 방지)
"""
__tablename__ = "user_project"
__table_args__ = (
Index("idx_user_project_user_id", "user_id"),
Index("idx_user_project_project_id", "project_id"),
Index("idx_user_project_user_project", "user_id", "project_id", unique=True),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
# 외래키: User 테이블 참조
# - BigInteger 사용 (User.id가 BigInteger이므로 타입 일치 필요)
# - ondelete="CASCADE": User 삭제 시 연결된 UserProject 레코드도 삭제
user_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
comment="사용자 외래키 (User.id 참조)",
)
# 외래키: Project 테이블 참조
# - Integer 사용 (Project.id가 Integer이므로 타입 일치 필요)
# - ondelete="CASCADE": Project 삭제 시 연결된 UserProject 레코드도 삭제
project_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
comment="프로젝트 외래키 (Project.id 참조)",
)
# ==========================================================================
# Relationships (관계 설정)
# ==========================================================================
# back_populates: 양방향 관계 설정 (User.user_projects, Project.user_projects)
# lazy="selectin": N+1 문제 방지를 위한 즉시 로딩
# ==========================================================================
user: Mapped["User"] = relationship(
"User",
back_populates="user_projects",
lazy="selectin",
)
project: Mapped["Project"] = relationship(
"Project",
back_populates="user_projects",
lazy="selectin",
)
def __repr__(self) -> str:
return (
f"<UserProject("
f"id={self.id}, "
f"user_id={self.user_id}, "
f"project_id={self.project_id}, "
f"role='{self.role}'"
f")>"
)
class Project(Base):
"""
@ -149,16 +32,15 @@ class Project(Base):
id: 고유 식별자 (자동 증가)
store_name: 고객명 (필수)
region: 지역명 (필수, : 서울, 부산, 대구 )
task_id: 작업 고유 식별자 (UUID 형식, 36)
task_id: 작업 고유 식별자 (UUID7 형식, 36)
detail_region_info: 상세 지역 정보 (선택, JSON 또는 텍스트 형식)
created_at: 생성 일시 (자동 설정)
Relationships:
owner: 프로젝트 소유자 (User, 1:N 관계)
lyrics: 생성된 가사 목록
songs: 생성된 노래 목록
videos: 최종 영상 결과 목록
user_projects: User와의 M:N 관계 (중계 테이블 통한 연결)
users: 프로젝트에 참여한 사용자 목록 (Association Proxy)
"""
__tablename__ = "project"
@ -166,6 +48,8 @@ class Project(Base):
Index("idx_project_task_id", "task_id"),
Index("idx_project_store_name", "store_name"),
Index("idx_project_region", "region"),
Index("idx_project_user_uuid", "user_uuid"),
Index("idx_project_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -184,21 +68,37 @@ class Project(Base):
store_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
index=True,
comment="가게명",
)
region: Mapped[str] = mapped_column(
String(100),
nullable=False,
index=True,
comment="지역명 (예: 군산)",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="프로젝트 작업 고유 식별자 (UUID)",
unique=True,
comment="프로젝트 작업 고유 식별자 (UUID7)",
)
# ==========================================================================
# User 1:N 관계 (한 사용자가 여러 프로젝트를 소유)
# ==========================================================================
user_uuid: Mapped[Optional[str]] = mapped_column(
String(36),
ForeignKey("user.user_uuid", ondelete="SET NULL"),
nullable=True,
comment="프로젝트 소유자 (User.user_uuid 외래키)",
)
# 소유자 관계 설정 (User.projects와 양방향 연결)
owner: Mapped[Optional["User"]] = relationship(
"User",
back_populates="projects",
lazy="selectin",
)
detail_region_info: Mapped[Optional[str]] = mapped_column(
@ -214,6 +114,13 @@ class Project(Base):
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
@ -243,20 +150,6 @@ class Project(Base):
lazy="selectin",
)
# ==========================================================================
# User M:N 관계 (중계 테이블 UserProject 통한 연결)
# ==========================================================================
# back_populates: UserProject.project와 양방향 연결
# cascade: Project 삭제 시 UserProject 레코드도 삭제 (User는 유지)
# lazy="selectin": N+1 문제 방지
# ==========================================================================
user_projects: Mapped[List["UserProject"]] = relationship(
"UserProject",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str:
if value is None:
@ -281,7 +174,7 @@ class Image(Base):
Attributes:
id: 고유 식별자 (자동 증가)
task_id: 이미지 업로드 작업 고유 식별자 (UUID)
task_id: 이미지 업로드 작업 고유 식별자 (UUID7)
img_name: 이미지명
img_url: 이미지 URL (S3, CDN 등의 경로)
created_at: 생성 일시 (자동 설정)
@ -289,6 +182,8 @@ class Image(Base):
__tablename__ = "image"
__table_args__ = (
Index("idx_image_task_id", "task_id"),
Index("idx_image_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -307,7 +202,7 @@ class Image(Base):
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="이미지 업로드 작업 고유 식별자 (UUID)",
comment="이미지 업로드 작업 고유 식별자 (UUID7)",
)
img_name: Mapped[str] = mapped_column(
@ -329,6 +224,13 @@ class Image(Base):
comment="이미지 순서",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,

View File

@ -22,6 +22,7 @@ async def save_upload_file(file: UploadFile, save_path: Path) -> None:
async def upload_image_to_blob(
task_id: str,
user_uuid: str,
file: UploadFile,
filename: str,
save_dir: Path,
@ -31,6 +32,7 @@ async def upload_image_to_blob(
Args:
task_id: 작업 고유 식별자
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
file: 업로드할 파일 객체
filename: 저장될 파일명
save_dir: media 저장 디렉토리 경로
@ -46,7 +48,7 @@ async def upload_image_to_blob(
await save_upload_file(file, save_path)
# 2. Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
upload_success = await uploader.upload_image(file_path=str(save_path))
if upload_success:

View File

@ -8,11 +8,11 @@ Lyric API Router
- POST /lyric/generate: 가사 생성
- GET /lyric/status/{task_id}: 가사 생성 상태 조회
- GET /lyric/{task_id}: 가사 상세 조회
- GET /lyrics: 가사 목록 조회 (페이지네이션)
- GET /lyric/list: 가사 목록 조회 (페이지네이션)
사용 예시:
from app.lyric.api.routers.v1.lyric import router
app.include_router(router, prefix="/api/v1")
app.include_router(router)
다른 서비스에서 재사용:
# 이 파일의 헬퍼 함수들을 import하여 사용 가능
@ -31,6 +31,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.home.models import Project
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.lyric.models import Lyric
from app.lyric.schemas.lyric import (
GenerateLyricRequest,
@ -172,6 +174,9 @@ async def get_lyric_by_task_id(
고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다.
백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 요청 필드
- **task_id**: 작업 고유 식별자 (이미지 업로드 생성된 task_id, 필수)
- **customer_name**: 고객명/가게명 (필수)
@ -190,16 +195,18 @@ async def get_lyric_by_task_id(
- GET /lyric/status/{task_id} 처리 상태 확인
- GET /lyric/{task_id} 생성된 가사 조회
## 사용 예시
```
POST /lyric/generate
{
## 사용 예시 (cURL)
```bash
curl -X POST "http://localhost:8000/lyric/generate" \\
-H "Authorization: Bearer {access_token}" \\
-H "Content-Type: application/json" \\
-d '{
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean"
}
}'
```
## 응답 예시
@ -216,12 +223,14 @@ POST /lyric/generate
response_model=GenerateLyricResponse,
responses={
200: {"description": "가사 생성 요청 접수 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
500: {"description": "서버 내부 오류"},
},
)
async def generate_lyric(
request_body: GenerateLyricRequest,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> GenerateLyricResponse:
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
@ -293,6 +302,7 @@ async def generate_lyric(
task_id=task_id,
detail_region_info=request_body.detail_region_info,
language=request_body.language,
user_uuid=current_user.user_uuid,
)
session.add(project)
await session.commit()
@ -373,24 +383,30 @@ async def generate_lyric(
description="""
task_id로 가사 생성 작업의 현재 상태를 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 상태 값
- **processing**: 가사 생성
- **completed**: 가사 생성 완료
- **failed**: 가사 생성 실패
## 사용 예시
```
GET /lyric/status/019123ab-cdef-7890-abcd-ef1234567890
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/lyric/status/019123ab-cdef-7890-abcd-ef1234567890" \\
-H "Authorization: Bearer {access_token}"
```
""",
response_model=LyricStatusResponse,
responses={
200: {"description": "상태 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
404: {"description": "해당 task_id를 찾을 수 없음"},
},
)
async def get_lyric_status(
task_id: str,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> LyricStatusResponse:
"""task_id로 가사 생성 작업 상태를 조회합니다."""
@ -398,11 +414,14 @@ async def get_lyric_status(
@router.get(
"s/",
"/list",
summary="가사 목록 조회 (페이지네이션)",
description="""
생성 완료된 가사를 페이지네이션으로 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 20, 최대: 100)
@ -416,11 +435,19 @@ async def get_lyric_status(
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시
```
GET /lyrics/ # 기본 조회 (1페이지, 20개)
GET /lyrics/?page=2 # 2페이지 조회
GET /lyrics/?page=1&page_size=50 # 50개씩 조회
## 사용 예시 (cURL)
```bash
# 기본 조회 (1페이지, 20개)
curl -X GET "http://localhost:8000/lyric/list" \\
-H "Authorization: Bearer {access_token}"
# 2페이지 조회
curl -X GET "http://localhost:8000/lyric/list?page=2" \\
-H "Authorization: Bearer {access_token}"
# 50개씩 조회
curl -X GET "http://localhost:8000/lyric/list?page=1&page_size=50" \\
-H "Authorization: Bearer {access_token}"
```
## 참고
@ -430,11 +457,13 @@ GET /lyrics/?page=1&page_size=50 # 50개씩 조회
response_model=PaginatedResponse[LyricListItem],
responses={
200: {"description": "가사 목록 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
},
)
async def list_lyrics(
page: int = Query(1, ge=1, description="페이지 번호 (1부터 시작)"),
page_size: int = Query(20, ge=1, le=100, description="페이지당 데이터 수"),
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> PaginatedResponse[LyricListItem]:
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
@ -456,6 +485,9 @@ async def list_lyrics(
description="""
task_id로 생성된 가사의 상세 정보를 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 반환 정보
- **id**: 가사 ID
- **task_id**: 작업 고유 식별자
@ -465,19 +497,22 @@ task_id로 생성된 가사의 상세 정보를 조회합니다.
- **lyric_result**: 생성된 가사 (완료 )
- **created_at**: 생성 일시
## 사용 예시
```
GET /lyric/019123ab-cdef-7890-abcd-ef1234567890
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/lyric/019123ab-cdef-7890-abcd-ef1234567890" \\
-H "Authorization: Bearer {access_token}"
```
""",
response_model=LyricDetailResponse,
responses={
200: {"description": "가사 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
404: {"description": "해당 task_id를 찾을 수 없음"},
},
)
async def get_lyric_detail(
task_id: str,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> LyricDetailResponse:
"""task_id로 생성된 가사를 조회합니다."""

View File

@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.mysql import LONGTEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
@ -23,7 +23,7 @@ class Lyric(Base):
Attributes:
id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키)
task_id: 가사 생성 작업의 고유 식별자 (UUID 형식)
task_id: 가사 생성 작업의 고유 식별자 (UUID7 형식)
status: 처리 상태 (pending, processing, completed, failed )
lyric_prompt: 가사 생성에 사용된 프롬프트
lyric_result: 생성된 가사 결과 (LONGTEXT로 가사 지원)
@ -37,6 +37,9 @@ class Lyric(Base):
__tablename__ = "lyric"
__table_args__ = (
Index("idx_lyric_task_id", "task_id"),
Index("idx_lyric_project_id", "project_id"),
Index("idx_lyric_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -56,14 +59,13 @@ class Lyric(Base):
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="가사 생성 작업 고유 식별자 (UUID)",
comment="가사 생성 작업 고유 식별자 (UUID7)",
)
status: Mapped[str] = mapped_column(
@ -91,6 +93,13 @@ class Lyric(Base):
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=True,

View File

@ -9,7 +9,7 @@ Song API Router
사용 예시:
from app.song.api.routers.v1.song import router
app.include_router(router, prefix="/api/v1")
app.include_router(router)
"""
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
@ -18,6 +18,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.home.models import Project
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp
from app.song.schemas.song_schema import (
@ -40,6 +42,9 @@ router = APIRouter(prefix="/song", tags=["Song"])
description="""
Suno API를 통해 노래 생성을 요청합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **task_id**: Project/Lyric의 task_id (필수) - 연관된 프로젝트와 가사를 조회하는 사용
@ -54,14 +59,16 @@ Suno API를 통해 노래 생성을 요청합니다.
- **song_id**: Suno API 작업 ID (상태 조회에 사용)
- **message**: 응답 메시지
## 사용 예시
```
POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
{
## 사용 예시 (cURL)
```bash
curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234567890" \\
-H "Authorization: Bearer {access_token}" \\
-H "Content-Type: application/json" \\
-d '{
"lyrics": "여기 군산에서 만나요\\n아름다운 하루를 함께",
"genre": "K-Pop",
"language": "Korean"
}
}'
```
## 참고
@ -72,6 +79,7 @@ POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
response_model=GenerateSongResponse,
responses={
200: {"description": "노래 생성 요청 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
404: {"description": "Project 또는 Lyric을 찾을 수 없음"},
500: {"description": "노래 생성 요청 실패"},
},
@ -79,6 +87,7 @@ POST /song/generate/019123ab-cdef-7890-abcd-ef1234567890
async def generate_song(
task_id: str,
request_body: GenerateSongRequest,
current_user: User = Depends(get_current_user),
) -> GenerateSongResponse:
"""가사와 장르를 기반으로 Suno API를 통해 노래를 생성합니다.
@ -313,6 +322,9 @@ async def generate_song(
Suno API를 통해 노래 생성 작업의 상태를 조회합니다.
SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드를 시작합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **song_id**: 노래 생성 반환된 Suno API 작업 ID (필수)
@ -321,9 +333,10 @@ SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고
- **status**: Suno API 작업 상태
- **message**: 상태 메시지
## 사용 예시
```
GET /song/status/abc123...
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/song/status/{song_id}" \\
-H "Authorization: Bearer {access_token}"
```
## 상태 값 (Suno API 응답)
@ -342,11 +355,13 @@ GET /song/status/abc123...
response_model=PollingSongResponse,
responses={
200: {"description": "상태 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
},
)
async def get_song_status(
song_id: str,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> PollingSongResponse:
"""song_id로 노래 생성 작업의 상태를 조회합니다.
@ -421,6 +436,7 @@ async def get_song_status(
suno_task_id=song_id,
audio_url=audio_url,
store_name=store_name,
user_uuid=current_user.user_uuid,
duration=clip_duration,
)
logger.info(
@ -467,6 +483,19 @@ async def get_song_status(
for order_idx, timestamped_lyric in enumerate(
timestamped_lyrics
):
# start_sec 또는 end_sec가 None인 경우 건너뛰기
if (
timestamped_lyric["start_sec"] is None
or timestamped_lyric["end_sec"] is None
):
logger.warning(
f"[get_song_status] Skipping timestamp - "
f"lyric_line: {timestamped_lyric['text']}, "
f"start_sec: {timestamped_lyric['start_sec']}, "
f"end_sec: {timestamped_lyric['end_sec']}"
)
continue
song_timestamp = SongTimestamp(
suno_audio_id=suno_audio_id,
order_idx=order_idx,

View File

@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, func
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -23,7 +23,7 @@ class Song(Base):
id: 고유 식별자 (자동 증가)
project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키)
task_id: 노래 생성 작업의 고유 식별자 (UUID 형식)
task_id: 노래 생성 작업의 고유 식별자 (UUID7 형식)
suno_task_id: Suno API 작업 고유 식별자 (선택)
status: 처리 상태 (processing, uploading, completed, failed)
song_prompt: 노래 생성에 사용된 프롬프트
@ -39,6 +39,10 @@ class Song(Base):
__tablename__ = "song"
__table_args__ = (
Index("idx_song_task_id", "task_id"),
Index("idx_song_project_id", "project_id"),
Index("idx_song_lyric_id", "lyric_id"),
Index("idx_song_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -58,7 +62,6 @@ class Song(Base):
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
@ -66,14 +69,13 @@ class Song(Base):
Integer,
ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Lyric의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="노래 생성 작업 고유 식별자 (UUID)",
comment="노래 생성 작업 고유 식별자 (UUID7)",
)
suno_task_id: Mapped[Optional[str]] = mapped_column(
@ -118,6 +120,13 @@ class Song(Base):
comment="출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
@ -177,6 +186,8 @@ class SongTimestamp(Base):
__tablename__ = "song_timestamp"
__table_args__ = (
Index("idx_song_timestamp_suno_audio_id", "suno_audio_id"),
Index("idx_song_timestamp_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -195,7 +206,6 @@ class SongTimestamp(Base):
suno_audio_id: Mapped[str] = mapped_column(
String(64),
nullable=False,
index=True,
comment="가사의 원본 오디오 ID",
)
@ -223,6 +233,13 @@ class SongTimestamp(Base):
comment="가사 종료 시점 (초)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,

View File

@ -177,6 +177,7 @@ async def download_and_upload_song_by_suno_task_id(
suno_task_id: str,
audio_url: str,
store_name: str,
user_uuid: str,
duration: float | None = None,
) -> None:
"""suno_task_id로 Song을 조회하여 노래를 다운로드하고 Azure Blob Storage에 업로드한 뒤 Song 테이블을 업데이트합니다.
@ -185,6 +186,7 @@ async def download_and_upload_song_by_suno_task_id(
suno_task_id: Suno API 작업 ID
audio_url: 다운로드할 오디오 URL
store_name: 저장할 파일명에 사용할 업체명
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
duration: 노래 재생 시간 ()
"""
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
@ -233,7 +235,7 @@ async def download_and_upload_song_by_suno_task_id(
logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
upload_success = await uploader.upload_music(file_path=str(temp_file_path))
if not upload_success:

View File

@ -5,10 +5,14 @@
"""
import logging
import random
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Header, Request, status
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import prj_settings
@ -16,7 +20,7 @@ from app.database.session import get_session
logger = logging.getLogger(__name__)
from app.user.dependencies import get_current_user
from app.user.models import User
from app.user.models import RefreshToken, User
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoCodeRequest,
@ -26,9 +30,56 @@ from app.user.schemas.user_schema import (
UserResponse,
)
from app.user.services import auth_service, kakao_client
from app.user.services.jwt import (
create_access_token,
create_refresh_token,
get_access_token_expire_seconds,
get_refresh_token_expires_at,
get_token_hash,
)
from app.utils.common import generate_uuid
router = APIRouter(prefix="/auth", tags=["Auth"])
# =============================================================================
# 테스트용 라우터 (DEBUG 모드에서만 main.py에서 등록됨)
# =============================================================================
test_router = APIRouter(prefix="/auth/test", tags=["Test Auth"])
# =============================================================================
# 테스트용 스키마
# =============================================================================
class TestUserCreateRequest(BaseModel):
"""테스트 사용자 생성 요청"""
nickname: str = "테스트유저"
class TestUserCreateResponse(BaseModel):
"""테스트 사용자 생성 응답"""
user_id: int
user_uuid: str
nickname: str
message: str
class TestTokenRequest(BaseModel):
"""테스트 토큰 발급 요청"""
user_uuid: str
class TestTokenResponse(BaseModel):
"""테스트 토큰 발급 응답"""
access_token: str
refresh_token: str
token_type: str = "Bearer"
expires_in: int
@router.get(
"/kakao/login",
@ -187,6 +238,10 @@ async def refresh_token(
status_code=status.HTTP_204_NO_CONTENT,
summary="로그아웃",
description="현재 세션의 리프레시 토큰을 폐기합니다.",
responses={
204: {"description": "로그아웃 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
},
)
async def logout(
body: RefreshTokenRequest,
@ -212,6 +267,10 @@ async def logout(
status_code=status.HTTP_204_NO_CONTENT,
summary="모든 기기에서 로그아웃",
description="사용자의 모든 리프레시 토큰을 폐기합니다.",
responses={
204: {"description": "모든 기기에서 로그아웃 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
},
)
async def logout_all(
current_user: User = Depends(get_current_user),
@ -235,6 +294,10 @@ async def logout_all(
response_model=UserResponse,
summary="내 정보 조회",
description="현재 로그인한 사용자의 정보를 반환합니다.",
responses={
200: {"description": "조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
},
)
async def get_me(
current_user: User = Depends(get_current_user),
@ -245,3 +308,143 @@ async def get_me(
현재 로그인한 사용자의 상세 정보를 반환합니다.
"""
return UserResponse.model_validate(current_user)
# =============================================================================
# 테스트용 엔드포인트 (DEBUG 모드에서만 main.py에서 라우터가 등록됨)
# =============================================================================
@test_router.post(
"/create-user",
response_model=TestUserCreateResponse,
summary="[테스트] 사용자 직접 생성",
description="""
**DEBUG 모드에서만 사용 가능합니다.**
카카오 로그인 없이 테스트용 사용자를 직접 생성합니다.
생성된 user_uuid로 `/generate-token` 엔드포인트에서 토큰을 발급받을 있습니다.
""",
)
async def create_test_user(
body: TestUserCreateRequest,
session: AsyncSession = Depends(get_session),
) -> TestUserCreateResponse:
"""
테스트용 사용자 직접 생성
카카오 로그인 없이 테스트용 사용자를 생성합니다.
DEBUG 모드에서만 사용 가능합니다.
"""
logger.info(f"[TEST] 테스트 사용자 생성 요청 - nickname: {body.nickname}")
# 고유한 uuid 생성
user_uuid = await generate_uuid(session=session, table_name=User)
# 테스트용 가짜 kakao_id 생성 (충돌 방지를 위해 큰 범위 사용)
fake_kakao_id = random.randint(9000000000, 9999999999)
# 사용자 생성
new_user = User(
kakao_id=fake_kakao_id,
user_uuid=user_uuid,
nickname=body.nickname,
is_active=True,
)
session.add(new_user)
await session.commit()
await session.refresh(new_user)
logger.info(
f"[TEST] 테스트 사용자 생성 완료 - user_id: {new_user.id}, "
f"user_uuid: {new_user.user_uuid}"
)
return TestUserCreateResponse(
user_id=new_user.id,
user_uuid=new_user.user_uuid,
nickname=new_user.nickname or body.nickname,
message="테스트 사용자가 생성되었습니다.",
)
@test_router.post(
"/generate-token",
response_model=TestTokenResponse,
summary="[테스트] 토큰 직접 발급",
description="""
**DEBUG 모드에서만 사용 가능합니다.**
user_uuid로 JWT 토큰을 직접 발급합니다.
`/create-user`에서 생성한 사용자의 user_uuid를 사용하세요.
""",
)
async def generate_test_token(
request: Request,
body: TestTokenRequest,
session: AsyncSession = Depends(get_session),
user_agent: Optional[str] = Header(None, alias="User-Agent"),
) -> TestTokenResponse:
"""
테스트용 토큰 직접 발급
카카오 로그인 없이 user_uuid로 JWT 토큰을 발급합니다.
DEBUG 모드에서만 사용 가능합니다.
"""
logger.info(f"[TEST] 테스트 토큰 발급 요청 - user_uuid: {body.user_uuid}")
# 사용자 조회
result = await session.execute(
select(User).where(User.user_uuid == body.user_uuid)
)
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"사용자를 찾을 수 없습니다: {body.user_uuid}",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="비활성화된 사용자입니다.",
)
# JWT 토큰 생성
access_token = create_access_token(user.user_uuid)
refresh_token = create_refresh_token(user.user_uuid)
# 클라이언트 IP 추출
ip_address = request.client.host if request.client else None
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
ip_address = forwarded_for.split(",")[0].strip()
# 리프레시 토큰 DB 저장
token_hash = get_token_hash(refresh_token)
expires_at = get_refresh_token_expires_at()
db_refresh_token = RefreshToken(
user_id=user.id,
user_uuid=user.user_uuid,
token_hash=token_hash,
expires_at=expires_at,
user_agent=user_agent,
ip_address=ip_address,
)
session.add(db_refresh_token)
# 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
logger.info(
f"[TEST] 테스트 토큰 발급 완료 - user_id: {user.id}, "
f"user_uuid: {user.user_uuid}"
)
return TestTokenResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="Bearer",
expires_in=get_access_token_expire_seconds(),
)

View File

@ -45,7 +45,7 @@ class UserAdmin(ModelView, model=User):
form_excluded_columns = [
"created_at",
"updated_at",
"user_projects",
"projects",
"refresh_tokens",
"social_accounts",
]

View File

@ -58,14 +58,14 @@ async def get_current_user(
if payload.get("type") != "access":
raise InvalidTokenError("액세스 토큰이 아닙니다.")
user_id = payload.get("sub")
if user_id is None:
user_uuid = payload.get("sub")
if user_uuid is None:
raise InvalidTokenError()
# 사용자 조회
result = await session.execute(
select(User).where(
User.id == int(user_id),
User.user_uuid == user_uuid,
User.is_deleted == False, # noqa: E712
)
)
@ -106,13 +106,13 @@ async def get_current_user_optional(
if payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
user_uuid = payload.get("sub")
if user_uuid is None:
return None
result = await session.execute(
select(User).where(
User.id == int(user_id),
User.user_uuid == user_uuid,
User.is_deleted == False, # noqa: E712
)
)

View File

@ -111,7 +111,7 @@ class MissingTokenError(AuthException):
class UserNotFoundError(AuthException):
"""사용자 없음"""
def __init__(self, message: str = "사용자를 찾을 수 없습니다."):
def __init__(self, message: str = "가입되지 않은 사용자 입니다."):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
code="USER_NOT_FOUND",
@ -122,7 +122,7 @@ class UserNotFoundError(AuthException):
class UserInactiveError(AuthException):
"""비활성화된 계정"""
def __init__(self, message: str = "비활성화된 계정입니다. 관리자에게 문의하세요."):
def __init__(self, message: str = "활성화 상태가 아닌 사용자 입니다."):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
code="USER_INACTIVE",

View File

@ -14,7 +14,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.home.models import UserProject
from app.home.models import Project
class User(Base):
@ -26,6 +26,7 @@ class User(Base):
Attributes:
id: 고유 식별자 (자동 증가)
kakao_id: 카카오 고유 ID (필수, 유니크)
user_uuid: 사용자 식별을 위한 UUID7 (필수, 유니크)
email: 이메일 주소 (선택, 카카오에서 제공 )
nickname: 카카오 닉네임 (선택)
profile_image_url: 카카오 프로필 이미지 URL (선택)
@ -53,8 +54,7 @@ class User(Base):
- 실제 데이터는 DB에 유지됨
Relationships:
user_projects: Project와의 M:N 관계 (중계 테이블 통한 연결)
projects: 사용자가 참여한 프로젝트 목록 (Association Proxy)
projects: 사용자가 소유한 프로젝트 목록 (1:N 관계)
카카오 API 응답 필드 매핑:
- kakao_id: id (카카오 회원번호)
@ -68,7 +68,8 @@ class User(Base):
__tablename__ = "user"
__table_args__ = (
Index("idx_user_kakao_id", "kakao_id", unique=True),
Index("idx_user_kakao_id", "kakao_id"),
Index("idx_user_uuid", "user_uuid"),
Index("idx_user_email", "email"),
Index("idx_user_phone", "phone"),
Index("idx_user_is_active", "is_active"),
@ -103,6 +104,13 @@ class User(Base):
comment="카카오 고유 ID (회원번호)",
)
user_uuid: Mapped[str] = mapped_column(
String(36),
nullable=False,
unique=True,
comment="사용자 식별을 위한 UUID7 (시간순 정렬 가능한 UUID)",
)
# ==========================================================================
# 카카오에서 제공하는 사용자 정보 (선택적)
# ==========================================================================
@ -222,16 +230,15 @@ class User(Base):
)
# ==========================================================================
# Project M:N 관계 (중계 테이블 UserProject 통한 연결)
# Project 1:N 관계 (한 사용자가 여러 프로젝트를 소유)
# ==========================================================================
# back_populates: UserProject.user와 양방향 연결
# cascade: User 삭제 시 UserProject 레코드도 삭제 (Project는 유지)
# back_populates: Project.owner와 양방향 연결
# cascade: 사용자 삭제 시 프로젝트는 유지 (owner가 NULL로 설정됨)
# lazy="selectin": N+1 문제 방지
# ==========================================================================
user_projects: Mapped[List["UserProject"]] = relationship(
"UserProject",
back_populates="user",
cascade="all, delete-orphan",
projects: Mapped[List["Project"]] = relationship(
"Project",
back_populates="owner",
lazy="selectin",
)
@ -282,6 +289,7 @@ class RefreshToken(Base):
Attributes:
id: 고유 식별자 (자동 증가)
user_id: 사용자 외래키 (User.id 참조)
user_uuid: 사용자 UUID (User.user_uuid 참조)
token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X)
expires_at: 토큰 만료 일시
is_revoked: 토큰 폐기 여부 (로그아웃 True)
@ -299,6 +307,7 @@ class RefreshToken(Base):
__tablename__ = "refresh_token"
__table_args__ = (
Index("idx_refresh_token_user_id", "user_id"),
Index("idx_refresh_token_user_uuid", "user_uuid"),
Index("idx_refresh_token_token_hash", "token_hash", unique=True),
Index("idx_refresh_token_expires_at", "expires_at"),
Index("idx_refresh_token_is_revoked", "is_revoked"),
@ -324,6 +333,12 @@ class RefreshToken(Base):
comment="사용자 외래키 (User.id 참조)",
)
user_uuid: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="사용자 UUID (User.user_uuid 참조)",
)
token_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,
@ -421,12 +436,13 @@ class SocialAccount(Base):
__tablename__ = "social_account"
__table_args__ = (
Index("idx_social_account_user_id", "user_id"),
Index("idx_social_account_user_uuid", "user_uuid"),
Index("idx_social_account_platform", "platform"),
Index("idx_social_account_is_active", "is_active"),
Index("idx_social_account_is_deleted", "is_deleted"),
Index(
"uq_user_platform_account",
"user_id",
"user_uuid",
"platform",
"platform_user_id",
unique=True,
@ -449,11 +465,11 @@ class SocialAccount(Base):
comment="고유 식별자",
)
user_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("user.id", ondelete="CASCADE"),
user_uuid: Mapped[str] = mapped_column(
String(36),
ForeignKey("user.user_uuid", ondelete="CASCADE"),
nullable=False,
comment="사용자 외래키 (User.id 참조)",
comment="사용자 외래키 (User.user_uuid 참조)",
)
# ==========================================================================
@ -526,6 +542,13 @@ class SocialAccount(Base):
comment="연동 활성화 상태 (비활성화 시 사용 중지)",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
# ==========================================================================
# 시간 정보
# ==========================================================================
@ -556,7 +579,7 @@ class SocialAccount(Base):
return (
f"<SocialAccount("
f"id={self.id}, "
f"user_id={self.user_id}, "
f"user_uuid='{self.user_uuid}', "
f"platform='{self.platform}', "
f"platform_username='{self.platform_username}', "
f"is_active={self.is_active}"

View File

@ -21,7 +21,7 @@ class KakaoLoginResponse(BaseModel):
model_config = {
"json_schema_extra": {
"example": {
"auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/api/v1/user/auth/kakao/callback&response_type=code"
"auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/user/auth/kakao/callback&response_type=code"
}
}
}
@ -156,13 +156,13 @@ class UserBriefResponse(BaseModel):
class LoginResponse(BaseModel):
"""로그인 응답 (토큰 + 사용자 정보)"""
"""로그인 응답 (토큰 정보)"""
access_token: str = Field(..., description="액세스 토큰")
refresh_token: str = Field(..., description="리프레시 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
user: UserBriefResponse = Field(..., description="사용자 정보")
is_new_user: bool = Field(..., description="신규 가입 여부")
redirect_url: str = Field(..., description="로그인 후 리다이렉트할 프론트엔드 URL")
model_config = {
@ -172,13 +172,7 @@ class LoginResponse(BaseModel):
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"id": 1,
"nickname": "홍길동",
"email": "user@kakao.com",
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
"is_new_user": False
},
"is_new_user": False,
"redirect_url": "http://localhost:3000"
}
}

View File

@ -24,11 +24,11 @@ from app.user.exceptions import (
UserNotFoundError,
)
from app.user.models import RefreshToken, User
from app.utils.common import generate_uuid
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoUserInfo,
LoginResponse,
UserBriefResponse,
)
from app.user.services.jwt import (
create_access_token,
@ -96,27 +96,28 @@ class AuthService:
# 5. JWT 토큰 생성
logger.info("[AUTH] 5단계: JWT 토큰 생성 시작")
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_id: {user.id}")
access_token = create_access_token(user.user_uuid)
refresh_token = create_refresh_token(user.user_uuid)
logger.debug(f"[AUTH] JWT 토큰 생성 완료 - user_uuid: {user.user_uuid}")
# 6. 리프레시 토큰 DB 저장
logger.info("[AUTH] 6단계: 리프레시 토큰 저장 시작")
await self._save_refresh_token(
user_id=user.id,
user_uuid=user.user_uuid,
token=refresh_token,
session=session,
user_agent=user_agent,
ip_address=ip_address,
)
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}")
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
# 7. 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, redirect_url: {redirect_url}")
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}, redirect_url: {redirect_url}")
logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...")
return LoginResponse(
@ -124,13 +125,7 @@ class AuthService:
refresh_token=refresh_token,
token_type="Bearer",
expires_in=get_access_token_expire_seconds(),
user=UserBriefResponse(
id=user.id,
nickname=user.nickname,
email=user.email,
profile_image_url=user.profile_image_url,
is_new_user=is_new_user,
),
is_new_user=is_new_user,
redirect_url=redirect_url,
)
@ -177,8 +172,8 @@ class AuthService:
raise TokenExpiredError()
# 4. 사용자 확인
user_id = int(payload.get("sub"))
user = await self._get_user_by_id(user_id, session)
user_uuid = payload.get("sub")
user = await self._get_user_by_uuid(user_uuid, session)
if user is None:
raise UserNotFoundError()
@ -187,7 +182,7 @@ class AuthService:
raise UserInactiveError()
# 5. 새 액세스 토큰 발급
new_access_token = create_access_token(user.id)
new_access_token = create_access_token(user.user_uuid)
return AccessTokenResponse(
access_token=new_access_token,
@ -269,8 +264,10 @@ class AuthService:
# 신규 사용자 생성
logger.info(f"[AUTH] 신규 사용자 생성 시작 - kakao_id: {kakao_id}")
user_uuid = await generate_uuid(session=session, table_name=User)
new_user = User(
kakao_id=kakao_id,
user_uuid=user_uuid,
email=kakao_account.email if kakao_account else None,
nickname=profile.nickname if profile else None,
profile_image_url=profile.profile_image_url if profile else None,
@ -319,6 +316,7 @@ class AuthService:
async def _save_refresh_token(
self,
user_id: int,
user_uuid: str,
token: str,
session: AsyncSession,
user_agent: Optional[str] = None,
@ -329,6 +327,7 @@ class AuthService:
Args:
user_id: 사용자 ID
user_uuid: 사용자 UUID
token: 리프레시 토큰
session: DB 세션
user_agent: User-Agent
@ -342,6 +341,7 @@ class AuthService:
refresh_token = RefreshToken(
user_id=user_id,
user_uuid=user_uuid,
token_hash=token_hash,
expires_at=expires_at,
user_agent=user_agent,
@ -391,6 +391,26 @@ class AuthService:
)
return result.scalar_one_or_none()
async def _get_user_by_uuid(
self,
user_uuid: str,
session: AsyncSession,
) -> Optional[User]:
"""
UUID로 사용자 조회
Args:
user_uuid: 사용자 UUID
session: DB 세션
Returns:
User 객체 또는 None
"""
result = await session.execute(
select(User).where(User.user_uuid == user_uuid, User.is_deleted == False) # noqa: E712
)
return result.scalar_one_or_none()
async def _revoke_refresh_token_by_hash(
self,
token_hash: str,

View File

@ -13,12 +13,12 @@ from jose import JWTError, jwt
from config import jwt_settings
def create_access_token(user_id: int) -> str:
def create_access_token(user_uuid: str) -> str:
"""
JWT 액세스 토큰 생성
Args:
user_id: 사용자 ID
user_uuid: 사용자 UUID
Returns:
JWT 액세스 토큰 문자열
@ -27,7 +27,7 @@ def create_access_token(user_id: int) -> str:
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {
"sub": str(user_id),
"sub": user_uuid,
"exp": expire,
"type": "access",
}
@ -38,12 +38,12 @@ def create_access_token(user_id: int) -> str:
)
def create_refresh_token(user_id: int) -> str:
def create_refresh_token(user_uuid: str) -> str:
"""
JWT 리프레시 토큰 생성
Args:
user_id: 사용자 ID
user_uuid: 사용자 UUID
Returns:
JWT 리프레시 토큰 문자열
@ -52,7 +52,7 @@ def create_refresh_token(user_id: int) -> str:
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode = {
"sub": str(user_id),
"sub": user_uuid,
"exp": expire,
"type": "refresh",
}

View File

@ -4,21 +4,71 @@ Common Utility Functions
공통으로 사용되는 유틸리티 함수들을 정의합니다.
사용 예시:
from app.utils.common import generate_task_id
from app.utils.common import generate_task_id, generate_uuid
# task_id 생성
task_id = await generate_task_id(session=session, table_name=Project)
# uuid 생성
user_uuid = await generate_uuid(session=session, table_name=User)
Note:
페이지네이션 기능은 app.utils.pagination 모듈을 사용하세요:
from app.utils.pagination import PaginatedResponse, get_paginated
"""
import os
import time
from typing import Any, Optional, Type
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from uuid_extensions import uuid7
def _generate_uuid7_string() -> str:
"""UUID7 문자열을 생성합니다.
UUID7 구조 (RFC 9562):
- 48 bits: Unix timestamp (밀리초)
- 4 bits: 버전 (7)
- 12 bits: 랜덤
- 2 bits: variant (10)
- 62 bits: 랜덤
- 128 bits -> 36 (하이픈 포함)
Returns:
36자리 UUID7 문자열 (xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx)
"""
# 현재 시간 (밀리초)
timestamp_ms = int(time.time() * 1000)
# 랜덤 바이트 (10바이트 = 80비트)
random_bytes = os.urandom(10)
# UUID7 바이트 구성 (16바이트 = 128비트)
# 처음 6바이트: 타임스탬프 (48비트)
uuid_bytes = timestamp_ms.to_bytes(6, byteorder="big")
# 다음 2바이트: 버전(7) + 랜덤 12비트
# 0x7000 | (random 12 bits)
rand_a = int.from_bytes(random_bytes[0:2], byteorder="big")
version_rand = (0x7000 | (rand_a & 0x0FFF)).to_bytes(2, byteorder="big")
uuid_bytes += version_rand
# 다음 2바이트: variant(10) + 랜덤 62비트의 앞 6비트
# 0x80 | (random 6 bits) + random 8 bits
rand_b = random_bytes[2]
variant_rand = bytes([0x80 | (rand_b & 0x3F)]) + random_bytes[3:4]
uuid_bytes += variant_rand
# 나머지 6바이트: 랜덤
uuid_bytes += random_bytes[4:10]
# 16진수로 변환
hex_str = uuid_bytes.hex()
# UUID 형식으로 포맷팅 (8-4-4-4-12)
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
async def generate_task_id(
@ -32,16 +82,16 @@ async def generate_task_id(
table_name: task_id 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional)
Returns:
str: 생성된 uuid7 문자열
str: 생성된 UUID7 문자열 (36)
Usage:
# 단순 uuid7 생성
# 단순 UUID7 생성
task_id = await generate_task_id()
# 테이블에서 중복 검사 후 생성
task_id = await generate_task_id(session=session, table_name=Project)
"""
task_id = str(uuid7())
task_id = _generate_uuid7_string()
if session is None or table_name is None:
return task_id
@ -55,4 +105,41 @@ async def generate_task_id(
if existing is None:
return task_id
task_id = str(uuid7())
task_id = _generate_uuid7_string()
async def generate_uuid(
session: Optional[AsyncSession] = None,
table_name: Optional[Type[Any]] = None,
) -> str:
"""고유한 UUID7을 생성합니다.
Args:
session: SQLAlchemy AsyncSession (optional)
table_name: user_uuid 컬럼이 있는 SQLAlchemy 테이블 클래스 (optional)
Returns:
str: 생성된 UUID7 문자열 (36)
Usage:
# 단순 UUID7 생성
new_uuid = await generate_uuid()
# 테이블에서 중복 검사 후 생성
new_uuid = await generate_uuid(session=session, table_name=User)
"""
new_uuid = _generate_uuid7_string()
if session is None or table_name is None:
return new_uuid
while True:
result = await session.execute(
select(table_name).where(table_name.user_uuid == new_uuid)
)
existing = result.scalar_one_or_none()
if existing is None:
return new_uuid
new_uuid = _generate_uuid7_string()

View File

@ -488,7 +488,8 @@ class CreatomateService:
json=payload,
)
if response.status_code == 200 or response.status_code == 201:
# 200 OK, 201 Created, 202 Accepted 모두 성공으로 처리
if response.status_code in (200, 201, 202):
return response.json()
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
@ -557,7 +558,8 @@ class CreatomateService:
json=source,
)
if response.status_code == 200 or response.status_code == 201:
# 200 OK, 201 Created, 202 Accepted 모두 성공으로 처리
if response.status_code in (200, 201, 202):
return response.json()
# 재시도 불가능한 오류 (4xx 클라이언트 오류)

View File

@ -3,9 +3,9 @@ from typing import List
# Input 정의
class MarketingPromptInput(BaseModel):
customer_name : str
region : str
detail_region_info : str
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
region : str = Field(..., description = "마케팅 대상 지역")
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
# Output 정의
@ -25,7 +25,7 @@ class AgeRange(BaseModel):
class TargetPersona(BaseModel):
persona: str = Field(..., description="타겟 페르소나 이름/설명")
age: AgeRange
age: AgeRange = Field(..., description="타겟 페르소나 나이대")
favor_target: List[str] = Field(..., description="페르소나의 선호 요소")
decision_trigger: str = Field(..., description="구매 결정 트리거")
@ -36,8 +36,8 @@ class SellingPoint(BaseModel):
score: int = Field(..., ge=0, le=100, description="점수 (100점 만점)")
class MarketingPromptOutput(BaseModel):
brand_identity: BrandIdentity
market_positioning: MarketPositioning
target_persona: List[TargetPersona]
selling_points: List[SellingPoint]
brand_identity: BrandIdentity = Field(..., description="브랜드 아이덴티티")
market_positioning: MarketPositioning = Field(..., description="시장 포지셔닝")
target_persona: List[TargetPersona] = Field(..., description="타겟 페르소나")
selling_points: List[SellingPoint] = Field(..., description="셀링 포인트")
target_keywords: List[str] = Field(..., description="타겟 키워드 리스트")

View File

@ -5,14 +5,14 @@ Azure Blob Storage에 파일을 업로드하는 클래스를 제공합니다.
파일 경로 또는 바이트 데이터를 직접 업로드할 있습니다.
URL 경로 형식:
- 음악: {BASE_URL}/{task_id}/song/{파일명}
- 영상: {BASE_URL}/{task_id}/video/{파일명}
- 이미지: {BASE_URL}/{task_id}/image/{파일명}
- 음악: {BASE_URL}/{user_uuid}/{task_id}/song/{파일명}
- 영상: {BASE_URL}/{user_uuid}/{task_id}/video/{파일명}
- 이미지: {BASE_URL}/{user_uuid}/{task_id}/image/{파일명}
사용 예시:
from app.utils.upload_blob_as_request import AzureBlobUploader
uploader = AzureBlobUploader(task_id="task-123")
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
# 파일 경로로 업로드
success = await uploader.upload_music(file_path="my_song.mp3")
@ -79,14 +79,15 @@ class AzureBlobUploader:
"""Azure Blob Storage 업로드 클래스
Azure Blob Storage에 음악, 영상, 이미지 파일을 업로드합니다.
URL 형식: {BASE_URL}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
URL 형식: {BASE_URL}/{user_uuid}/{task_id}/{category}/{file_name}?{SAS_TOKEN}
카테고리별 경로:
- 음악: {task_id}/song/{file_name}
- 영상: {task_id}/video/{file_name}
- 이미지: {task_id}/image/{file_name}
- 음악: {user_uuid}/{task_id}/song/{file_name}
- 영상: {user_uuid}/{task_id}/video/{file_name}
- 이미지: {user_uuid}/{task_id}/image/{file_name}
Attributes:
user_uuid: 사용자 고유 식별자 (UUID)
task_id: 작업 고유 식별자
"""
@ -100,17 +101,24 @@ class AzureBlobUploader:
".bmp": "image/bmp",
}
def __init__(self, task_id: str):
def __init__(self, user_uuid: str, task_id: str):
"""AzureBlobUploader 초기화
Args:
user_uuid: 사용자 고유 식별자 (UUID)
task_id: 작업 고유 식별자
"""
self._user_uuid = user_uuid
self._task_id = task_id
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
self._last_public_url: str = ""
@property
def user_uuid(self) -> str:
"""사용자 고유 식별자 (UUID)"""
return self._user_uuid
@property
def task_id(self) -> str:
"""작업 고유 식별자"""
@ -126,12 +134,12 @@ class AzureBlobUploader:
# SAS 토큰 앞뒤의 ?, ', " 제거
sas_token = self._sas_token.strip("?'\"")
return (
f"{self._base_url}/{self._task_id}/{category}/{file_name}?{sas_token}"
f"{self._base_url}/{self._user_uuid}/{self._task_id}/{category}/{file_name}?{sas_token}"
)
def _build_public_url(self, category: str, file_name: str) -> str:
"""공개 URL 생성 (SAS 토큰 제외)"""
return f"{self._base_url}/{self._task_id}/{category}/{file_name}"
return f"{self._base_url}/{self._user_uuid}/{self._task_id}/{category}/{file_name}"
async def _upload_bytes(
self,
@ -253,7 +261,7 @@ class AzureBlobUploader:
async def upload_music(self, file_path: str) -> bool:
"""음악 파일을 Azure Blob Storage에 업로드합니다.
URL 경로: {task_id}/song/{파일명}
URL 경로: {user_uuid}/{task_id}/song/{파일명}
Args:
file_path: 업로드할 파일 경로
@ -262,7 +270,7 @@ class AzureBlobUploader:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
success = await uploader.upload_music(file_path="my_song.mp3")
print(uploader.public_url)
"""
@ -279,7 +287,7 @@ class AzureBlobUploader:
) -> bool:
"""음악 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/song/{파일명}
URL 경로: {user_uuid}/{task_id}/song/{파일명}
Args:
file_content: 업로드할 파일 바이트 데이터
@ -289,7 +297,7 @@ class AzureBlobUploader:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
success = await uploader.upload_music_bytes(audio_bytes, "my_song")
print(uploader.public_url)
"""
@ -315,7 +323,7 @@ class AzureBlobUploader:
async def upload_video(self, file_path: str) -> bool:
"""영상 파일을 Azure Blob Storage에 업로드합니다.
URL 경로: {task_id}/video/{파일명}
URL 경로: {user_uuid}/{task_id}/video/{파일명}
Args:
file_path: 업로드할 파일 경로
@ -324,7 +332,7 @@ class AzureBlobUploader:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
success = await uploader.upload_video(file_path="my_video.mp4")
print(uploader.public_url)
"""
@ -341,7 +349,7 @@ class AzureBlobUploader:
) -> bool:
"""영상 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/video/{파일명}
URL 경로: {user_uuid}/{task_id}/video/{파일명}
Args:
file_content: 업로드할 파일 바이트 데이터
@ -351,7 +359,7 @@ class AzureBlobUploader:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
success = await uploader.upload_video_bytes(video_bytes, "my_video")
print(uploader.public_url)
"""
@ -377,7 +385,7 @@ class AzureBlobUploader:
async def upload_image(self, file_path: str) -> bool:
"""이미지 파일을 Azure Blob Storage에 업로드합니다.
URL 경로: {task_id}/image/{파일명}
URL 경로: {user_uuid}/{task_id}/image/{파일명}
Args:
file_path: 업로드할 파일 경로
@ -386,7 +394,7 @@ class AzureBlobUploader:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
success = await uploader.upload_image(file_path="my_image.png")
print(uploader.public_url)
"""
@ -406,7 +414,7 @@ class AzureBlobUploader:
) -> bool:
"""이미지 바이트 데이터를 Azure Blob Storage에 직접 업로드합니다.
URL 경로: {task_id}/image/{파일명}
URL 경로: {user_uuid}/{task_id}/image/{파일명}
Args:
file_content: 업로드할 파일 바이트 데이터
@ -416,7 +424,7 @@ class AzureBlobUploader:
bool: 업로드 성공 여부
Example:
uploader = AzureBlobUploader(task_id="task-123")
uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
with open("my_image.png", "rb") as f:
content = f.read()
success = await uploader.upload_image_bytes(content, "my_image.png")
@ -445,17 +453,17 @@ class AzureBlobUploader:
# import asyncio
#
# async def main():
# uploader = AzureBlobUploader(task_id="task-123")
# uploader = AzureBlobUploader(user_uuid="user-abc", task_id="task-123")
#
# # 음악 업로드 -> {BASE_URL}/task-123/song/my_song.mp3
# # 음악 업로드 -> {BASE_URL}/user-abc/task-123/song/my_song.mp3
# await uploader.upload_music("my_song.mp3")
# print(uploader.public_url)
#
# # 영상 업로드 -> {BASE_URL}/task-123/video/my_video.mp4
# # 영상 업로드 -> {BASE_URL}/user-abc/task-123/video/my_video.mp4
# await uploader.upload_video("my_video.mp4")
# print(uploader.public_url)
#
# # 이미지 업로드 -> {BASE_URL}/task-123/image/my_image.png
# # 이미지 업로드 -> {BASE_URL}/user-abc/task-123/image/my_image.png
# await uploader.upload_image("my_image.png")
# print(uploader.public_url)
#

View File

@ -7,11 +7,11 @@ Video API Router
- POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결)
- GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회
- GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling)
- GET /videos/: 완료된 영상 목록 조회 (페이지네이션)
- GET /video/list: 완료된 영상 목록 조회 (페이지네이션)
사용 예시:
from app.video.api.routers.v1.video import router
app.include_router(router, prefix="/api/v1")
app.include_router(router)
"""
import json
@ -23,6 +23,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.dependencies.pagination import PaginationParams, get_pagination_params
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.home.models import Image, Project
from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp
@ -50,6 +52,9 @@ router = APIRouter(prefix="/video", tags=["Video"])
description="""
Creatomate API를 통해 영상 생성을 요청합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **task_id**: Project/Lyric/Song/Image의 task_id (필수) - 연관된 프로젝트, 가사, 노래, 이미지를 조회하는 사용
@ -68,10 +73,15 @@ Creatomate API를 통해 영상 생성을 요청합니다.
- **creatomate_render_id**: Creatomate 렌더 ID (상태 조회에 사용)
- **message**: 응답 메시지
## 사용 예시
```
GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431
GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal
## 사용 예시 (cURL)
```bash
# 세로형 영상 생성 (기본값)
curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5fce431" \\
-H "Authorization: Bearer {access_token}"
# 가로형 영상 생성
curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal" \\
-H "Authorization: Bearer {access_token}"
```
## 참고
@ -86,6 +96,7 @@ GET /video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal
responses={
200: {"description": "영상 생성 요청 성공"},
400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"},
500: {"description": "영상 생성 요청 실패"},
},
@ -96,6 +107,7 @@ async def generate_video(
default="vertical",
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
),
current_user: User = Depends(get_current_user),
) -> GenerateVideoResponse:
"""Creatomate API를 통해 영상을 생성합니다.
@ -347,9 +359,9 @@ async def generate_video(
)
final_template["source"]["elements"].append(caption)
logger.debug(
f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
)
# logger.debug(
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
# )
# 6-5. 커스텀 렌더링 요청 (비동기)
render_response = await creatomate_service.make_creatomate_custom_call_async(
@ -454,6 +466,9 @@ async def generate_video(
Creatomate API를 통해 영상 생성 작업의 상태를 조회합니다.
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 Video 테이블을 업데이트합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **creatomate_render_id**: 영상 생성 반환된 Creatomate 렌더 ID (필수)
@ -464,9 +479,10 @@ succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하
- **render_data**: 렌더링 결과 데이터 (완료 )
- **raw_response**: Creatomate API 원본 응답
## 사용 예시
```
GET /video/status/render-id-123...
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/video/status/{creatomate_render_id}" \\
-H "Authorization: Bearer {access_token}"
```
## 상태 값
@ -483,12 +499,14 @@ GET /video/status/render-id-123...
response_model=PollingVideoResponse,
responses={
200: {"description": "상태 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
500: {"description": "상태 조회 실패"},
},
)
async def get_video_status(
creatomate_render_id: str,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> PollingVideoResponse:
"""creatomate_render_id로 영상 생성 작업의 상태를 조회합니다.
@ -550,6 +568,7 @@ async def get_video_status(
task_id=video.task_id,
video_url=video_url,
store_name=store_name,
user_uuid=current_user.user_uuid,
)
elif video and video.status == "completed":
logger.debug(
@ -598,6 +617,9 @@ async def get_video_status(
task_id를 기반으로 Video 테이블의 상태를 polling하고,
completed인 경우 Project 정보와 영상 URL을 반환합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 경로 파라미터
- **task_id**: 프로젝트 task_id (필수)
@ -611,9 +633,10 @@ completed인 경우 Project 정보와 영상 URL을 반환합니다.
- **result_movie_url**: 영상 결과 URL (completed )
- **created_at**: 생성 일시
## 사용 예시
```
GET /video/download/019123ab-cdef-7890-abcd-ef1234567890
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/video/download/019123ab-cdef-7890-abcd-ef1234567890" \\
-H "Authorization: Bearer {access_token}"
```
## 참고
@ -623,12 +646,14 @@ GET /video/download/019123ab-cdef-7890-abcd-ef1234567890
response_model=DownloadVideoResponse,
responses={
200: {"description": "조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
404: {"description": "Video를 찾을 수 없음"},
500: {"description": "조회 실패"},
},
)
async def download_video(
task_id: str,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> DownloadVideoResponse:
"""task_id로 Video 상태를 polling하고 completed 시 Project 정보와 영상 URL을 반환합니다."""
@ -708,11 +733,14 @@ async def download_video(
@router.get(
"s/",
"/list",
summary="생성된 영상 목록 조회",
description="""
완료된 영상 목록을 페이지네이션하여 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 쿼리 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 10, 최대: 100)
@ -726,9 +754,10 @@ async def download_video(
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시
```
GET /videos/?page=1&page_size=10
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/video/list?page=1&page_size=10" \\
-H "Authorization: Bearer {access_token}"
```
## 참고
@ -739,10 +768,12 @@ GET /videos/?page=1&page_size=10
response_model=PaginatedResponse[VideoListItem],
responses={
200: {"description": "영상 목록 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
500: {"description": "조회 실패"},
},
)
async def get_videos(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[VideoListItem]:

View File

@ -1,7 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -24,7 +24,7 @@ class Video(Base):
project_id: 연결된 Project의 id (외래키)
lyric_id: 연결된 Lyric의 id (외래키)
song_id: 연결된 Song의 id (외래키)
task_id: 영상 생성 작업의 고유 식별자 (UUID 형식)
task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식)
status: 처리 상태 (pending, processing, completed, failed )
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
created_at: 생성 일시 (자동 설정)
@ -37,6 +37,11 @@ class Video(Base):
__tablename__ = "video"
__table_args__ = (
Index("idx_video_task_id", "task_id"),
Index("idx_video_project_id", "project_id"),
Index("idx_video_lyric_id", "lyric_id"),
Index("idx_video_song_id", "song_id"),
Index("idx_video_is_deleted", "is_deleted"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
@ -56,7 +61,6 @@ class Video(Base):
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Project의 id",
)
@ -64,7 +68,6 @@ class Video(Base):
Integer,
ForeignKey("lyric.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Lyric의 id",
)
@ -72,15 +75,13 @@ class Video(Base):
Integer,
ForeignKey("song.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="연결된 Song의 id",
)
task_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
index=True,
comment="영상 생성 작업 고유 식별자 (UUID)",
comment="영상 생성 작업 고유 식별자 (UUID7)",
)
creatomate_render_id: Mapped[Optional[str]] = mapped_column(
@ -101,6 +102,13 @@ class Video(Base):
comment="생성된 영상 URL",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,

View File

@ -106,6 +106,7 @@ async def download_and_upload_video_to_blob(
task_id: str,
video_url: str,
store_name: str,
user_uuid: str,
) -> None:
"""백그라운드에서 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
@ -113,6 +114,7 @@ async def download_and_upload_video_to_blob(
task_id: 프로젝트 task_id
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
"""
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
@ -142,7 +144,7 @@ async def download_and_upload_video_to_blob(
logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
if not upload_success:
@ -190,6 +192,7 @@ async def download_and_upload_video_by_creatomate_render_id(
creatomate_render_id: str,
video_url: str,
store_name: str,
user_uuid: str,
) -> None:
"""creatomate_render_id로 Video를 조회하여 영상을 다운로드하고 Azure Blob Storage에 업로드한 뒤 Video 테이블을 업데이트합니다.
@ -197,6 +200,7 @@ async def download_and_upload_video_by_creatomate_render_id(
creatomate_render_id: Creatomate API 렌더 ID
video_url: 다운로드할 영상 URL
store_name: 저장할 파일명에 사용할 업체명
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
"""
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
temp_file_path: Path | None = None
@ -244,7 +248,7 @@ async def download_and_upload_video_by_creatomate_render_id(
logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
upload_success = await uploader.upload_video(file_path=str(temp_file_path))
if not upload_success:

View File

@ -219,7 +219,7 @@ class KakaoSettings(BaseSettings):
KAKAO_CLIENT_ID: str = Field(default="", description="카카오 REST API 키")
KAKAO_CLIENT_SECRET: str = Field(default="", description="카카오 Client Secret (선택)")
KAKAO_REDIRECT_URI: str = Field(
default="http://localhost:8000/api/v1/user/auth/kakao/callback",
default="http://localhost:8000/user/auth/kakao/callback",
description="카카오 로그인 후 리다이렉트 URI",
)

View File

@ -0,0 +1,75 @@
-- ============================================================================
-- 마이그레이션: is_deleted 필드 추가 (소프트 삭제 지원)
-- 생성일: 2026-01-28
-- 설명: 모든 테이블(refresh_token 제외)에 is_deleted 필드 및 인덱스 추가
-- ============================================================================
-- 주의: 이 마이그레이션을 실행하기 전에 데이터베이스 백업을 권장합니다.
-- ============================================================================
-- 1. Project 테이블
-- ============================================================================
ALTER TABLE project
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_project_is_deleted ON project(is_deleted);
-- ============================================================================
-- 2. Image 테이블
-- ============================================================================
ALTER TABLE image
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_image_is_deleted ON image(is_deleted);
-- ============================================================================
-- 3. Lyric 테이블
-- ============================================================================
ALTER TABLE lyric
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_lyric_is_deleted ON lyric(is_deleted);
-- ============================================================================
-- 4. Song 테이블
-- ============================================================================
ALTER TABLE song
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_song_is_deleted ON song(is_deleted);
-- ============================================================================
-- 5. SongTimestamp 테이블
-- ============================================================================
ALTER TABLE song_timestamp
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_song_timestamp_is_deleted ON song_timestamp(is_deleted);
-- ============================================================================
-- 6. Video 테이블
-- ============================================================================
ALTER TABLE video
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_video_is_deleted ON video(is_deleted);
-- ============================================================================
-- 7. SocialAccount 테이블
-- ============================================================================
ALTER TABLE social_account
ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부 (True: 삭제됨)';
CREATE INDEX idx_social_account_is_deleted ON social_account(is_deleted);
-- ============================================================================
-- 검증 쿼리 (마이그레이션 후 실행하여 확인)
-- ============================================================================
-- SELECT
-- TABLE_NAME,
-- COLUMN_NAME,
-- DATA_TYPE,
-- COLUMN_DEFAULT
-- FROM INFORMATION_SCHEMA.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE()
-- AND COLUMN_NAME = 'is_deleted';

685
docs/plan/access_plan.md Normal file
View File

@ -0,0 +1,685 @@
# Access Token 인증 설계 문서
> 작성일: 2026-01-28
> 목적: 모든 API 요청에서 액세스 토큰을 검증하여 로그인 사용자를 인증하는 최적의 설계 제안
---
## 1. 현재 시스템 분석
### 1.1 기존 인증 구조
#### 인증 의존성 (`app/user/dependencies/auth.py`)
현재 3가지 인증 의존성이 구현되어 있습니다:
| 의존성 | 용도 | 토큰 없음 시 |
|--------|------|-------------|
| `get_current_user()` | 필수 인증 | 예외 발생 (401) |
| `get_current_user_optional()` | 선택적 인증 | `None` 반환 |
| `get_current_admin()` | 관리자 전용 | 예외 발생 (403) |
#### JWT 토큰 구조 (`app/user/services/jwt.py`)
```python
# Access Token Payload
{
"sub": user_uuid, # 사용자 고유 식별자 (UUID7)
"exp": datetime, # 만료 시간
"type": "access" # 토큰 타입
}
```
- **Access Token 유효기간**: `JWT_ACCESS_TOKEN_EXPIRE_MINUTES` (설정 파일)
- **Refresh Token 유효기간**: `JWT_REFRESH_TOKEN_EXPIRE_DAYS` (설정 파일)
- **알고리즘**: HS256
#### 커스텀 예외 (`app/user/exceptions.py`)
| 예외 | HTTP 상태 | 코드 |
|------|----------|------|
| `MissingTokenError` | 401 | MISSING_TOKEN |
| `InvalidTokenError` | 401 | INVALID_TOKEN |
| `TokenExpiredError` | 401 | TOKEN_EXPIRED |
| `TokenRevokedError` | 401 | TOKEN_REVOKED |
| `UserNotFoundError` | 404 | USER_NOT_FOUND |
| `UserInactiveError` | 403 | USER_INACTIVE |
| `AdminRequiredError` | 403 | ADMIN_REQUIRED |
---
### 1.2 현재 엔드포인트 인증 현황
#### 인증이 적용된 엔드포인트 (3개)
| 엔드포인트 | 메서드 | 의존성 |
|-----------|--------|--------|
| `/auth/me` | GET | `get_current_user` |
| `/auth/logout` | POST | `get_current_user` |
| `/auth/logout/all` | POST | `get_current_user` |
#### 인증이 없는 엔드포인트 (13개)
| 모듈 | 엔드포인트 | 메서드 | 설명 |
|------|-----------|--------|------|
| **Home** | `/crawling` | POST | 네이버 지도 크롤링 |
| **Home** | `/autocomplete` | POST | 자동완성 크롤링 |
| **Home** | `/image/upload/server` | POST | 이미지 업로드 (로컬) |
| **Home** | `/image/upload/blob` | POST | 이미지 업로드 (Azure Blob) |
| **Lyric** | `/lyric/generate` | POST | 가사 생성 |
| **Lyric** | `/lyric/status/{task_id}` | GET | 가사 상태 조회 |
| **Lyric** | `/lyrics/` | GET | 가사 목록 조회 |
| **Lyric** | `/lyric/{task_id}` | GET | 가사 상세 조회 |
| **Song** | `/song/generate/{task_id}` | POST | 노래 생성 |
| **Song** | `/song/status/{song_id}` | GET | 노래 상태 조회 |
| **Video** | `/video/generate/{task_id}` | GET | 영상 생성 |
| **Video** | `/video/status/{creatomate_render_id}` | GET | 영상 상태 조회 |
| **Video** | `/video/download/{task_id}` | GET | 영상 다운로드 |
| **Video** | `/videos/` | GET | 영상 목록 조회 |
| **Archive** | `/archive/videos/` | GET | 완료된 영상 목록 조회 (아카이브) |
| **Archive** | `/archive/videos/{task_id}` | DELETE | 아카이브 영상 삭제 (CASCADE) |
---
### 1.3 모델의 user_uuid 외래키 현황
`user_uuid` 외래키는 **Project 테이블에만** 존재합니다. 하위 리소스(Lyric, Song, Video, Image)의 소유권은 Project를 통해 간접적으로 확인합니다.
| 모델 | user_uuid 필드 | nullable | 비고 |
|------|-----------------|----------|------|
| Project | `user_uuid` → User.user_uuid | True | ✅ 소유권 기준 |
| Image | ❌ 없음 | - | task_id로 Project 연결 |
| Lyric | ❌ 없음 | - | project_id로 Project 연결 |
| Song | ❌ 없음 | - | project_id로 Project 연결 |
| Video | ❌ 없음 | - | project_id로 Project 연결 |
**소유권 확인 흐름:**
```
Lyric/Song/Video → project_id → Project → user_uuid → User
Image → task_id → Project (같은 task_id) → user_uuid → User
```
---
## 2. 설계 방안 비교
### 2.1 방안 A: 의존성 주입 방식 (Dependency Injection)
각 엔드포인트에 개별적으로 인증 의존성을 추가하는 방식입니다.
```python
# 예시: 필수 인증
@router.post("/lyric/generate")
async def generate_lyric(
request_body: GenerateLyricRequest,
current_user: User = Depends(get_current_user), # 인증 추가
session: AsyncSession = Depends(get_session),
):
# current_user.user_uuid 사용 가능
...
# 예시: 선택적 인증
@router.get("/lyrics/")
async def list_lyrics(
current_user: User | None = Depends(get_current_user_optional), # 선택적 인증
session: AsyncSession = Depends(get_session),
):
if current_user:
# 로그인 사용자: 자신의 가사만 조회
...
else:
# 비로그인 사용자: 공개 가사 조회
...
```
**장점:**
- FastAPI 표준 패턴 (공식 문서 권장)
- 엔드포인트별 세밀한 제어 가능
- 테스트 작성이 용이 (의존성 오버라이드)
- 기존 코드와의 호환성 우수
**단점:**
- 각 엔드포인트에 개별 적용 필요
- 실수로 인증 누락 가능
---
### 2.2 방안 B: 미들웨어 방식 (Middleware)
모든 요청에 미들웨어가 자동으로 토큰을 검증하는 방식입니다.
```python
# app/middleware/auth.py
class AuthMiddleware(BaseHTTPMiddleware):
# 인증 예외 경로
EXEMPT_PATHS = [
"/docs", "/openapi.json", "/health",
"/auth/kakao/login", "/auth/kakao/callback", "/auth/kakao/verify",
"/auth/refresh",
]
async def dispatch(self, request: Request, call_next):
# 예외 경로는 통과
if any(request.url.path.startswith(p) for p in self.EXEMPT_PATHS):
return await call_next(request)
# 토큰 검증
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
return JSONResponse(status_code=401, content={"code": "MISSING_TOKEN"})
token = auth_header.split(" ")[1]
payload = decode_token(token)
if not payload:
return JSONResponse(status_code=401, content={"code": "INVALID_TOKEN"})
# request.state에 사용자 정보 저장
request.state.user_uuid = payload.get("sub")
return await call_next(request)
```
**장점:**
- 모든 엔드포인트에 자동 적용
- 중앙 집중식 관리
- 인증 누락 방지
**단점:**
- 예외 경로 관리가 복잡해질 수 있음
- DB 조회를 미들웨어에서 처리하면 성능 이슈
- 선택적 인증 구현이 어려움
- FastAPI의 의존성 주입 패턴과 맞지 않음
---
### 2.3 방안 C: 라우터 레벨 의존성 (Router-Level Dependencies) ⭐ 권장
라우터 전체에 기본 인증을 적용하고, 개별 엔드포인트에서 오버라이드하는 방식입니다.
```python
# 인증이 필요한 라우터
lyric_router = APIRouter(
prefix="/lyric",
tags=["Lyric"],
dependencies=[Depends(get_current_user_optional)], # 라우터 전체에 적용
)
# 필수 인증이 필요한 엔드포인트
@lyric_router.post(
"/generate",
dependencies=[Depends(get_current_user)], # 오버라이드: 필수 인증
)
async def generate_lyric(...):
...
# 선택적 인증 (라우터 기본값 사용)
@lyric_router.get("/lyrics/")
async def list_lyrics(...):
...
```
**장점:**
- 모듈별 일관된 인증 정책 적용
- 엔드포인트별 세밀한 제어 가능
- FastAPI 표준 패턴 준수
- 인증 누락 가능성 감소
**단점:**
- 라우터 수정 필요
- 새 엔드포인트 추가 시 주의 필요
---
## 3. 권장 설계안
### 3.1 최적 설계: 의존성 주입 방식 (방안 A) + 라우터 레벨 기본값 (방안 C)
#### 핵심 원칙
1. **데이터 생성 엔드포인트**: 필수 인증 (`get_current_user`)
2. **데이터 조회 엔드포인트**: 선택적 인증 (`get_current_user_optional`)
3. **공개 엔드포인트**: 인증 없음 (로그인, 콜백 등)
#### 엔드포인트별 인증 정책
| 엔드포인트 | 인증 타입 | 이유 |
|-----------|----------|------|
| `/auth/kakao/login` | 없음 | 로그인 진입점 |
| `/auth/kakao/callback` | 없음 | OAuth 콜백 |
| `/auth/kakao/verify` | 없음 | 토큰 발급 |
| `/auth/refresh` | 없음 | 토큰 갱신 |
| `/auth/me` | **필수** | 내 정보 조회 |
| `/auth/logout` | **필수** | 로그아웃 |
| `/auth/logout/all` | **필수** | 전체 로그아웃 |
| `/crawling` | **선택적** | 비로그인도 테스트 가능 |
| `/autocomplete` | **선택적** | 비로그인도 테스트 가능 |
| `/image/upload/blob` | **필수** | 리소스 생성 |
| `/lyric/generate` | **필수** | 리소스 생성 |
| `/lyric/status/{task_id}` | **선택적** | 상태 조회 |
| `/lyric/{task_id}` | **선택적** | 상세 조회 |
| `/lyrics/` | **선택적** | 목록 조회 |
| `/song/generate/{task_id}` | **필수** | 리소스 생성 |
| `/song/status/{song_id}` | **선택적** | 상태 조회 |
| `/video/generate/{task_id}` | **필수** | 리소스 생성 |
| `/video/status/{...}` | **선택적** | 상태 조회 |
| `/video/download/{task_id}` | **선택적** | 다운로드 |
| `/videos/` | **선택적** | 목록 조회 |
| `/archive/videos/` | **필수** | 완료된 영상 목록 조회 (아카이브) |
| `/archive/videos/{task_id}` | **필수** | 아카이브 영상 삭제 + 소유권 검증 |
---
### 3.2 구현 코드 예시
#### 3.2.1 리소스 생성 엔드포인트 (필수 인증)
```python
# app/lyric/api/routers/v1/lyric.py
from app.user.dependencies import get_current_user
from app.user.models import User
@router.post("/generate")
async def generate_lyric(
request_body: GenerateLyricRequest,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user), # ✅ 필수 인증
session: AsyncSession = Depends(get_session),
) -> GenerateLyricResponse:
"""고객 정보를 기반으로 가사를 생성합니다. (백그라운드 처리)"""
# Project 생성 시 user_uuid 연결 (소유권의 기준점)
project = Project(
store_name=request_body.customer_name,
region=request_body.region,
task_id=task_id,
user_uuid=current_user.user_uuid, # ✅ 사용자 연결
...
)
# Lyric은 project_id를 통해 소유권 확인 (user_uuid 없음)
lyric = Lyric(
project_id=project.id, # ✅ Project 연결 → 소유권 간접 확인
task_id=task_id,
...
)
```
#### 3.2.2 데이터 조회 엔드포인트 (선택적 인증)
```python
# app/lyric/api/routers/v1/lyric.py
from app.user.dependencies import get_current_user_optional
from app.user.models import User
@router.get("/lyrics/")
async def list_lyrics(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User | None = Depends(get_current_user_optional), # ✅ 선택적 인증
session: AsyncSession = Depends(get_session),
) -> PaginatedResponse[LyricListItem]:
"""페이지네이션으로 완료된 가사 목록을 조회합니다."""
base_query = select(Lyric).where(Lyric.status == "completed")
# 로그인 사용자: Project.user_uuid를 통해 자신의 가사만 조회
if current_user:
base_query = (
base_query
.join(Project, Lyric.project_id == Project.id)
.where(Project.user_uuid == current_user.user_uuid) # ✅ Project를 통한 소유자 필터
)
else:
# 비로그인 사용자: 공개 데이터만 조회 (Project.user_uuid가 NULL)
base_query = (
base_query
.join(Project, Lyric.project_id == Project.id)
.where(Project.user_uuid.is_(None))
)
# 페이지네이션 처리
...
```
#### 3.2.3 이미지 업로드 (필수 인증 + user_uuid 폴더 구조)
```python
# app/home/api/routers/v1/home.py
from app.user.dependencies import get_current_user
from app.user.models import User
from app.utils.upload_blob_as_request import AzureBlobUploader
@router.post("/image/upload/blob")
async def upload_images_blob(
images_json: Optional[str] = Form(default=None),
files: Optional[list[UploadFile]] = File(default=None),
current_user: User = Depends(get_current_user), # ✅ 필수 인증
) -> ImageUploadResponse:
"""이미지 업로드 (URL + Azure Blob Storage)"""
task_id = await generate_task_id()
# ✅ user_uuid를 포함한 Blob 업로더 생성
uploader = AzureBlobUploader(
user_uuid=current_user.user_uuid,
task_id=task_id,
)
# Blob 경로: {BASE_URL}/{user_uuid}/{task_id}/image/{file_name}
# Image 저장 (user_uuid 없음 - task_id로 Project와 연결)
image = Image(
task_id=task_id, # ✅ task_id를 통해 Project와 연결 → 소유권 확인
img_name=img_name,
img_url=blob_url,
...
)
```
> **Note**: Image 테이블에는 `user_uuid` 필드가 없습니다.
> 소유권은 `task_id`를 공유하는 Project를 통해 확인합니다.
---
### 3.3 소유권 검증 유틸리티
데이터 조회/수정 시 **Project를 통한** 소유권 검증을 위한 유틸리티 함수를 추가합니다.
```python
# app/utils/ownership.py
from fastapi import HTTPException, status
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.user.models import User
from app.home.models import Project
def verify_ownership(
project_user_uuid: Optional[str],
current_user: Optional[User],
raise_on_mismatch: bool = True,
) -> bool:
"""
Project 기반 리소스 소유권 검증
Args:
project_user_uuid: Project의 user_uuid
current_user: 현재 로그인 사용자 (None이면 비로그인)
raise_on_mismatch: True면 불일치 시 예외 발생
Returns:
bool: 소유권 일치 여부
Raises:
HTTPException: 소유권 불일치 시 (raise_on_mismatch=True)
"""
# Project에 소유자가 없으면 모두 접근 가능 (공개 리소스)
if project_user_uuid is None:
return True
# 비로그인 사용자가 소유자가 있는 리소스에 접근
if current_user is None:
if raise_on_mismatch:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH_REQUIRED", "message": "로그인이 필요합니다."},
)
return False
# 소유자 불일치
if project_user_uuid != current_user.user_uuid:
if raise_on_mismatch:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "FORBIDDEN", "message": "접근 권한이 없습니다."},
)
return False
return True
async def get_project_owner_uuid(
session: AsyncSession,
project_id: int,
) -> Optional[str]:
"""project_id로 소유자의 user_uuid 조회"""
result = await session.execute(
select(Project.user_uuid).where(Project.id == project_id)
)
return result.scalar_one_or_none()
```
**사용 예시:**
```python
@router.get("/{task_id}")
async def get_lyric_detail(
task_id: str,
current_user: User | None = Depends(get_current_user_optional),
session: AsyncSession = Depends(get_session),
) -> LyricDetailResponse:
"""task_id로 생성된 가사를 조회합니다."""
lyric = await get_lyric_by_task_id(session, task_id)
# ✅ Project를 통한 소유권 검증
project_user_uuid = await get_project_owner_uuid(session, lyric.project_id)
verify_ownership(project_user_uuid, current_user)
return LyricDetailResponse(...)
```
---
## 4. 구현 체크리스트
### 4.1 Phase 1: 인증 인프라 확장
- [ ] `app/utils/ownership.py` 생성 (소유권 검증 유틸리티)
- [ ] `AzureBlobUploader` 클래스에 `user_uuid` 파라미터 활성화
- [ ] 기존 `get_current_user`, `get_current_user_optional` 테스트
### 4.2 Phase 2: 리소스 생성 엔드포인트 인증 적용
- [ ] `POST /image/upload/blob` - 필수 인증 (Image에는 user_uuid 없음, task_id로 연결)
- [ ] `POST /lyric/generate` - 필수 인증 + Project.user_uuid 저장
- [ ] `POST /song/generate/{task_id}` - 필수 인증 (Project 통해 소유권 확인)
- [ ] `GET /video/generate/{task_id}` - 필수 인증 (Project 통해 소유권 확인)
### 4.3 Phase 3: 조회 엔드포인트 인증 적용
- [ ] `GET /lyric/status/{task_id}` - 선택적 인증 + Project 통해 소유권 검증
- [ ] `GET /lyric/{task_id}` - 선택적 인증 + Project 통해 소유권 검증
- [ ] `GET /lyrics/` - 선택적 인증 + Project.user_uuid 기반 필터
- [ ] `GET /song/status/{song_id}` - 선택적 인증 + Project 통해 소유권 검증
- [ ] `GET /video/status/{...}` - 선택적 인증 + Project 통해 소유권 검증
- [ ] `GET /video/download/{task_id}` - 선택적 인증 + Project 통해 소유권 검증
- [ ] `GET /videos/` - 선택적 인증 + Project.user_uuid 기반 필터
- [ ] `GET /archive/videos/` - 필수 인증 + Project.user_uuid 기반 필터
- [ ] `DELETE /archive/videos/{task_id}` - 필수 인증 + 소유권 검증 + CASCADE 삭제
### 4.4 Phase 4: 크롤링 엔드포인트
- [ ] `POST /crawling` - 선택적 인증 (비로그인도 허용)
- [ ] `POST /autocomplete` - 선택적 인증 (비로그인도 허용)
### 4.5 Phase 5: OpenAPI 문서 업데이트
- [ ] `main.py``custom_openapi()` 수정하여 인증이 필요한 엔드포인트에 security 표시
---
## 5. OpenAPI Security 스키마 업데이트
`main.py``custom_openapi()` 함수를 수정하여 인증이 필요한 엔드포인트를 표시합니다.
```python
def custom_openapi():
"""커스텀 OpenAPI 스키마 생성 (Bearer 인증 추가)"""
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
tags=tags_metadata,
)
# Bearer 토큰 인증 스키마 추가
openapi_schema["components"]["securitySchemes"] = {
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "JWT 액세스 토큰을 입력하세요.",
}
}
# 인증이 필요한 경로 패턴
AUTH_REQUIRED_PATHS = [
"/auth/me", "/auth/logout",
"/image/upload/blob",
"/lyric/generate",
"/song/generate",
"/video/generate",
"/archive/videos", # GET (목록조회), DELETE (삭제)
]
AUTH_OPTIONAL_PATHS = [
"/crawling", "/autocomplete",
"/lyric/status", "/lyric/",
"/lyrics/",
"/song/status",
"/video/status", "/video/download",
"/videos/",
]
for path, path_item in openapi_schema["paths"].items():
for method, operation in path_item.items():
if method in ["get", "post", "put", "patch", "delete"]:
# 필수 인증
if any(auth_path in path for auth_path in AUTH_REQUIRED_PATHS):
operation["security"] = [{"BearerAuth": []}]
# 선택적 인증 (문서에는 표시하지만 필수 아님)
elif any(auth_path in path for auth_path in AUTH_OPTIONAL_PATHS):
operation["security"] = [{"BearerAuth": []}, {}] # 빈 객체 = 인증 없이도 가능
app.openapi_schema = openapi_schema
return app.openapi_schema
```
---
## 6. 테스트 전략
### 6.1 단위 테스트
```python
# tests/test_auth_dependencies.py
import pytest
from fastapi import HTTPException
from app.user.dependencies import get_current_user, get_current_user_optional
@pytest.mark.asyncio
async def test_get_current_user_missing_token():
"""토큰 없이 접근 시 MissingTokenError 발생"""
with pytest.raises(HTTPException) as exc_info:
await get_current_user(credentials=None, session=mock_session)
assert exc_info.value.status_code == 401
assert exc_info.value.detail["code"] == "MISSING_TOKEN"
@pytest.mark.asyncio
async def test_get_current_user_optional_missing_token():
"""선택적 인증에서 토큰 없으면 None 반환"""
result = await get_current_user_optional(credentials=None, session=mock_session)
assert result is None
```
### 6.2 통합 테스트
```python
# tests/test_lyric_auth.py
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_generate_lyric_without_auth(client: AsyncClient):
"""인증 없이 가사 생성 시 401 반환"""
response = await client.post("/lyric/generate", json={...})
assert response.status_code == 401
assert response.json()["detail"]["code"] == "MISSING_TOKEN"
@pytest.mark.asyncio
async def test_generate_lyric_with_auth(client: AsyncClient, auth_headers: dict):
"""인증된 사용자의 가사 생성 성공"""
response = await client.post(
"/lyric/generate",
json={...},
headers=auth_headers,
)
assert response.status_code == 200
```
---
## 7. 마이그레이션 고려사항
### 7.1 기존 데이터 처리
현재 Project 테이블에서 `user_uuid``NULL`인 레코드들에 대한 처리 방안:
1. **기존 데이터 유지**: `Project.user_uuid``NULL`이면 해당 Project 및 하위 리소스(Lyric, Song, Video, Image)를 공개 데이터로 취급
2. **관리자 전용**: `Project.user_uuid``NULL`인 데이터는 관리자만 접근 가능
3. **마이그레이션**: 특정 사용자에게 기존 Project 할당 (비권장)
**권장**: 옵션 1 (기존 데이터는 공개 데이터로 유지)
> **Note**: `user_uuid`는 Project 테이블에만 존재합니다.
> Lyric, Song, Video, Image 테이블에는 `user_uuid` 필드가 없으며,
> 소유권은 Project를 통해 간접적으로 확인합니다.
### 7.2 하위 호환성
- 기존 API 클라이언트가 인증 없이 호출하는 경우 401 응답
- 프론트엔드 업데이트 필요: 모든 API 호출에 `Authorization` 헤더 추가
- 점진적 적용: Phase별로 적용하여 영향 범위 최소화
---
## 8. 결론
### 권장 구현 순서
1. **의존성 주입 방식** 채택 (FastAPI 표준 패턴)
2. **필수/선택적 인증** 엔드포인트별 적용
3. **Project 기반 소유권 검증** 유틸리티 활용
4. **Project.user_uuid 연결** 리소스 생성 시 적용
### 데이터 모델 구조
```
User (user_uuid)
└── Project (user_uuid → User) ← 소유권의 기준점
├── Lyric (project_id → Project)
│ └── Song (lyric_id → Lyric)
│ └── Video (song_id → Song)
└── Image (task_id = Project.task_id)
```
이 설계를 통해:
- **Project 테이블만** `user_uuid`를 가지며, 모든 소유권 확인의 기준점 역할
- 하위 리소스(Lyric, Song, Video, Image)는 Project를 통해 간접적으로 소유권 확인
- 사용자별 데이터 격리가 가능합니다
- 비로그인 사용자도 제한된 기능을 사용할 수 있습니다
- Azure Blob Storage 폴더 구조에 user_uuid가 포함됩니다

1211
docs/plan/token_plan.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,143 @@
# 소프트 삭제 (Soft Delete) 가이드
## 개요
소프트 삭제는 데이터를 실제로 삭제하지 않고 `is_deleted` 필드를 `True`로 설정하여 삭제된 것처럼 처리하는 방식입니다.
이를 통해 데이터 복구가 가능하고, 삭제 이력을 추적할 수 있습니다.
## 적용 테이블
| 테이블 | is_deleted | 인덱스 | 비고 |
|--------|------------|--------|------|
| User | ✅ | idx_user_is_deleted | deleted_at 필드도 포함 |
| Project | ✅ | idx_project_is_deleted | |
| Image | ✅ | idx_image_is_deleted | |
| Lyric | ✅ | idx_lyric_is_deleted | |
| Song | ✅ | idx_song_is_deleted | |
| SongTimestamp | ✅ | idx_song_timestamp_is_deleted | |
| Video | ✅ | idx_video_is_deleted | |
| SocialAccount | ✅ | idx_social_account_is_deleted | |
| RefreshToken | ❌ | - | 토큰은 is_revoked로 관리 |
## 필드 정의
```python
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
```
## API 엔드포인트
### 아카이브 삭제 API
```
DELETE /archive/videos/delete/{task_id}
```
task_id에 해당하는 모든 관련 데이터를 소프트 삭제합니다.
백그라운드에서 비동기로 처리됩니다.
## 백그라운드 태스크
### soft_delete_by_task_id
위치: `app/archive/worker/archive_task.py`
```python
from app.archive.worker.archive_task import soft_delete_by_task_id
# 백그라운드 태스크로 실행
background_tasks.add_task(soft_delete_by_task_id, task_id)
```
삭제 대상 테이블 (순서대로):
1. Video
2. SongTimestamp (suno_audio_id 기준)
3. Song
4. Lyric
5. Image
6. Project
## 사용 예시
### 소프트 삭제 수행
```python
async def soft_delete_project(session: AsyncSession, project_id: int) -> None:
"""프로젝트를 소프트 삭제합니다."""
stmt = (
update(Project)
.where(Project.id == project_id)
.values(is_deleted=True)
)
await session.execute(stmt)
await session.commit()
```
### 삭제되지 않은 데이터만 조회
```python
async def get_active_projects(session: AsyncSession) -> list[Project]:
"""삭제되지 않은 프로젝트만 조회합니다."""
stmt = select(Project).where(Project.is_deleted == False)
result = await session.execute(stmt)
return result.scalars().all()
```
### 삭제된 데이터 복구
```python
async def restore_project(session: AsyncSession, project_id: int) -> None:
"""삭제된 프로젝트를 복구합니다."""
stmt = (
update(Project)
.where(Project.id == project_id)
.values(is_deleted=False)
)
await session.execute(stmt)
await session.commit()
```
## 쿼리 시 주의사항
1. **기본 조회 시 is_deleted 필터 추가**
- 모든 조회 쿼리에서 `is_deleted == False` 조건을 명시적으로 추가해야 합니다.
2. **관리자 기능에서만 삭제된 데이터 포함**
- 일반 사용자 API에서는 삭제된 데이터가 노출되지 않도록 해야 합니다.
3. **CASCADE 삭제와의 관계**
- 부모 테이블 소프트 삭제 시 자식 테이블도 함께 소프트 삭제하는 로직 필요
- 또는 부모만 소프트 삭제하고 자식은 JOIN 시 필터링
## 마이그레이션
기존 데이터베이스에 `is_deleted` 필드를 추가하려면:
```bash
# 마이그레이션 SQL 실행
mysql -u <user> -p <database> < docs/database-schema/migration_add_is_deleted.sql
```
또는 Alembic 마이그레이션 사용:
```bash
alembic revision --autogenerate -m "Add is_deleted field to all tables"
alembic upgrade head
```
## 인덱스 활용
`is_deleted` 필드에 인덱스가 생성되어 있으므로, 다음과 같은 쿼리가 효율적으로 실행됩니다:
```sql
-- 삭제되지 않은 프로젝트 조회 (인덱스 활용)
SELECT * FROM project WHERE is_deleted = FALSE;
-- 복합 조건 (task_id + is_deleted)
SELECT * FROM project WHERE task_id = 'xxx' AND is_deleted = FALSE;
```

112
main.py
View File

@ -8,11 +8,12 @@ from app.admin_manager import init_admin
from app.core.common import lifespan
from app.database.session import engine
# 주의: User 모델을 먼저 import해야 UserProject가 User를 참조할 수 있음
# User 모델 import (테이블 메타데이터 등록용)
from app.user.models import User, RefreshToken # noqa: F401
from app.archive.api.routers.v1.archive import router as archive_router
from app.home.api.routers.v1.home import router as home_router
from app.user.api.routers.v1.auth import router as auth_router
from app.user.api.routers.v1.auth import router as auth_router, test_router as auth_test_router
from app.lyric.api.routers.v1.lyric import router as lyric_router
from app.song.api.routers.v1.song import router as song_router
from app.video.api.routers.v1.video import router as video_router
@ -26,16 +27,24 @@ tags_metadata = [
## 인증 흐름
1. `GET /api/v1/user/auth/kakao/login` - 카카오 로그인 URL 획득
1. `GET /user/auth/kakao/login` - 카카오 로그인 URL 획득
2. 사용자를 auth_url로 리다이렉트 카카오 로그인
3. 카카오에서 인가 코드(code) 발급
4. `POST /api/v1/user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급
4. `POST /user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급
5. 이후 API 호출 `Authorization: Bearer {access_token}` 헤더 사용
## 토큰 관리
- **Access Token**: 1시간 유효, API 호출 사용
- **Refresh Token**: 7 유효, Access Token 갱신 사용
## Scalar에서 인증 사용하기
1. 카카오 로그인 또는 테스트 토큰 발급으로 `access_token` 획득
2. 우측 상단 **Authorize** 버튼 클릭
3. `access_token` 입력 (Bearer 접두사 없이 토큰만 입력)
4. **Authorize** 클릭하여 저장
5. 이후 인증이 필요한 API 호출 자동으로 토큰이 포함됨
""",
},
# {
@ -44,46 +53,100 @@ tags_metadata = [
# },
{
"name": "Crawling",
"description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",
"description": """네이버 지도 크롤링 API - 장소 정보 및 이미지 수집
**인증: 불필요** (공개 API)
""",
},
{
"name": "Image-Blob",
"description": "이미지 업로드 API - Azure Blob Storage",
"description": """이미지 업로드 API - Azure Blob Storage
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
""",
},
{
"name": "Lyric",
"description": """가사 생성 및 관리 API
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 가사 생성 흐름
1. `POST /api/v1/lyric/generate` - 가사 생성 요청 (백그라운드 처리)
2. `GET /api/v1/lyric/status/{task_id}` - 생성 상태 확인
3. `GET /api/v1/lyric/{task_id}` - 생성된 가사 조회
1. `POST /lyric/generate` - 가사 생성 요청 (백그라운드 처리)
2. `GET /lyric/status/{task_id}` - 생성 상태 확인
3. `GET /lyric/{task_id}` - 생성된 가사 조회
4. `GET /lyric/list` - 가사 목록 조회 (페이지네이션)
""",
},
{
"name": "Song",
"description": """노래 생성 및 관리 API (Suno AI)
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 노래 생성 흐름
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
2. `GET /api/v1/song/status/{song_id}` - Suno API 상태 확인
1. `POST /song/generate/{task_id}` - 노래 생성 요청
2. `GET /song/status/{song_id}` - Suno API 상태 확인
""",
},
{
"name": "Video",
"description": """영상 생성 및 관리 API (Creatomate)
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 영상 생성 흐름
1. `GET /api/v1/video/generate/{task_id}` - 영상 생성 요청
2. `GET /api/v1/video/status/{creatomate_render_id}` - Creatomate 상태 확인
3. `GET /api/v1/video/download/{task_id}` - 영상 다운로드 URL 조회
1. `GET /video/generate/{task_id}` - 영상 생성 요청
2. `GET /video/status/{creatomate_render_id}` - Creatomate 상태 확인
3. `GET /video/download/{task_id}` - 영상 다운로드 URL 조회
4. `GET /video/list` - 영상 목록 조회 (페이지네이션)
""",
},
{
"name": "Archive",
"description": """아카이브 API - 완료된 영상 목록 조회 및 삭제
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 주요 기능
- `GET /archive/videos/` - 완료된 영상 목록 페이지네이션 조회
- `DELETE /archive/videos/delete/{task_id}` - 아카이브 영상 소프트 삭제
## 참고
- **본인 소유의 데이터만 조회/삭제 가능합니다.**
- status가 'completed' 영상만 반환됩니다.
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
- created_at 기준 내림차순 정렬됩니다.
- 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다.
- 삭제 대상: Video, SongTimestamp, Song, Lyric, Image, Project
""",
},
]
# DEBUG 모드에서만 Test Auth 태그 추가
if prj_settings.DEBUG:
tags_metadata.append(
{
"name": "Test Auth",
"description": """테스트용 인증 API (DEBUG 모드 전용)
**주의: API는 DEBUG 모드에서만 사용 가능합니다.**
카카오 로그인 없이 테스트용 사용자 생성 토큰 발급이 가능합니다.
## 테스트 흐름
1. `POST /user/auth/test/create-user` - 테스트 사용자 생성
2. `POST /user/auth/test/generate-token` - JWT 토큰 발급
""",
}
)
app = FastAPI(
title=prj_settings.PROJECT_NAME,
version=prj_settings.VERSION,
@ -118,12 +181,24 @@ def custom_openapi():
}
}
# 인증이 필요하지 않은 엔드포인트 (공개 API)
public_endpoints = [
"/auth/kakao/login",
"/auth/kakao/callback",
"/auth/kakao/verify",
"/auth/refresh",
"/auth/test/", # 테스트 엔드포인트
"/crawling",
"/autocomplete",
]
# 보안이 필요한 엔드포인트에 security 적용
for path, path_item in openapi_schema["paths"].items():
for method, operation in path_item.items():
if method in ["get", "post", "put", "patch", "delete"]:
# /auth/me, /auth/logout 등 인증이 필요한 엔드포인트
if any(auth_path in path for auth_path in ["/auth/me", "/auth/logout"]):
# 공개 엔드포인트가 아닌 경우 인증 필요
is_public = any(public_path in path for public_path in public_endpoints)
if not is_public and path.startswith("/api/"):
operation["security"] = [{"BearerAuth": []}]
app.openapi_schema = openapi_schema
@ -163,3 +238,8 @@ app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
app.include_router(lyric_router)
app.include_router(song_router)
app.include_router(video_router)
app.include_router(archive_router) # Archive API 라우터 추가
# DEBUG 모드에서만 테스트 라우터 등록
if prj_settings.DEBUG:
app.include_router(auth_test_router, prefix="/user") # Test Auth API 라우터