229 lines
8.6 KiB
Python
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}")
|