o2o-castad-backend/app/archive/api/routers/v1/archive.py

325 lines
12 KiB
Python

"""
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'인 영상만 반환됩니다.
- 재생성된 영상 포함 모든 영상이 반환됩니다.
- 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: 전체 개수 조회 (모든 영상)
count_query = (
select(func.count(Video.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 - 전체 영상 개수 (total): {total}")
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회 (모든 영상)
query = (
select(Video, Project)
.join(Project, Video.project_id == Project.id)
.where(*base_conditions)
.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(
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,
)
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)}",
)