diff --git a/app/archive/api/routers/v1/archive.py b/app/archive/api/routers/v1/archive.py index e69de29..678ffbd 100644 --- a/app/archive/api/routers/v1/archive.py +++ b/app/archive/api/routers/v1/archive.py @@ -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)}", + ) diff --git a/app/archive/worker/archive_task.py b/app/archive/worker/archive_task.py index e69de29..9c725d2 100644 --- a/app/archive/worker/archive_task.py +++ b/app/archive/worker/archive_task.py @@ -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 diff --git a/app/database/session.py b/app/database/session.py index af942d0..e3e54dd 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -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 의존성용 세션 제너레이터 diff --git a/app/home/api/home_admin.py b/app/home/api/home_admin.py index 2517e18..da81d07 100644 --- a/app/home/api/home_admin.py +++ b/app/home/api/home_admin.py @@ -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": "프로젝트", - } diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 2359088..2ade1dc 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -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): diff --git a/app/home/models.py b/app/home/models.py index 026523d..af5619f 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -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"" - ) - 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, diff --git a/app/home/worker/home_task.py b/app/home/worker/home_task.py index d352c16..07a02dc 100644 --- a/app/home/worker/home_task.py +++ b/app/home/worker/home_task.py @@ -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: diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index 68735b2..1effee9 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -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로 생성된 가사를 조회합니다.""" diff --git a/app/lyric/models.py b/app/lyric/models.py index 8a5c0a9..75f14aa 100644 --- a/app/lyric/models.py +++ b/app/lyric/models.py @@ -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, diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 13b39f3..3f2a07b 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -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, diff --git a/app/song/models.py b/app/song/models.py index 8f6d7d6..8baace1 100644 --- a/app/song/models.py +++ b/app/song/models.py @@ -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, diff --git a/app/song/worker/song_task.py b/app/song/worker/song_task.py index 94bae88..f743669 100644 --- a/app/song/worker/song_task.py +++ b/app/song/worker/song_task.py @@ -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: diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index f25c836..2c730f9 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -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(), + ) diff --git a/app/user/api/user_admin.py b/app/user/api/user_admin.py index 087ae8e..7f70ecb 100644 --- a/app/user/api/user_admin.py +++ b/app/user/api/user_admin.py @@ -45,7 +45,7 @@ class UserAdmin(ModelView, model=User): form_excluded_columns = [ "created_at", "updated_at", - "user_projects", + "projects", "refresh_tokens", "social_accounts", ] diff --git a/app/user/dependencies/auth.py b/app/user/dependencies/auth.py index 441762b..a798854 100644 --- a/app/user/dependencies/auth.py +++ b/app/user/dependencies/auth.py @@ -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 ) ) diff --git a/app/user/exceptions.py b/app/user/exceptions.py index 41752ae..310f949 100644 --- a/app/user/exceptions.py +++ b/app/user/exceptions.py @@ -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", diff --git a/app/user/models.py b/app/user/models.py index 72635fd..58a81e6 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -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" 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, diff --git a/app/user/services/jwt.py b/app/user/services/jwt.py index 91c2eee..e8b3bce 100644 --- a/app/user/services/jwt.py +++ b/app/user/services/jwt.py @@ -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", } diff --git a/app/utils/common.py b/app/utils/common.py index 72ad241..34062d6 100644 --- a/app/utils/common.py +++ b/app/utils/common.py @@ -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() diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index a1bfe54..cb76c61 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -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 클라이언트 오류) diff --git a/app/utils/upload_blob_as_request.py b/app/utils/upload_blob_as_request.py index da4b01b..8ca2132 100644 --- a/app/utils/upload_blob_as_request.py +++ b/app/utils/upload_blob_as_request.py @@ -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) # diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index bd7ca8a..9f268b7 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -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]: diff --git a/app/video/models.py b/app/video/models.py index 9bcce0b..3a6a391 100644 --- a/app/video/models.py +++ b/app/video/models.py @@ -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, diff --git a/app/video/worker/video_task.py b/app/video/worker/video_task.py index 7edc2c2..4680a32 100644 --- a/app/video/worker/video_task.py +++ b/app/video/worker/video_task.py @@ -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: diff --git a/config.py b/config.py index 970a248..05b2016 100644 --- a/config.py +++ b/config.py @@ -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", ) diff --git a/docs/database-schema/migration_add_is_deleted.sql b/docs/database-schema/migration_add_is_deleted.sql new file mode 100644 index 0000000..6e1065a --- /dev/null +++ b/docs/database-schema/migration_add_is_deleted.sql @@ -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'; diff --git a/docs/plan/access_plan.md b/docs/plan/access_plan.md new file mode 100644 index 0000000..75d54a0 --- /dev/null +++ b/docs/plan/access_plan.md @@ -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가 포함됩니다 diff --git a/error_plan.md b/docs/plan/error_plan.md similarity index 100% rename from error_plan.md rename to docs/plan/error_plan.md diff --git a/docs/plan/token_plan.md b/docs/plan/token_plan.md new file mode 100644 index 0000000..4a899a5 --- /dev/null +++ b/docs/plan/token_plan.md @@ -0,0 +1,1211 @@ +# JWT 토큰 기반 인증 시스템 설계 + +## 목차 + +1. [개요](#1-개요) +2. [인증 정책](#2-인증-정책) +3. [토큰 구조](#3-토큰-구조) +4. [인증 흐름](#4-인증-흐름) +5. [토큰 검증](#5-토큰-검증) +6. [토큰 갱신 (Refresh)](#6-토큰-갱신-refresh) +7. [보안 고려사항](#7-보안-고려사항) +8. [에러 처리](#8-에러-처리) +9. [클라이언트 구현 가이드](#9-클라이언트-구현-가이드) +10. [API 엔드포인트 정리](#10-api-엔드포인트-정리) +11. [테스트용 엔드포인트 (DEBUG 모드)](#11-테스트용-엔드포인트-debug-모드) + +--- + +## 1. 개요 + +### 1.1 인증 방식 + +본 시스템은 **JWT (JSON Web Token)** 기반의 **이중 토큰 방식**을 사용합니다. + +| 토큰 종류 | 용도 | 유효 기간 | 저장 위치 | +|----------|------|----------|----------| +| Access Token | API 요청 인증 | 60분 | 클라이언트 메모리 | +| Refresh Token | Access Token 갱신 | 7일 | 클라이언트 + DB(해시) | + +### 1.2 설계 원칙 + +1. **Stateless 인증**: Access Token만으로 인증 가능 (DB 조회 불필요) +2. **보안 강화**: Refresh Token은 해시로만 DB에 저장 +3. **다중 기기 지원**: 사용자당 여러 Refresh Token 허용 +4. **명시적 로그아웃**: 토큰 폐기(revoke) 기능 제공 + +--- + +## 2. 인증 정책 + +### 2.1 인증 필요 여부 분류 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 엔드포인트 인증 정책 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ [인증 불필요 - Public] │ +│ ├── POST /api/v1/home/crawling # 네이버 지도 크롤링 │ +│ ├── POST /api/v1/home/autocomplete # 네이버 자동완성 크롤링 │ +│ ├── GET /api/v1/user/auth/kakao/login # 카카오 로그인 URL │ +│ ├── GET /api/v1/user/auth/kakao/callback # 카카오 콜백 │ +│ ├── POST /api/v1/user/auth/kakao/verify # 카카오 코드 검증 │ +│ └── POST /api/v1/user/auth/refresh # 토큰 갱신 │ +│ │ +│ [인증 필수 - Protected] │ +│ ├── /api/v1/home/* (crawling, autocomplete 제외) │ +│ ├── /api/v1/lyric/* │ +│ ├── /api/v1/song/* │ +│ ├── /api/v1/video/* │ +│ └── /api/v1/user/auth/me, logout, logout/all │ +│ │ +│ [관리자 전용 - Admin Only] │ +│ └── /admin/* │ +│ │ +│ [테스트 전용 - DEBUG 모드에서만 활성화] │ +│ ├── POST /api/v1/user/auth/test/create-user # 테스트 사용자 생성 │ +│ └── POST /api/v1/user/auth/test/generate-token # 테스트 토큰 발급 │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 의존성 주입 패턴 + +```python +# 인증 필수 엔드포인트 +@router.get("/protected-endpoint") +async def protected_endpoint( + current_user: User = Depends(get_current_user), # 필수 인증 + session: AsyncSession = Depends(get_session), +): + pass + +# 인증 선택적 엔드포인트 +@router.get("/optional-auth-endpoint") +async def optional_auth_endpoint( + current_user: User | None = Depends(get_current_user_optional), # 선택적 + session: AsyncSession = Depends(get_session), +): + if current_user: + # 로그인 사용자용 로직 + else: + # 비로그인 사용자용 로직 + +# 관리자 전용 엔드포인트 +@router.get("/admin-only-endpoint") +async def admin_only_endpoint( + current_user: User = Depends(get_current_admin), # 관리자 필수 + session: AsyncSession = Depends(get_session), +): + pass +``` + +--- + +## 3. 토큰 구조 + +### 3.1 Access Token + +```json +{ + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "payload": { + "sub": "01234567-89ab-7cde-8f01-23456789abcd", // user_uuid (36자) + "exp": 1706500000, // 만료 시간 (Unix timestamp) + "type": "access" // 토큰 타입 구분 + }, + "signature": "..." +} +``` + +### 3.2 Refresh Token + +```json +{ + "header": { + "alg": "HS256", + "typ": "JWT" + }, + "payload": { + "sub": "01234567-89ab-7cde-8f01-23456789abcd", // user_uuid (36자) + "exp": 1707104800, // 만료 시간 (Unix timestamp, 7일 후) + "type": "refresh" // 토큰 타입 구분 + }, + "signature": "..." +} +``` + +### 3.3 토큰 설정 (.env) + +```env +# JWT 토큰 설정 +JWT_SECRET=oa8SBEXdDdbYmGRnIVhpLWQNJjW6yD9kL8N5DMHHCImxgvreXEd1bSxkgtXpDpqW +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 +``` + +--- + +## 4. 인증 흐름 + +### 4.1 로그인 흐름 (카카오 OAuth) + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Client │ │ Server │ │ Kakao │ │ DB │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ │ + │ 1. GET /auth/kakao/login │ │ + │───────────────>│ │ │ + │ │ │ │ + │ 2. auth_url 반환 │ │ + │<───────────────│ │ │ + │ │ │ │ + │ 3. 카카오 로그인 페이지 이동 │ │ + │────────────────────────────────>│ │ + │ │ │ │ + │ 4. 사용자 로그인/동의 │ │ + │<────────────────────────────────│ │ + │ │ │ │ + │ 5. 인가 코드 (code) 받음 │ │ + │<────────────────────────────────│ │ + │ │ │ │ + │ 6. POST /auth/kakao/verify {code} │ + │───────────────>│ │ │ + │ │ │ │ + │ │ 7. 카카오 토큰 요청 │ + │ │───────────────>│ │ + │ │ │ │ + │ │ 8. access_token 반환 │ + │ │<───────────────│ │ + │ │ │ │ + │ │ 9. 사용자 정보 조회 │ + │ │───────────────>│ │ + │ │ │ │ + │ │ 10. 사용자 정보 반환 │ + │ │<───────────────│ │ + │ │ │ │ + │ │ 11. 사용자 조회/생성 │ + │ │───────────────────────────────>│ + │ │ │ │ + │ │ 12. User 정보 │ + │ │<───────────────────────────────│ + │ │ │ │ + │ │ 13. JWT 토큰 생성 │ + │ │ (Access + Refresh) │ + │ │ │ │ + │ │ 14. Refresh Token 해시 저장 │ + │ │───────────────────────────────>│ + │ │ │ │ + │ 15. LoginResponse 반환 │ │ + │ {access_token, refresh_token, │ │ + │ expires_in, is_new_user} │ │ + │<───────────────│ │ │ + │ │ │ │ +``` + +### 4.2 API 요청 흐름 (인증된 요청) + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Client │ │ Server │ │ DB │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ + │ 1. API 요청 │ │ + │ Authorization: Bearer │ │ + │─────────────────────────────────────>│ │ + │ │ │ + │ 2. JWT 디코딩 & 검증 │ + │ - 서명 검증 │ + │ - 만료 시간 확인 │ + │ - type = "access" 확인 │ + │ │ │ + │ 3. user_uuid 추출 │ + │ │ │ + │ │ 4. 사용자 조회 │ + │ │───────────────>│ + │ │ │ + │ │ 5. User 정보 │ + │ │<───────────────│ + │ │ │ + │ 6. 사용자 상태 확인 │ + │ - is_deleted = False │ + │ - is_active = True │ + │ │ │ + │ 7. API 응답 │ │ + │<─────────────────────────────────────│ │ + │ │ │ +``` + +### 4.3 인증 실패 흐름 + +``` +┌──────────┐ ┌──────────┐ +│ Client │ │ Server │ +└────┬─────┘ └────┬─────┘ + │ │ + │ 토큰 없이 요청 │ + │─────────────────────────────────────>│ + │ │ + │ 401 MissingTokenError │ + │ {"code": "MISSING_TOKEN"} │ + │<─────────────────────────────────────│ + │ │ + │ │ + │ 만료된 토큰으로 요청 │ + │─────────────────────────────────────>│ + │ │ + │ 401 TokenExpiredError │ + │ {"code": "TOKEN_EXPIRED"} │ + │<─────────────────────────────────────│ + │ │ + │ │ + │ 잘못된 토큰으로 요청 │ + │─────────────────────────────────────>│ + │ │ + │ 401 InvalidTokenError │ + │ {"code": "INVALID_TOKEN"} │ + │<─────────────────────────────────────│ + │ │ +``` + +--- + +## 5. 토큰 검증 + +### 5.1 검증 프로세스 + +```python +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session), +) -> User: + """ + 인증된 사용자 반환 (필수 인증) + + 검증 단계: + 1. Bearer 토큰 존재 여부 확인 + 2. JWT 서명 및 구조 검증 + 3. 토큰 만료 시간 확인 + 4. 토큰 타입 확인 (type = "access") + 5. 사용자 존재 여부 확인 + 6. 사용자 활성화 상태 확인 + """ + + # 1. 토큰 존재 확인 + if credentials is None: + raise MissingTokenError() + + token = credentials.credentials + + # 2-3. JWT 디코딩 (서명 검증 + 만료 확인 포함) + payload = decode_token(token) + if payload is None: + raise InvalidTokenError() + + # 4. 토큰 타입 확인 + token_type = payload.get("type") + if token_type != "access": + raise InvalidTokenError(detail="Access token이 아닙니다") + + # 5. 사용자 조회 + user_uuid = payload.get("sub") + user = await get_user_by_uuid(session, user_uuid) + + if user is None or user.is_deleted: + raise UserNotFoundError() + + # 6. 활성화 상태 확인 + if not user.is_active: + raise UserInactiveError() + + return user +``` + +### 5.2 JWT 디코딩 함수 + +```python +def decode_token(token: str) -> dict | None: + """ + JWT 토큰 디코딩 및 검증 + + 검증 항목: + - 서명 유효성 (JWT_SECRET으로 검증) + - 만료 시간 (exp 클레임) + - 토큰 구조 + + Returns: + dict: 디코딩된 페이로드 + None: 검증 실패 시 + """ + try: + payload = jwt.decode( + token, + jwt_settings.JWT_SECRET, + algorithms=[jwt_settings.JWT_ALGORITHM], + ) + return payload + except jwt.ExpiredSignatureError: + raise TokenExpiredError() + except jwt.InvalidTokenError: + return None +``` + +### 5.3 검증 체크리스트 + +| 순서 | 검증 항목 | 실패 시 예외 | HTTP 코드 | +|-----|----------|-------------|----------| +| 1 | Bearer 토큰 존재 | MissingTokenError | 401 | +| 2 | JWT 구조 유효성 | InvalidTokenError | 401 | +| 3 | JWT 서명 검증 | InvalidTokenError | 401 | +| 4 | 토큰 만료 시간 | TokenExpiredError | 401 | +| 5 | 토큰 타입 (access) | InvalidTokenError | 401 | +| 6 | 사용자 존재 여부 | UserNotFoundError | 404 | +| 7 | 소프트 삭제 여부 | UserNotFoundError | 404 | +| 8 | 계정 활성화 상태 | UserInactiveError | 403 | + +--- + +## 6. 토큰 갱신 (Refresh) + +### 6.1 갱신 흐름 + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Client │ │ Server │ │ DB │ +└────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ + │ 1. POST /auth/refresh │ │ + │ {"refresh_token": "..."} │ │ + │─────────────────────────────────────>│ │ + │ │ │ + │ 2. JWT 디코딩 & 검증 │ + │ - 서명 검증 │ + │ - 만료 시간 확인 │ + │ - type = "refresh" 확인 │ + │ │ │ + │ 3. 토큰 해시 계산 │ + │ token_hash = SHA256(refresh_token)│ + │ │ │ + │ │ 4. DB에서 토큰 │ + │ │ 해시로 조회 │ + │ │───────────────>│ + │ │ │ + │ │ 5. RefreshToken│ + │ │ 레코드 │ + │ │<───────────────│ + │ │ │ + │ 6. 토큰 상태 확인 │ + │ - is_revoked = False │ + │ - expires_at > now │ + │ │ │ + │ 7. 새 Access Token 생성 │ + │ │ │ + │ 8. AccessTokenResponse 반환 │ │ + │ {"access_token": "...", │ │ + │ "token_type": "bearer", │ │ + │ "expires_in": 3600} │ │ + │<─────────────────────────────────────│ │ + │ │ │ +``` + +### 6.2 갱신 API 상세 + +**요청** +```http +POST /api/v1/user/auth/refresh +Content-Type: application/json + +{ + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**성공 응답 (200 OK)** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "bearer", + "expires_in": 3600 +} +``` + +**실패 응답** +```json +// 401 - 토큰 만료 +{ + "code": "TOKEN_EXPIRED", + "message": "리프레시 토큰이 만료되었습니다" +} + +// 401 - 토큰 폐기됨 +{ + "code": "TOKEN_REVOKED", + "message": "취소된 토큰입니다" +} + +// 401 - 유효하지 않은 토큰 +{ + "code": "INVALID_TOKEN", + "message": "유효하지 않은 토큰입니다" +} +``` + +### 6.3 갱신 로직 구현 + +```python +async def refresh_tokens( + refresh_token: str, + session: AsyncSession, +) -> AccessTokenResponse: + """ + Refresh Token으로 새 Access Token 발급 + + 검증 단계: + 1. JWT 디코딩 (서명, 만료 확인) + 2. 토큰 타입 확인 (type = "refresh") + 3. DB에서 토큰 해시로 조회 + 4. 토큰 폐기 여부 확인 + 5. DB 만료 시간 재확인 + 6. 새 Access Token 생성 + """ + + # 1. JWT 디코딩 + payload = decode_token(refresh_token) + if payload is None: + raise InvalidTokenError() + + # 2. 토큰 타입 확인 + if payload.get("type") != "refresh": + raise InvalidTokenError(detail="Refresh token이 아닙니다") + + user_uuid = payload.get("sub") + + # 3. DB에서 토큰 조회 + token_hash = get_token_hash(refresh_token) + db_token = await get_refresh_token_by_hash(session, token_hash) + + if db_token is None: + raise InvalidTokenError(detail="등록되지 않은 토큰입니다") + + # 4. 폐기 여부 확인 + if db_token.is_revoked: + raise TokenRevokedError() + + # 5. 만료 시간 확인 (DB 기준) + if db_token.expires_at < datetime.now(UTC): + raise TokenExpiredError() + + # 6. 새 Access Token 생성 + new_access_token = create_access_token(user_uuid) + + return AccessTokenResponse( + access_token=new_access_token, + token_type="bearer", + expires_in=get_access_token_expire_seconds(), + ) +``` + +### 6.4 Refresh Token Rotation (선택적 강화) + +보안 강화를 위해 Refresh Token도 함께 갱신하는 방식: + +```python +async def refresh_tokens_with_rotation( + refresh_token: str, + session: AsyncSession, + user_agent: str | None = None, + ip_address: str | None = None, +) -> LoginResponse: + """ + Refresh Token Rotation 방식 + + 동작: + 1. 기존 Refresh Token 검증 + 2. 기존 토큰 폐기 (is_revoked = True) + 3. 새 Access Token + 새 Refresh Token 발급 + 4. 새 Refresh Token을 DB에 저장 + + 장점: + - Refresh Token 탈취 시 빠른 감지 가능 + - 토큰 재사용 공격 방지 + + 단점: + - 네트워크 문제로 클라이언트가 새 토큰을 받지 못하면 로그아웃됨 + - 동시 요청 시 레이스 컨디션 발생 가능 + """ + + # ... 기존 검증 로직 ... + + # 기존 토큰 폐기 + await revoke_refresh_token(session, db_token) + + # 새 토큰 쌍 생성 + new_access_token = create_access_token(user_uuid) + new_refresh_token = create_refresh_token(user_uuid) + + # 새 Refresh Token 저장 + await save_refresh_token( + session=session, + user_id=db_token.user_id, + user_uuid=user_uuid, + refresh_token=new_refresh_token, + user_agent=user_agent, + ip_address=ip_address, + ) + + return LoginResponse( + access_token=new_access_token, + refresh_token=new_refresh_token, + token_type="bearer", + expires_in=get_access_token_expire_seconds(), + is_new_user=False, + ) +``` + +### 6.5 자동 갱신 타이밍 + +클라이언트에서 토큰 갱신을 요청하는 적절한 시점: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Access Token 생명주기 (60분) │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 0분 15분 30분 45분 55분 60분 │ +│ ├──────────┼──────────┼──────────┼──────────┼─────────┤ │ +│ │ 생성 │ │ │ │ 갱신 │ 만료 │ +│ │ │ │ │ │ 권장 │ │ +│ │ │ │ │ │ 시점 │ │ +│ │ │ +│ │ [정상 사용 구간] [갱신 구간] │ +│ │ 토큰으로 API 요청 토큰 갱신 요청 │ +│ │ │ +└─────────────────────────────────────────────────────────────────────┘ + +권장 갱신 시점: +- 만료 5분 전 (expires_in < 300초) +- 또는 API 요청 시 남은 시간 확인 후 자동 갱신 +``` + +--- + +## 7. 보안 고려사항 + +### 7.1 토큰 저장 보안 + +| 저장 위치 | Access Token | Refresh Token | 권장 여부 | +|----------|-------------|---------------|----------| +| localStorage | 가능 | 위험 | X | +| sessionStorage | 권장 | 가능 | O | +| httpOnly Cookie | 권장 | 권장 | O | +| 메모리 (변수) | 권장 | 가능 | O | + +**권장 방식:** +```javascript +// Access Token: 메모리에 저장 (SPA) +let accessToken = null; + +// Refresh Token: httpOnly Cookie (서버 설정) 또는 secure storage +// 현재 구현: 클라이언트에서 관리 +``` + +### 7.2 Refresh Token 보안 + +**DB 저장 방식:** +```python +# 원본 저장 X, 해시만 저장 +token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + +# RefreshToken 테이블 +class RefreshToken(Base): + token_hash: str # SHA-256 해시 (64자) + is_revoked: bool # 폐기 여부 + expires_at: datetime # 만료 시간 + user_agent: str # 접속 기기 정보 + ip_address: str # 접속 IP +``` + +### 7.3 토큰 폐기 시나리오 + +| 시나리오 | 처리 방법 | +|---------|----------| +| 사용자 로그아웃 | 해당 Refresh Token `is_revoked = True` | +| 모든 기기 로그아웃 | 사용자의 모든 Refresh Token `is_revoked = True` | +| 비밀번호 변경 | 모든 Refresh Token 폐기 (미구현) | +| 의심스러운 활동 감지 | 모든 Refresh Token 폐기 | +| 계정 비활성화 | `User.is_active = False` (토큰 검증 시 실패) | + +### 7.4 XSS/CSRF 방지 + +**XSS 방지:** +- Access Token을 DOM에 노출하지 않음 +- httpOnly Cookie 사용 권장 +- Content Security Policy (CSP) 헤더 설정 + +**CSRF 방지:** +- Bearer 토큰 사용 (쿠키 자동 전송 아님) +- SameSite Cookie 속성 설정 (쿠키 사용 시) + +### 7.5 토큰 갱신 공격 방지 + +```python +# Rate Limiting (권장 추가 구현) +@limiter.limit("10/minute") +async def refresh_endpoint(request: RefreshTokenRequest): + pass + +# Refresh Token 재사용 탐지 +async def detect_token_reuse(token_hash: str, session: AsyncSession): + """ + 이미 폐기된 토큰으로 갱신 시도 시 경고 + → 토큰 탈취 의심 → 모든 토큰 폐기 + """ + db_token = await get_refresh_token_by_hash(session, token_hash) + if db_token and db_token.is_revoked: + # 보안 경고: 토큰 재사용 시도 + await revoke_all_user_tokens(session, db_token.user_id) + raise SecurityException("의심스러운 활동이 감지되어 모든 세션이 로그아웃되었습니다") +``` + +--- + +## 8. 에러 처리 + +### 8.1 인증 관련 예외 클래스 + +```python +# app/user/exceptions.py + +class AuthException(Exception): + """인증 관련 기본 예외""" + def __init__( + self, + code: str, + message: str, + status_code: int = 401, + ): + self.code = code + self.message = message + self.status_code = status_code + +class MissingTokenError(AuthException): + def __init__(self): + super().__init__( + code="MISSING_TOKEN", + message="인증 토큰이 필요합니다", + status_code=401, + ) + +class InvalidTokenError(AuthException): + def __init__(self, detail: str = "유효하지 않은 토큰입니다"): + super().__init__( + code="INVALID_TOKEN", + message=detail, + status_code=401, + ) + +class TokenExpiredError(AuthException): + def __init__(self): + super().__init__( + code="TOKEN_EXPIRED", + message="토큰이 만료되었습니다", + status_code=401, + ) + +class TokenRevokedError(AuthException): + def __init__(self): + super().__init__( + code="TOKEN_REVOKED", + message="취소된 토큰입니다", + status_code=401, + ) + +class UserNotFoundError(AuthException): + def __init__(self): + super().__init__( + code="USER_NOT_FOUND", + message="가입되지 않은 사용자 입니다.", + status_code=404, + ) + +class UserInactiveError(AuthException): + def __init__(self): + super().__init__( + code="USER_INACTIVE", + message="활성화 상태가 아닌 사용자 입니다.", + status_code=403, + ) + +class AdminRequiredError(AuthException): + def __init__(self): + super().__init__( + code="ADMIN_REQUIRED", + message="관리자 권한이 필요합니다", + status_code=403, + ) +``` + +### 8.2 에러 응답 형식 + +```json +{ + "code": "TOKEN_EXPIRED", + "message": "토큰이 만료되었습니다", + "detail": null +} +``` + +### 8.3 전역 예외 핸들러 + +```python +# app/core/exceptions.py + +@app.exception_handler(AuthException) +async def auth_exception_handler(request: Request, exc: AuthException): + return JSONResponse( + status_code=exc.status_code, + content={ + "code": exc.code, + "message": exc.message, + }, + ) +``` + +--- + +## 9. 클라이언트 구현 가이드 + +### 9.1 토큰 관리 클래스 (JavaScript/TypeScript) + +```typescript +class AuthManager { + private accessToken: string | null = null; + private refreshToken: string | null = null; + private tokenExpiry: number | null = null; + + // 로그인 후 토큰 저장 + setTokens(loginResponse: LoginResponse) { + this.accessToken = loginResponse.access_token; + this.refreshToken = loginResponse.refresh_token; + this.tokenExpiry = Date.now() + (loginResponse.expires_in * 1000); + + // Refresh Token은 안전한 저장소에 저장 + localStorage.setItem('refresh_token', this.refreshToken); + } + + // Access Token 가져오기 (자동 갱신) + async getAccessToken(): Promise { + // 만료 5분 전이면 갱신 + if (this.isTokenExpiringSoon()) { + await this.refreshAccessToken(); + } + return this.accessToken; + } + + // 토큰 만료 임박 확인 + isTokenExpiringSoon(): boolean { + if (!this.tokenExpiry) return true; + const fiveMinutes = 5 * 60 * 1000; + return Date.now() > (this.tokenExpiry - fiveMinutes); + } + + // Access Token 갱신 + async refreshAccessToken(): Promise { + const refreshToken = this.refreshToken || localStorage.getItem('refresh_token'); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const response = await fetch('/api/v1/user/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!response.ok) { + // 갱신 실패 시 로그아웃 처리 + this.clearTokens(); + throw new Error('Token refresh failed'); + } + + const data = await response.json(); + this.accessToken = data.access_token; + this.tokenExpiry = Date.now() + (data.expires_in * 1000); + } + + // 토큰 삭제 (로그아웃) + clearTokens() { + this.accessToken = null; + this.refreshToken = null; + this.tokenExpiry = null; + localStorage.removeItem('refresh_token'); + } +} + +// 싱글톤 인스턴스 +export const authManager = new AuthManager(); +``` + +### 9.2 API 요청 인터셉터 (Axios) + +```typescript +import axios from 'axios'; +import { authManager } from './auth'; + +const api = axios.create({ + baseURL: '/api/v1', +}); + +// 요청 인터셉터: Authorization 헤더 추가 +api.interceptors.request.use(async (config) => { + const token = await authManager.getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// 응답 인터셉터: 401 에러 처리 +api.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response?.status === 401) { + const errorCode = error.response.data?.code; + + if (errorCode === 'TOKEN_EXPIRED') { + // 토큰 만료: 갱신 시도 + try { + await authManager.refreshAccessToken(); + // 원래 요청 재시도 + return api.request(error.config); + } catch (refreshError) { + // 갱신 실패: 로그인 페이지로 리다이렉트 + authManager.clearTokens(); + window.location.href = '/login'; + } + } else { + // 기타 인증 에러: 로그인 페이지로 리다이렉트 + authManager.clearTokens(); + window.location.href = '/login'; + } + } + return Promise.reject(error); + } +); + +export default api; +``` + +### 9.3 로그인 플로우 구현 + +```typescript +// 1. 카카오 로그인 URL 가져오기 +async function getKakaoLoginUrl(): Promise { + const response = await fetch('/api/v1/user/auth/kakao/login'); + const data = await response.json(); + return data.auth_url; +} + +// 2. 카카오 로그인 페이지로 이동 +function redirectToKakaoLogin() { + getKakaoLoginUrl().then(url => { + window.location.href = url; + }); +} + +// 3. 콜백 처리 (카카오에서 리다이렉트 후) +async function handleKakaoCallback(code: string): Promise { + const response = await fetch('/api/v1/user/auth/kakao/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), + }); + + if (!response.ok) { + throw new Error('Login failed'); + } + + const loginResponse = await response.json(); + authManager.setTokens(loginResponse); + + // 로그인 후 리다이렉트 + if (loginResponse.is_new_user) { + window.location.href = '/welcome'; // 신규 사용자 + } else { + window.location.href = '/dashboard'; // 기존 사용자 + } +} + +// 4. 로그아웃 +async function logout(): Promise { + const token = await authManager.getAccessToken(); + const refreshToken = localStorage.getItem('refresh_token'); + + await fetch('/api/v1/user/auth/logout', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + authManager.clearTokens(); + window.location.href = '/login'; +} +``` + +--- + +## 10. API 엔드포인트 정리 + +### 10.1 인증 API + +| 메서드 | 엔드포인트 | 설명 | 인증 필요 | 요청 본문 | 응답 | +|-------|-----------|------|----------|----------|------| +| GET | `/auth/kakao/login` | 카카오 로그인 URL | X | - | `{auth_url}` | +| GET | `/auth/kakao/callback` | 카카오 콜백 | X | code (query) | 리다이렉트 | +| POST | `/auth/kakao/verify` | 코드 검증 & 로그인 | X | `{code}` | `LoginResponse` | +| POST | `/auth/refresh` | 토큰 갱신 | X | `{refresh_token}` | `AccessTokenResponse` | +| POST | `/auth/logout` | 로그아웃 | O | `{refresh_token}` | 204 | +| POST | `/auth/logout/all` | 모든 기기 로그아웃 | O | - | 204 | +| GET | `/auth/me` | 현재 사용자 정보 | O | - | `UserResponse` | +| POST | `/auth/test/create-user` | [DEBUG] 테스트 사용자 생성 | X | `{nickname}` | `TestUserCreateResponse` | +| POST | `/auth/test/generate-token` | [DEBUG] 테스트 토큰 발급 | X | `{user_uuid}` | `TestTokenResponse` | + +### 10.2 응답 스키마 + +```typescript +// LoginResponse +interface LoginResponse { + access_token: string; + refresh_token: string; + token_type: "bearer"; + expires_in: number; // 초 단위 (3600) + is_new_user: boolean; + redirect_url?: string; +} + +// AccessTokenResponse +interface AccessTokenResponse { + access_token: string; + token_type: "bearer"; + expires_in: number; +} + +// UserResponse +interface UserResponse { + id: number; + kakao_id: number; + user_uuid: string; + email?: string; + nickname?: string; + profile_image_url?: string; + is_active: boolean; + is_admin: boolean; + role: string; + last_login_at?: string; + created_at: string; +} +``` + +### 10.3 에러 응답 코드 + +| HTTP 코드 | 에러 코드 | 메시지 | 클라이언트 처리 | +|----------|----------|------|---------------| +| 401 | MISSING_TOKEN | 인증 토큰이 필요합니다. | 로그인 페이지 이동 | +| 401 | INVALID_TOKEN | 유효하지 않은 토큰입니다. | 로그인 페이지 이동 | +| 401 | TOKEN_EXPIRED | 토큰이 만료되었습니다. 다시 로그인해주세요. | 토큰 갱신 시도 | +| 401 | TOKEN_REVOKED | 취소된 토큰입니다. 다시 로그인해주세요. | 로그인 페이지 이동 | +| 403 | USER_INACTIVE | 활성화 상태가 아닌 사용자 입니다. | 안내 메시지 표시 | +| 403 | ADMIN_REQUIRED | 관리자 권한이 필요합니다. | 접근 거부 메시지 | +| 404 | USER_NOT_FOUND | 가입되지 않은 사용자 입니다. | 로그인 페이지 이동 | + +--- + +## 부록: 참고 파일 경로 + +| 파일 | 설명 | +|-----|------| +| `app/user/services/jwt.py` | JWT 생성/검증 유틸리티 | +| `app/user/services/auth.py` | 인증 비즈니스 로직 | +| `app/user/api/routers/v1/auth.py` | 인증 API 라우터 | +| `app/user/dependencies/auth.py` | FastAPI 의존성 주입 | +| `app/user/models.py` | User, RefreshToken 모델 | +| `app/user/schemas/user_schema.py` | Pydantic 스키마 | +| `app/user/exceptions.py` | 인증 예외 클래스 | +| `config.py` | JWT 설정 (jwt_settings) | + +--- + +## 11. 테스트용 엔드포인트 (DEBUG 모드) + +### 11.1 개요 + +카카오 로그인 없이 테스트 목적으로 사용자를 생성하고 토큰을 발급받을 수 있는 엔드포인트입니다. + +**중요:** `DEBUG=True` 환경에서만 동작하며, 프로덕션 환경에서는 403 에러를 반환합니다. + +### 11.2 테스트 사용자 생성 + +**요청** +```http +POST /api/v1/auth/test/create-user +Content-Type: application/json + +{ + "nickname": "테스트유저" +} +``` + +**응답 (200 OK)** +```json +{ + "user_id": 1, + "user_uuid": "01234567-89ab-7cde-8f01-23456789abcd", + "nickname": "테스트유저", + "message": "테스트 사용자가 생성되었습니다." +} +``` + +### 11.3 테스트 토큰 발급 + +**요청** +```http +POST /api/v1/auth/test/generate-token +Content-Type: application/json + +{ + "user_uuid": "01234567-89ab-7cde-8f01-23456789abcd" +} +``` + +**응답 (200 OK)** +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +### 11.4 사용 예시 (cURL) + +```bash +# 1. 테스트 사용자 생성 +curl -X POST "http://localhost:8000/api/v1/auth/test/create-user" \ + -H "Content-Type: application/json" \ + -d '{"nickname": "테스트유저"}' + +# 응답에서 user_uuid 확인: "01234567-89ab-7cde-8f01-23456789abcd" + +# 2. 토큰 발급 +curl -X POST "http://localhost:8000/api/v1/auth/test/generate-token" \ + -H "Content-Type: application/json" \ + -d '{"user_uuid": "01234567-89ab-7cde-8f01-23456789abcd"}' + +# 응답에서 access_token 확인 + +# 3. 인증이 필요한 API 호출 +curl -X GET "http://localhost:8000/api/v1/auth/me" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +``` + +### 11.5 테스트 흐름 다이어그램 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ 테스트 인증 흐름 (DEBUG 모드) │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ ┌─────────┐ │ +│ │ Client │ │ Server │ │ +│ └────┬────┘ └────┬────┘ │ +│ │ │ │ +│ │ 1. POST /auth/test/create-user │ │ +│ │ {"nickname": "테스트유저"} │ │ +│ │─────────────────────────────────────────────────>│ │ +│ │ │ │ +│ │ 2. DEBUG 모드 확인 │ │ +│ │ 3. 테스트 User 생성 │ │ +│ │ 4. UUID7 발급 │ │ +│ │ │ │ +│ │ 5. {user_id, user_uuid, nickname, message} │ │ +│ │<─────────────────────────────────────────────────│ │ +│ │ │ │ +│ │ 6. POST /auth/test/generate-token │ │ +│ │ {"user_uuid": "..."} │ │ +│ │─────────────────────────────────────────────────>│ │ +│ │ │ │ +│ │ 7. DEBUG 모드 확인 │ │ +│ │ 8. 사용자 조회 │ │ +│ │ 9. JWT 토큰 생성 │ │ +│ │ 10. RefreshToken │ │ +│ │ DB 저장 │ │ +│ │ │ │ +│ │ 11. {access_token, refresh_token, ...} │ │ +│ │<─────────────────────────────────────────────────│ │ +│ │ │ │ +│ │ 12. 보호된 API 요청 │ │ +│ │ Authorization: Bearer │ │ +│ │─────────────────────────────────────────────────>│ │ +│ │ │ │ +│ │ 13. API 응답 │ │ +│ │<─────────────────────────────────────────────────│ │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 11.6 응답 스키마 + +```typescript +// TestUserCreateRequest +interface TestUserCreateRequest { + nickname: string; // 기본값: "테스트유저" +} + +// TestUserCreateResponse +interface TestUserCreateResponse { + user_id: number; + user_uuid: string; + nickname: string; + message: string; +} + +// TestTokenRequest +interface TestTokenRequest { + user_uuid: string; +} + +// TestTokenResponse +interface TestTokenResponse { + access_token: string; + refresh_token: string; + token_type: "Bearer"; + expires_in: number; +} +``` + +### 11.7 에러 응답 + +| HTTP 코드 | 조건 | 응답 | +|----------|------|------| +| 403 | DEBUG=False (프로덕션) | `{"detail": "테스트 엔드포인트는 DEBUG 모드에서만 사용 가능합니다."}` | +| 404 | 사용자 없음 | `{"detail": "사용자를 찾을 수 없습니다: {user_uuid}"}` | +| 403 | 비활성화 사용자 | `{"detail": "비활성화된 사용자입니다."}` | + +### 11.8 보안 고려사항 + +| 항목 | 설명 | +|------|------| +| 환경 분리 | `DEBUG=True` 일 때만 엔드포인트 활성화 | +| 가짜 kakao_id | 9000000000~9999999999 범위의 랜덤 값 사용 (실제 카카오 ID와 충돌 방지) | +| 로깅 | 모든 테스트 요청/응답에 `[TEST]` 프리픽스로 로깅 | +| 프로덕션 차단 | `config.py`의 `DEBUG` 설정으로 완전 차단 | diff --git a/docs/reference/soft_delete_guide.md b/docs/reference/soft_delete_guide.md new file mode 100644 index 0000000..f3d12a5 --- /dev/null +++ b/docs/reference/soft_delete_guide.md @@ -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 -p < 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; +``` diff --git a/main.py b/main.py index c3ba268..516cada 100644 --- a/main.py +++ b/main.py @@ -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 라우터