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

229 lines
8.6 KiB
Python

"""
SNS API 라우터
Instagram 업로드 관련 엔드포인트를 제공합니다.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.sns.schemas.sns_schema import InstagramUploadRequest, InstagramUploadResponse
from app.user.dependencies.auth import get_current_user
from app.user.models import Platform, SocialAccount, User
from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error
from app.utils.logger import get_logger
from app.video.models import Video
logger = get_logger(__name__)
# =============================================================================
# SNS 예외 클래스 정의
# =============================================================================
class SNSException(HTTPException):
"""SNS 관련 기본 예외"""
def __init__(self, status_code: int, code: str, message: str):
super().__init__(status_code=status_code, detail={"code": code, "message": message})
class SocialAccountNotFoundError(SNSException):
"""소셜 계정 없음"""
def __init__(self, message: str = "연동된 소셜 계정을 찾을 수 없습니다."):
super().__init__(status.HTTP_404_NOT_FOUND, "SOCIAL_ACCOUNT_NOT_FOUND", message)
class VideoNotFoundError(SNSException):
"""비디오 없음"""
def __init__(self, message: str = "해당 작업 ID에 대한 비디오를 찾을 수 없습니다."):
super().__init__(status.HTTP_404_NOT_FOUND, "VIDEO_NOT_FOUND", message)
class VideoUrlNotReadyError(SNSException):
"""비디오 URL 미준비"""
def __init__(self, message: str = "비디오가 아직 준비되지 않았습니다."):
super().__init__(status.HTTP_400_BAD_REQUEST, "VIDEO_URL_NOT_READY", message)
class InstagramUploadError(SNSException):
"""Instagram 업로드 실패"""
def __init__(self, message: str = "Instagram 업로드에 실패했습니다."):
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_UPLOAD_ERROR", message)
class InstagramRateLimitError(SNSException):
"""Instagram API Rate Limit"""
def __init__(self, message: str = "Instagram API 호출 제한을 초과했습니다.", retry_after: int = 60):
super().__init__(
status.HTTP_429_TOO_MANY_REQUESTS,
"INSTAGRAM_RATE_LIMIT",
f"{message} {retry_after}초 후 다시 시도해주세요.",
)
class InstagramAuthError(SNSException):
"""Instagram 인증 오류"""
def __init__(self, message: str = "Instagram 인증에 실패했습니다. 계정을 다시 연동해주세요."):
super().__init__(status.HTTP_401_UNAUTHORIZED, "INSTAGRAM_AUTH_ERROR", message)
class InstagramContainerTimeoutError(SNSException):
"""Instagram 미디어 처리 타임아웃"""
def __init__(self, message: str = "Instagram 미디어 처리 시간이 초과되었습니다."):
super().__init__(status.HTTP_504_GATEWAY_TIMEOUT, "INSTAGRAM_CONTAINER_TIMEOUT", message)
class InstagramContainerError(SNSException):
"""Instagram 미디어 컨테이너 오류"""
def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."):
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message)
router = APIRouter(prefix="/sns", tags=["SNS"])
@router.post(
"/instagram/upload/{task_id}",
summary="Instagram 비디오 업로드",
description="""
## 개요
task_id에 해당하는 비디오를 Instagram에 업로드합니다.
## 경로 파라미터
- **task_id**: 비디오 생성 작업 고유 식별자
## 요청 본문
- **caption**: 게시물 캡션 (선택, 최대 2200자)
- **share_to_feed**: 피드에 공유 여부 (기본값: true)
## 인증
- Bearer 토큰 필요 (Authorization: Bearer <token>)
- 사용자의 Instagram 계정이 연동되어 있어야 합니다.
## 반환 정보
- **task_id**: 작업 고유 식별자
- **state**: 업로드 상태 (completed, failed)
- **message**: 상태 메시지
- **media_id**: Instagram 미디어 ID (성공 시)
- **permalink**: Instagram 게시물 URL (성공 시)
- **error**: 에러 메시지 (실패 시)
""",
response_model=InstagramUploadResponse,
responses={
200: {"description": "업로드 성공"},
400: {"description": "비디오 URL 미준비"},
401: {"description": "인증 실패"},
404: {"description": "비디오 또는 소셜 계정 없음"},
429: {"description": "Instagram API Rate Limit"},
500: {"description": "업로드 실패"},
504: {"description": "타임아웃"},
},
)
async def upload_to_instagram(
task_id: str,
request: InstagramUploadRequest,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> InstagramUploadResponse:
"""Instagram에 비디오를 업로드합니다."""
logger.info(f"[upload_to_instagram] START - task_id: {task_id}, user_uuid: {current_user.user_uuid}")
# Step 1: 사용자의 Instagram 소셜 계정 조회
social_account_result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == current_user.user_uuid,
SocialAccount.platform == Platform.INSTAGRAM,
SocialAccount.is_active == True, # noqa: E712
SocialAccount.is_deleted == False, # noqa: E712
)
)
social_account = social_account_result.scalar_one_or_none()
if social_account is None:
logger.warning(f"[upload_to_instagram] Instagram 계정 없음 - user_uuid: {current_user.user_uuid}")
raise SocialAccountNotFoundError("연동된 Instagram 계정을 찾을 수 없습니다.")
logger.info(f"[upload_to_instagram] 소셜 계정 확인 - social_account_id: {social_account.id}")
# Step 2: task_id로 비디오 조회 (가장 최근 것)
video_result = await session.execute(
select(Video)
.where(
Video.task_id == task_id,
Video.is_deleted == False, # noqa: E712
)
.order_by(Video.created_at.desc())
.limit(1)
)
video = video_result.scalar_one_or_none()
if video is None:
logger.warning(f"[upload_to_instagram] 비디오 없음 - task_id: {task_id}")
raise VideoNotFoundError(f"task_id '{task_id}'에 해당하는 비디오를 찾을 수 없습니다.")
if video.result_movie_url is None:
logger.warning(f"[upload_to_instagram] 비디오 URL 미준비 - task_id: {task_id}, status: {video.status}")
raise VideoUrlNotReadyError("비디오가 아직 처리 중입니다. 잠시 후 다시 시도해주세요.")
logger.info(f"[upload_to_instagram] 비디오 확인 - video_id: {video.id}, url: {video.result_movie_url[:50]}...")
# Step 3: Instagram 업로드
try:
async with InstagramClient(access_token=social_account.access_token) as client:
# 접속 테스트 (계정 ID 조회)
await client.get_account_id()
logger.info("[upload_to_instagram] Instagram 접속 확인 완료")
# 비디오 업로드
media = await client.publish_video(
video_url=video.result_movie_url,
caption=request.caption,
share_to_feed=request.share_to_feed,
)
logger.info(
f"[upload_to_instagram] SUCCESS - task_id: {task_id}, "
f"media_id: {media.id}, permalink: {media.permalink}"
)
return InstagramUploadResponse(
task_id=task_id,
state="completed",
message="Instagram 업로드 완료",
media_id=media.id,
permalink=media.permalink,
error=None,
)
except Exception as e:
error_state, message, extra_info = parse_instagram_error(e)
logger.error(f"[upload_to_instagram] FAILED - task_id: {task_id}, error_state: {error_state}, message: {message}")
match error_state:
case ErrorState.RATE_LIMIT:
retry_after = extra_info.get("retry_after", 60)
raise InstagramRateLimitError(retry_after=retry_after)
case ErrorState.AUTH_ERROR:
raise InstagramAuthError()
case ErrorState.CONTAINER_TIMEOUT:
raise InstagramContainerTimeoutError()
case ErrorState.CONTAINER_ERROR:
status = extra_info.get("status", "UNKNOWN")
raise InstagramContainerError(f"미디어 처리 실패: {status}")
case _:
raise InstagramUploadError(f"Instagram 업로드 실패: {message}")