""" 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**: 영상 목록 (video_id, 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의 영상이 여러 개인 경우, 가장 최근에 생성된 영상만 반환됩니다. - 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 - user: {current_user.user_uuid}, " f"page: {pagination.page}, page_size: {pagination.page_size}" ) try: offset = (pagination.page - 1) * pagination.page_size # 서브쿼리: task_id별 최신 Video ID 추출 # id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치 latest_video_ids = ( select(func.max(Video.id).label("latest_id")) .join(Project, Video.project_id == Project.id) .where( Project.user_uuid == current_user.user_uuid, Video.status == "completed", Video.is_deleted == False, # noqa: E712 Project.is_deleted == False, # noqa: E712 ) .group_by(Video.task_id) .subquery() ) # 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만) count_query = select(func.count(Video.id)).where( Video.id.in_(select(latest_video_ids.c.latest_id)) ) total_result = await session.execute(count_query) total = total_result.scalar() or 0 # 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만) data_query = ( select(Video, Project) .join(Project, Video.project_id == Project.id) .where(Video.id.in_(select(latest_video_ids.c.latest_id))) .order_by(Video.created_at.desc()) .offset(offset) .limit(pagination.page_size) ) result = await session.execute(data_query) rows = result.all() # VideoListItem으로 변환 items = [ VideoListItem( video_id=video.id, 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, ) for video, project in rows ] 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/{video_id}", summary="개별 영상 소프트 삭제", description=""" ## 개요 video_id에 해당하는 영상만 소프트 삭제합니다. (is_deleted=True로 설정, 실제 데이터는 DB에 유지) ## 경로 파라미터 - **video_id**: 삭제할 영상의 ID (Video.id) ## 참고 - 본인이 소유한 프로젝트의 영상만 삭제할 수 있습니다. - 소프트 삭제 방식으로 데이터 복구가 가능합니다. - 프로젝트나 다른 관련 데이터(Song, Lyric 등)는 삭제되지 않습니다. """, responses={ 200: {"description": "삭제 성공"}, 401: {"description": "인증 실패 (토큰 없음/만료)"}, 403: {"description": "삭제 권한 없음"}, 404: {"description": "영상을 찾을 수 없음"}, 500: {"description": "삭제 실패"}, }, ) async def delete_single_video( video_id: int, current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> dict: """video_id에 해당하는 개별 영상만 소프트 삭제합니다.""" logger.info(f"[delete_single_video] START - video_id: {video_id}, user: {current_user.user_uuid}") try: # Video 조회 (Project와 함께) result = await session.execute( select(Video, Project) .join(Project, Video.project_id == Project.id) .where( Video.id == video_id, Video.is_deleted == False, ) ) row = result.one_or_none() if row is None: logger.warning(f"[delete_single_video] NOT FOUND - video_id: {video_id}") raise HTTPException( status_code=404, detail="영상을 찾을 수 없습니다.", ) video, project = row # 소유권 검증 if project.user_uuid != current_user.user_uuid: logger.warning( f"[delete_single_video] FORBIDDEN - video_id: {video_id}, " f"owner: {project.user_uuid}, requester: {current_user.user_uuid}" ) raise HTTPException( status_code=403, detail="삭제 권한이 없습니다.", ) # 소프트 삭제 video.is_deleted = True await session.commit() logger.info(f"[delete_single_video] SUCCESS - video_id: {video_id}") return { "success": True, "message": "영상이 삭제되었습니다.", "video_id": video_id, } except HTTPException: raise except Exception as e: logger.error(f"[delete_single_video] EXCEPTION - video_id: {video_id}, error: {e}") raise HTTPException( status_code=500, detail=f"삭제에 실패했습니다: {str(e)}", ) @router.delete( "/videos/delete/{task_id}", summary="프로젝트 전체 소프트 삭제 (task_id 기준)", 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 형식) ## 참고 - 본인이 소유한 프로젝트만 삭제할 수 있습니다. - 소프트 삭제 방식으로 데이터 복구가 가능합니다. - 백그라운드에서 비동기로 처리됩니다. - **개별 영상만 삭제하려면 DELETE /archive/videos/{video_id}를 사용하세요.** """, 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)}", )