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

350 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'인 영상만 반환됩니다.
- 동일 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)}",
)