338 lines
13 KiB
Python
338 lines
13 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**: 영상 목록 (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)}",
|
|
)
|