428 lines
14 KiB
Python
428 lines
14 KiB
Python
"""
|
|
소셜 업로드 API 라우터
|
|
|
|
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
|
from sqlalchemy import select, func
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.session import get_session
|
|
from app.social.constants import SocialPlatform, UploadStatus
|
|
from app.social.exceptions import SocialAccountNotFoundError, VideoNotFoundError
|
|
from app.social.models import SocialUpload
|
|
from app.social.schemas import (
|
|
MessageResponse,
|
|
SocialUploadHistoryItem,
|
|
SocialUploadHistoryResponse,
|
|
SocialUploadRequest,
|
|
SocialUploadResponse,
|
|
SocialUploadStatusResponse,
|
|
)
|
|
from app.social.services import social_account_service
|
|
from app.social.worker.upload_task import process_social_upload
|
|
from app.user.dependencies import get_current_user
|
|
from app.user.models import User
|
|
from app.video.models import Video
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/upload", tags=["Social Upload"])
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=SocialUploadResponse,
|
|
summary="소셜 플랫폼에 영상 업로드 요청",
|
|
description="""
|
|
영상을 소셜 미디어 플랫폼에 업로드합니다.
|
|
|
|
## 사전 조건
|
|
- 해당 플랫폼에 계정이 연동되어 있어야 합니다
|
|
- 영상이 completed 상태여야 합니다 (result_movie_url 필요)
|
|
|
|
## 요청 필드
|
|
- **video_id**: 업로드할 영상 ID
|
|
- **social_account_id**: 업로드할 소셜 계정 ID (연동 계정 목록 조회 API에서 확인)
|
|
- **title**: 영상 제목 (최대 100자)
|
|
- **description**: 영상 설명 (최대 5000자)
|
|
- **tags**: 태그 목록
|
|
- **privacy_status**: 공개 상태 (public, unlisted, private)
|
|
- **scheduled_at**: 예약 게시 시간 (선택사항)
|
|
|
|
## 업로드 상태
|
|
업로드는 백그라운드에서 처리되며, 상태를 폴링하여 확인할 수 있습니다:
|
|
- `pending`: 업로드 대기 중
|
|
- `uploading`: 업로드 진행 중
|
|
- `processing`: 플랫폼에서 처리 중
|
|
- `completed`: 업로드 완료
|
|
- `failed`: 업로드 실패
|
|
""",
|
|
)
|
|
async def upload_to_social(
|
|
body: SocialUploadRequest,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> SocialUploadResponse:
|
|
"""
|
|
소셜 플랫폼에 영상 업로드 요청
|
|
|
|
백그라운드에서 영상을 다운로드하고 소셜 플랫폼에 업로드합니다.
|
|
"""
|
|
logger.info(
|
|
f"[UPLOAD_API] 업로드 요청 - "
|
|
f"user_uuid: {current_user.user_uuid}, "
|
|
f"video_id: {body.video_id}, "
|
|
f"social_account_id: {body.social_account_id}"
|
|
)
|
|
|
|
# 1. 영상 조회 및 검증
|
|
video_result = await session.execute(
|
|
select(Video).where(Video.id == body.video_id)
|
|
)
|
|
video = video_result.scalar_one_or_none()
|
|
|
|
if not video:
|
|
logger.warning(f"[UPLOAD_API] 영상 없음 - video_id: {body.video_id}")
|
|
raise VideoNotFoundError(video_id=body.video_id)
|
|
|
|
if not video.result_movie_url:
|
|
logger.warning(f"[UPLOAD_API] 영상 URL 없음 - video_id: {body.video_id}")
|
|
raise VideoNotFoundError(
|
|
video_id=body.video_id,
|
|
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
|
)
|
|
|
|
# 2. 소셜 계정 조회 (social_account_id로 직접 조회, 소유권 검증 포함)
|
|
account = await social_account_service.get_account_by_id(
|
|
user_uuid=current_user.user_uuid,
|
|
account_id=body.social_account_id,
|
|
session=session,
|
|
)
|
|
|
|
if not account:
|
|
logger.warning(
|
|
f"[UPLOAD_API] 연동 계정 없음 - "
|
|
f"user_uuid: {current_user.user_uuid}, social_account_id: {body.social_account_id}"
|
|
)
|
|
raise SocialAccountNotFoundError()
|
|
|
|
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
|
in_progress_result = await session.execute(
|
|
select(SocialUpload).where(
|
|
SocialUpload.video_id == body.video_id,
|
|
SocialUpload.social_account_id == account.id,
|
|
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
|
)
|
|
)
|
|
in_progress_upload = in_progress_result.scalar_one_or_none()
|
|
|
|
if in_progress_upload:
|
|
logger.info(
|
|
f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {in_progress_upload.id}"
|
|
)
|
|
return SocialUploadResponse(
|
|
success=True,
|
|
upload_id=in_progress_upload.id,
|
|
platform=account.platform,
|
|
status=in_progress_upload.status,
|
|
message="이미 업로드가 진행 중입니다.",
|
|
)
|
|
|
|
# 4. 업로드 순번 계산 (동일 video + account 조합에서 최대 순번 + 1)
|
|
max_seq_result = await session.execute(
|
|
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
|
SocialUpload.video_id == body.video_id,
|
|
SocialUpload.social_account_id == account.id,
|
|
)
|
|
)
|
|
max_seq = max_seq_result.scalar() or 0
|
|
next_seq = max_seq + 1
|
|
|
|
# 5. 새 업로드 레코드 생성 (항상 새로 생성하여 이력 보존)
|
|
social_upload = SocialUpload(
|
|
user_uuid=current_user.user_uuid,
|
|
video_id=body.video_id,
|
|
social_account_id=account.id,
|
|
upload_seq=next_seq,
|
|
platform=account.platform,
|
|
status=UploadStatus.PENDING.value,
|
|
upload_progress=0,
|
|
title=body.title,
|
|
description=body.description,
|
|
tags=body.tags,
|
|
privacy_status=body.privacy_status.value,
|
|
platform_options={
|
|
**(body.platform_options or {}),
|
|
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
|
|
},
|
|
retry_count=0,
|
|
)
|
|
|
|
session.add(social_upload)
|
|
await session.commit()
|
|
await session.refresh(social_upload)
|
|
|
|
logger.info(
|
|
f"[UPLOAD_API] 업로드 레코드 생성 - "
|
|
f"upload_id: {social_upload.id}, video_id: {body.video_id}, "
|
|
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
|
|
)
|
|
|
|
# 6. 백그라운드 태스크 등록
|
|
background_tasks.add_task(process_social_upload, social_upload.id)
|
|
|
|
return SocialUploadResponse(
|
|
success=True,
|
|
upload_id=social_upload.id,
|
|
platform=account.platform,
|
|
status=social_upload.status,
|
|
message="업로드 요청이 접수되었습니다.",
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{upload_id}/status",
|
|
response_model=SocialUploadStatusResponse,
|
|
summary="업로드 상태 조회",
|
|
description="특정 업로드 작업의 상태를 조회합니다.",
|
|
)
|
|
async def get_upload_status(
|
|
upload_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> SocialUploadStatusResponse:
|
|
"""
|
|
업로드 상태 조회
|
|
"""
|
|
logger.info(f"[UPLOAD_API] 상태 조회 - upload_id: {upload_id}")
|
|
|
|
result = await session.execute(
|
|
select(SocialUpload).where(
|
|
SocialUpload.id == upload_id,
|
|
SocialUpload.user_uuid == current_user.user_uuid,
|
|
)
|
|
)
|
|
upload = result.scalar_one_or_none()
|
|
|
|
if not upload:
|
|
from fastapi import HTTPException, status
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="업로드 정보를 찾을 수 없습니다.",
|
|
)
|
|
|
|
return SocialUploadStatusResponse(
|
|
upload_id=upload.id,
|
|
video_id=upload.video_id,
|
|
social_account_id=upload.social_account_id,
|
|
upload_seq=upload.upload_seq,
|
|
platform=upload.platform,
|
|
status=UploadStatus(upload.status),
|
|
upload_progress=upload.upload_progress,
|
|
title=upload.title,
|
|
platform_video_id=upload.platform_video_id,
|
|
platform_url=upload.platform_url,
|
|
error_message=upload.error_message,
|
|
retry_count=upload.retry_count,
|
|
created_at=upload.created_at,
|
|
uploaded_at=upload.uploaded_at,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/history",
|
|
response_model=SocialUploadHistoryResponse,
|
|
summary="업로드 이력 조회",
|
|
description="사용자의 소셜 미디어 업로드 이력을 조회합니다.",
|
|
)
|
|
async def get_upload_history(
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"),
|
|
status: Optional[UploadStatus] = Query(None, description="상태 필터"),
|
|
page: int = Query(1, ge=1, description="페이지 번호"),
|
|
size: int = Query(20, ge=1, le=100, description="페이지 크기"),
|
|
) -> SocialUploadHistoryResponse:
|
|
"""
|
|
업로드 이력 조회
|
|
"""
|
|
logger.info(
|
|
f"[UPLOAD_API] 이력 조회 - "
|
|
f"user_uuid: {current_user.user_uuid}, page: {page}, size: {size}"
|
|
)
|
|
|
|
# 기본 쿼리
|
|
query = select(SocialUpload).where(
|
|
SocialUpload.user_uuid == current_user.user_uuid
|
|
)
|
|
|
|
count_query = select(func.count(SocialUpload.id)).where(
|
|
SocialUpload.user_uuid == current_user.user_uuid
|
|
)
|
|
|
|
# 필터 적용
|
|
if platform:
|
|
query = query.where(SocialUpload.platform == platform.value)
|
|
count_query = count_query.where(SocialUpload.platform == platform.value)
|
|
|
|
if status:
|
|
query = query.where(SocialUpload.status == status.value)
|
|
count_query = count_query.where(SocialUpload.status == status.value)
|
|
|
|
# 총 개수 조회
|
|
total_result = await session.execute(count_query)
|
|
total = total_result.scalar() or 0
|
|
|
|
# 페이지네이션 적용
|
|
query = (
|
|
query.order_by(SocialUpload.created_at.desc())
|
|
.offset((page - 1) * size)
|
|
.limit(size)
|
|
)
|
|
|
|
result = await session.execute(query)
|
|
uploads = result.scalars().all()
|
|
|
|
items = [
|
|
SocialUploadHistoryItem(
|
|
upload_id=upload.id,
|
|
video_id=upload.video_id,
|
|
social_account_id=upload.social_account_id,
|
|
upload_seq=upload.upload_seq,
|
|
platform=upload.platform,
|
|
status=upload.status,
|
|
title=upload.title,
|
|
platform_url=upload.platform_url,
|
|
created_at=upload.created_at,
|
|
uploaded_at=upload.uploaded_at,
|
|
)
|
|
for upload in uploads
|
|
]
|
|
|
|
return SocialUploadHistoryResponse(
|
|
items=items,
|
|
total=total,
|
|
page=page,
|
|
size=size,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{upload_id}/retry",
|
|
response_model=SocialUploadResponse,
|
|
summary="업로드 재시도",
|
|
description="실패한 업로드를 재시도합니다.",
|
|
)
|
|
async def retry_upload(
|
|
upload_id: int,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> SocialUploadResponse:
|
|
"""
|
|
업로드 재시도
|
|
|
|
실패한 업로드를 다시 시도합니다.
|
|
"""
|
|
logger.info(f"[UPLOAD_API] 재시도 요청 - upload_id: {upload_id}")
|
|
|
|
result = await session.execute(
|
|
select(SocialUpload).where(
|
|
SocialUpload.id == upload_id,
|
|
SocialUpload.user_uuid == current_user.user_uuid,
|
|
)
|
|
)
|
|
upload = result.scalar_one_or_none()
|
|
|
|
if not upload:
|
|
from fastapi import HTTPException, status
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="업로드 정보를 찾을 수 없습니다.",
|
|
)
|
|
|
|
if upload.status not in [UploadStatus.FAILED.value, UploadStatus.CANCELLED.value]:
|
|
from fastapi import HTTPException, status
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="실패하거나 취소된 업로드만 재시도할 수 있습니다.",
|
|
)
|
|
|
|
# 상태 초기화
|
|
upload.status = UploadStatus.PENDING.value
|
|
upload.upload_progress = 0
|
|
upload.error_message = None
|
|
await session.commit()
|
|
|
|
# 백그라운드 태스크 등록
|
|
background_tasks.add_task(process_social_upload, upload.id)
|
|
|
|
return SocialUploadResponse(
|
|
success=True,
|
|
upload_id=upload.id,
|
|
platform=upload.platform,
|
|
status=upload.status,
|
|
message="업로드 재시도가 요청되었습니다.",
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/{upload_id}",
|
|
response_model=MessageResponse,
|
|
summary="업로드 취소",
|
|
description="대기 중인 업로드를 취소합니다.",
|
|
)
|
|
async def cancel_upload(
|
|
upload_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> MessageResponse:
|
|
"""
|
|
업로드 취소
|
|
|
|
대기 중인 업로드를 취소합니다.
|
|
이미 진행 중이거나 완료된 업로드는 취소할 수 없습니다.
|
|
"""
|
|
logger.info(f"[UPLOAD_API] 취소 요청 - upload_id: {upload_id}")
|
|
|
|
result = await session.execute(
|
|
select(SocialUpload).where(
|
|
SocialUpload.id == upload_id,
|
|
SocialUpload.user_uuid == current_user.user_uuid,
|
|
)
|
|
)
|
|
upload = result.scalar_one_or_none()
|
|
|
|
if not upload:
|
|
from fastapi import HTTPException, status
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="업로드 정보를 찾을 수 없습니다.",
|
|
)
|
|
|
|
if upload.status != UploadStatus.PENDING.value:
|
|
from fastapi import HTTPException, status
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="대기 중인 업로드만 취소할 수 있습니다.",
|
|
)
|
|
|
|
upload.status = UploadStatus.CANCELLED.value
|
|
await session.commit()
|
|
|
|
return MessageResponse(
|
|
success=True,
|
|
message="업로드가 취소되었습니다.",
|
|
)
|