From c07a2f6dae9595688b4ca1ed18eb8e38fbc0aa90 Mon Sep 17 00:00:00 2001 From: Dohyun Lim Date: Wed, 28 Jan 2026 19:23:46 +0900 Subject: [PATCH] finish --- access_plan.md | 7 + app/archive/api/routers/v1/archive.py | 334 ++++++++++++++++++ app/archive/worker/archive_task.py | 185 ++++++++++ app/database/session.py | 5 +- app/home/api/routers/v1/home.py | 7 +- app/home/models.py | 19 +- app/home/worker/home_task.py | 4 +- app/lyric/api/routers/v1/lyric.py | 57 ++- app/lyric/models.py | 11 +- app/song/api/routers/v1/song.py | 39 +- app/song/models.py | 19 +- app/song/worker/song_task.py | 4 +- app/user/models.py | 8 + app/utils/creatomate.py | 6 +- app/video/api/routers/v1/video.py | 53 ++- app/video/models.py | 11 +- app/video/worker/video_task.py | 8 +- .../migration_add_is_deleted.sql | 75 ++++ docs/reference/soft_delete_guide.md | 143 ++++++++ main.py | 61 +++- 20 files changed, 995 insertions(+), 61 deletions(-) create mode 100644 docs/database-schema/migration_add_is_deleted.sql create mode 100644 docs/reference/soft_delete_guide.md diff --git a/access_plan.md b/access_plan.md index 754da82..75d54a0 100644 --- a/access_plan.md +++ b/access_plan.md @@ -76,6 +76,8 @@ | **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) | --- @@ -261,6 +263,8 @@ async def list_lyrics(...): | `/video/status/{...}` | **선택적** | 상태 조회 | | `/video/download/{task_id}` | **선택적** | 다운로드 | | `/videos/` | **선택적** | 목록 조회 | +| `/archive/videos/` | **필수** | 완료된 영상 목록 조회 (아카이브) | +| `/archive/videos/{task_id}` | **필수** | 아카이브 영상 삭제 + 소유권 검증 | --- @@ -494,6 +498,8 @@ async def get_lyric_detail( - [ ] `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: 크롤링 엔드포인트 @@ -541,6 +547,7 @@ def custom_openapi(): "/lyric/generate", "/song/generate", "/video/generate", + "/archive/videos", # GET (목록조회), DELETE (삭제) ] AUTH_OPTIONAL_PATHS = [ diff --git a/app/archive/api/routers/v1/archive.py b/app/archive/api/routers/v1/archive.py index e69de29..4e639d7 100644 --- a/app/archive/api/routers/v1/archive.py +++ b/app/archive/api/routers/v1/archive.py @@ -0,0 +1,334 @@ +""" +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": "영상 목록 조회 성공"}, + 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": "삭제 요청 성공"}, + 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 39071b5..e3e54dd 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -72,16 +72,17 @@ async def create_db_tables(): import asyncio # 모델 import (테이블 메타데이터 등록용) - from app.user.models import User, RefreshToken # 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, SongTimestamp # noqa: F401 from app.video.models import Video # noqa: F401 - # 생성할 테이블 목록 (SocialAccount 제외) + # 생성할 테이블 목록 tables_to_create = [ User.__table__, RefreshToken.__table__, + SocialAccount.__table__, Project.__table__, Image.__table__, Lyric.__table__, diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index 46f51f0..6e66d1a 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -658,6 +658,9 @@ async def upload_images( 이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다. 바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다. +## 인증 +**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. + ## 요청 방식 multipart/form-data 형식으로 전송합니다. @@ -684,11 +687,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" ``` @@ -812,7 +817,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 a96aafc..af5619f 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -9,7 +9,7 @@ Home 모듈 SQLAlchemy 모델 정의 from datetime import datetime from typing import TYPE_CHECKING, List, Optional -from sqlalchemy import 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 @@ -49,6 +49,7 @@ class Project(Base): 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", @@ -113,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, @@ -175,6 +183,7 @@ 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", @@ -193,7 +202,6 @@ class Image(Base): task_id: Mapped[str] = mapped_column( String(36), nullable=False, - unique=True, comment="이미지 업로드 작업 고유 식별자 (UUID7)", ) @@ -216,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 977eb21..886bb33 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -174,6 +174,9 @@ async def get_lyric_by_task_id( 고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다. 백그라운드에서 비동기로 처리되며, 즉시 task_id를 반환합니다. +## 인증 +**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. + ## 요청 필드 - **task_id**: 작업 고유 식별자 (이미지 업로드 시 생성된 task_id, 필수) - **customer_name**: 고객명/가게명 (필수) @@ -192,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/api/v1/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" -} + }' ``` ## 응답 예시 @@ -296,6 +301,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() @@ -376,14 +382,18 @@ 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/api/v1/lyric/status/019123ab-cdef-7890-abcd-ef1234567890" \\ + -H "Authorization: Bearer {access_token}" ``` """, response_model=LyricStatusResponse, @@ -407,6 +417,9 @@ async def get_lyric_status( description=""" 생성 완료된 가사를 페이지네이션으로 조회합니다. +## 인증 +**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. + ## 파라미터 - **page**: 페이지 번호 (1부터 시작, 기본값: 1) - **page_size**: 페이지당 데이터 수 (기본값: 20, 최대: 100) @@ -420,11 +433,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/api/v1/lyrics/" \\ + -H "Authorization: Bearer {access_token}" + +# 2페이지 조회 +curl -X GET "http://localhost:8000/api/v1/lyrics/?page=2" \\ + -H "Authorization: Bearer {access_token}" + +# 50개씩 조회 +curl -X GET "http://localhost:8000/api/v1/lyrics/?page=1&page_size=50" \\ + -H "Authorization: Bearer {access_token}" ``` ## 참고 @@ -461,6 +482,9 @@ async def list_lyrics( description=""" task_id로 생성된 가사의 상세 정보를 조회합니다. +## 인증 +**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. + ## 반환 정보 - **id**: 가사 ID - **task_id**: 작업 고유 식별자 @@ -470,9 +494,10 @@ task_id로 생성된 가사의 상세 정보를 조회합니다. - **lyric_result**: 생성된 가사 (완료 시) - **created_at**: 생성 일시 -## 사용 예시 -``` -GET /lyric/019123ab-cdef-7890-abcd-ef1234567890 +## 사용 예시 (cURL) +```bash +curl -X GET "http://localhost:8000/api/v1/lyric/019123ab-cdef-7890-abcd-ef1234567890" \\ + -H "Authorization: Bearer {access_token}" ``` """, response_model=LyricDetailResponse, diff --git a/app/lyric/models.py b/app/lyric/models.py index b31be97..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, Index, 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 @@ -39,6 +39,7 @@ class Lyric(Base): __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", @@ -64,7 +65,6 @@ class Lyric(Base): task_id: Mapped[str] = mapped_column( String(36), nullable=False, - unique=True, comment="가사 생성 작업 고유 식별자 (UUID7)", ) @@ -93,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 1fa6c2f..3dd4e08 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -42,6 +42,9 @@ router = APIRouter(prefix="/song", tags=["Song"]) description=""" Suno API를 통해 노래 생성을 요청합니다. +## 인증 +**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. + ## 경로 파라미터 - **task_id**: Project/Lyric의 task_id (필수) - 연관된 프로젝트와 가사를 조회하는 데 사용 @@ -56,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/api/v1/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" -} + }' ``` ## 참고 @@ -316,6 +321,9 @@ async def generate_song( Suno API를 통해 노래 생성 작업의 상태를 조회합니다. SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 Azure Blob Storage에 업로드를 시작합니다. +## 인증 +**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. + ## 경로 파라미터 - **song_id**: 노래 생성 시 반환된 Suno API 작업 ID (필수) @@ -324,9 +332,10 @@ SUCCESS 상태인 경우 백그라운드에서 MP3 파일을 다운로드하고 - **status**: Suno API 작업 상태 - **message**: 상태 메시지 -## 사용 예시 -``` -GET /song/status/abc123... +## 사용 예시 (cURL) +```bash +curl -X GET "http://localhost:8000/api/v1/song/status/{song_id}" \\ + -H "Authorization: Bearer {access_token}" ``` ## 상태 값 (Suno API 응답) @@ -425,6 +434,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( @@ -471,6 +481,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 0849955..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, Index, 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 @@ -42,6 +42,7 @@ class Song(Base): 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", @@ -74,7 +75,6 @@ class Song(Base): task_id: Mapped[str] = mapped_column( String(36), nullable=False, - unique=True, comment="노래 생성 작업 고유 식별자 (UUID7)", ) @@ -120,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, @@ -180,6 +187,7 @@ 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", @@ -225,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/models.py b/app/user/models.py index 5a310d8..58a81e6 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -439,6 +439,7 @@ class SocialAccount(Base): 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_uuid", @@ -541,6 +542,13 @@ class SocialAccount(Base): comment="연동 활성화 상태 (비활성화 시 사용 중지)", ) + is_deleted: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + comment="소프트 삭제 여부 (True: 삭제됨)", + ) + # ========================================================================== # 시간 정보 # ========================================================================== 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/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 82126aa..b4d39b5 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -52,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 (필수) - 연관된 프로젝트, 가사, 노래, 이미지를 조회하는 데 사용 @@ -70,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/api/v1/video/generate/0694b716-dbff-7219-8000-d08cb5fce431" \\ + -H "Authorization: Bearer {access_token}" + +# 가로형 영상 생성 +curl -X GET "http://localhost:8000/api/v1/video/generate/0694b716-dbff-7219-8000-d08cb5fce431?orientation=horizontal" \\ + -H "Authorization: Bearer {access_token}" ``` ## 참고 @@ -350,9 +358,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( @@ -457,6 +465,9 @@ async def generate_video( Creatomate API를 통해 영상 생성 작업의 상태를 조회합니다. succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 Video 테이블을 업데이트합니다. +## 인증 +**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. + ## 경로 파라미터 - **creatomate_render_id**: 영상 생성 시 반환된 Creatomate 렌더 ID (필수) @@ -467,9 +478,10 @@ succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하 - **render_data**: 렌더링 결과 데이터 (완료 시) - **raw_response**: Creatomate API 원본 응답 -## 사용 예시 -``` -GET /video/status/render-id-123... +## 사용 예시 (cURL) +```bash +curl -X GET "http://localhost:8000/api/v1/video/status/{creatomate_render_id}" \\ + -H "Authorization: Bearer {access_token}" ``` ## 상태 값 @@ -554,6 +566,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( @@ -602,6 +615,9 @@ async def get_video_status( task_id를 기반으로 Video 테이블의 상태를 polling하고, completed인 경우 Project 정보와 영상 URL을 반환합니다. +## 인증 +**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. + ## 경로 파라미터 - **task_id**: 프로젝트 task_id (필수) @@ -615,9 +631,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/api/v1/video/download/019123ab-cdef-7890-abcd-ef1234567890" \\ + -H "Authorization: Bearer {access_token}" ``` ## 참고 @@ -718,6 +735,9 @@ async def download_video( description=""" 완료된 영상 목록을 페이지네이션하여 조회합니다. +## 인증 +**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. + ## 쿼리 파라미터 - **page**: 페이지 번호 (1부터 시작, 기본값: 1) - **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100) @@ -731,9 +751,10 @@ async def download_video( - **has_next**: 다음 페이지 존재 여부 - **has_prev**: 이전 페이지 존재 여부 -## 사용 예시 -``` -GET /videos/?page=1&page_size=10 +## 사용 예시 (cURL) +```bash +curl -X GET "http://localhost:8000/api/v1/videos/?page=1&page_size=10" \\ + -H "Authorization: Bearer {access_token}" ``` ## 참고 diff --git a/app/video/models.py b/app/video/models.py index b1738f0..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, Index, 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 @@ -41,6 +41,7 @@ class Video(Base): 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", @@ -80,7 +81,6 @@ class Video(Base): task_id: Mapped[str] = mapped_column( String(36), nullable=False, - unique=True, comment="영상 생성 작업 고유 식별자 (UUID7)", ) @@ -102,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/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/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 e2485ea..4977749 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ from app.database.session import engine # 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, test_router as auth_test_router from app.lyric.api.routers.v1.lyric import router as lyric_router @@ -36,6 +37,14 @@ tags_metadata = [ - **Access Token**: 1시간 유효, API 호출 시 사용 - **Refresh Token**: 7일 유효, Access Token 갱신 시 사용 + +## Scalar에서 인증 사용하기 + +1. 카카오 로그인 또는 테스트 토큰 발급으로 `access_token` 획득 +2. 우측 상단 **Authorize** 버튼 클릭 +3. `access_token` 값 입력 (Bearer 접두사 없이 토큰만 입력) +4. **Authorize** 클릭하여 저장 +5. 이후 인증이 필요한 API 호출 시 자동으로 토큰이 포함됨 """, }, # { @@ -44,16 +53,24 @@ 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` - 가사 생성 요청 (백그라운드 처리) @@ -65,6 +82,8 @@ tags_metadata = [ "name": "Song", "description": """노래 생성 및 관리 API (Suno AI) +**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수 + ## 노래 생성 흐름 1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청 @@ -75,11 +94,32 @@ tags_metadata = [ "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 조회 +""", + }, + { + "name": "Archive", + "description": """아카이브 API - 완료된 영상 목록 조회 및 삭제 + +**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수 + +## 주요 기능 + +- `GET /api/v1/archive/videos/` - 완료된 영상 목록 페이지네이션 조회 +- `DELETE /api/v1/archive/videos/{task_id}` - 아카이브 영상 삭제 (CASCADE) + +## 참고 + +- status가 'completed'인 영상만 반환됩니다. +- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다. +- created_at 기준 내림차순 정렬됩니다. +- 삭제 시 관련 Lyric, Song, Video가 CASCADE로 함께 삭제됩니다. """, }, ] @@ -137,12 +177,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 @@ -182,6 +234,7 @@ 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: