""" 소셜 업로드 API 라우터 소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다. """ import logging, json from typing import Optional from fastapi import APIRouter, BackgroundTasks, Depends, Query from fastapi import HTTPException, status 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, YoutubeDescriptionRequest, YoutubeDescriptionResponse, ) 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.home.models import Project, MarketingIntel from app.video.models import Video from app.utils.prompts.prompts import yt_upload_prompt from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError 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: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="업로드 정보를 찾을 수 없습니다.", ) if upload.status != UploadStatus.PENDING.value: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="대기 중인 업로드만 취소할 수 있습니다.", ) upload.status = UploadStatus.CANCELLED.value await session.commit() return MessageResponse( success=True, message="업로드가 취소되었습니다.", ) @router.post( "/autodescription", response_model=YoutubeDescriptionResponse, summary="유튜브 SEO descrption 생성", description="유튜브 업로드 시 사용할 descrption을 SEO 적용하여 생성", ) async def youtube_seo_description( request_body: YoutubeDescriptionRequest, current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> YoutubeDescriptionResponse: logger.info( f"[youtube_seo_description] START - user: {current_user.user_uuid} " ) try: task_id = request_body.task_id project_query = await session.execute( select(Project) .where( Project.task_id == task_id, Project.user_uuid == current_user.user_uuid) .order_by(Project.created_at.desc()) .limit(1) ) project = project_query.scalar_one_or_none() marketing_query = await session.execute( select(MarketingIntel) .where(MarketingIntel.id == project.marketing_inteligence) ) marketing_intelligence = marketing_query.scalar_one_or_none() hashtags = marketing_intelligence.intel_result["target_keywords"] yt_seo_input_data = { "customer_name" : project.store_name, "detail_region_info" : project.detail_region_info, "marketing_intelligence_summary" : json.dumps(marketing_intelligence.intel_result, ensure_ascii=False), "language" : project.language, "target_keywords" : hashtags } chatgpt = ChatgptService() yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data) response = YoutubeDescriptionResponse( title=yt_seo_output.title, description=yt_seo_output.description, keywords = hashtags ) return response except Exception as e: logger.error(f"[youtube_seo_description] EXCEPTION - error: {e}") raise HTTPException( status_code=500, detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}", )