From ef203dc14d3e8422555125e2dfdae9ddc9711772 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 2 Feb 2026 11:13:08 +0900 Subject: [PATCH] =?UTF-8?q?youtube=20=EA=B3=84=EC=A0=95=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=9E=91=EC=97=85=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/social/__init__.py | 15 + app/social/api/__init__.py | 3 + app/social/api/routers/__init__.py | 3 + app/social/api/routers/v1/__init__.py | 8 + app/social/api/routers/v1/oauth.py | 281 ++++++++++++ app/social/api/routers/v1/upload.py | 404 +++++++++++++++++ app/social/constants.py | 123 ++++++ app/social/exceptions.py | 330 ++++++++++++++ app/social/models.py | 245 +++++++++++ app/social/oauth/__init__.py | 46 ++ app/social/oauth/base.py | 113 +++++ app/social/oauth/youtube.py | 326 ++++++++++++++ app/social/schemas.py | 288 ++++++++++++ app/social/services.py | 529 +++++++++++++++++++++++ app/social/uploader/__init__.py | 47 ++ app/social/uploader/base.py | 168 +++++++ app/social/uploader/youtube.py | 420 ++++++++++++++++++ app/social/worker/__init__.py | 9 + app/social/worker/upload_task.py | 374 ++++++++++++++++ config.py | 134 ++++++ docs/api/social_api_spec.json | 601 ++++++++++++++++++++++++++ main.py | 54 +++ 22 files changed, 4521 insertions(+) create mode 100644 app/social/__init__.py create mode 100644 app/social/api/__init__.py create mode 100644 app/social/api/routers/__init__.py create mode 100644 app/social/api/routers/v1/__init__.py create mode 100644 app/social/api/routers/v1/oauth.py create mode 100644 app/social/api/routers/v1/upload.py create mode 100644 app/social/constants.py create mode 100644 app/social/exceptions.py create mode 100644 app/social/models.py create mode 100644 app/social/oauth/__init__.py create mode 100644 app/social/oauth/base.py create mode 100644 app/social/oauth/youtube.py create mode 100644 app/social/schemas.py create mode 100644 app/social/services.py create mode 100644 app/social/uploader/__init__.py create mode 100644 app/social/uploader/base.py create mode 100644 app/social/uploader/youtube.py create mode 100644 app/social/worker/__init__.py create mode 100644 app/social/worker/upload_task.py create mode 100644 docs/api/social_api_spec.json diff --git a/app/social/__init__.py b/app/social/__init__.py new file mode 100644 index 0000000..1e0395b --- /dev/null +++ b/app/social/__init__.py @@ -0,0 +1,15 @@ +""" +Social Media Integration Module + +소셜 미디어 플랫폼 연동 및 영상 업로드 기능을 제공합니다. + +지원 플랫폼: +- YouTube (구현됨) +- Instagram (추후 구현) +- Facebook (추후 구현) +- TikTok (추후 구현) +""" + +from app.social.constants import SocialPlatform, UploadStatus + +__all__ = ["SocialPlatform", "UploadStatus"] diff --git a/app/social/api/__init__.py b/app/social/api/__init__.py new file mode 100644 index 0000000..98f88f3 --- /dev/null +++ b/app/social/api/__init__.py @@ -0,0 +1,3 @@ +""" +Social API Module +""" diff --git a/app/social/api/routers/__init__.py b/app/social/api/routers/__init__.py new file mode 100644 index 0000000..8757f3a --- /dev/null +++ b/app/social/api/routers/__init__.py @@ -0,0 +1,3 @@ +""" +Social API Routers +""" diff --git a/app/social/api/routers/v1/__init__.py b/app/social/api/routers/v1/__init__.py new file mode 100644 index 0000000..c960715 --- /dev/null +++ b/app/social/api/routers/v1/__init__.py @@ -0,0 +1,8 @@ +""" +Social API Routers v1 +""" + +from app.social.api.routers.v1.oauth import router as oauth_router +from app.social.api.routers.v1.upload import router as upload_router + +__all__ = ["oauth_router", "upload_router"] diff --git a/app/social/api/routers/v1/oauth.py b/app/social/api/routers/v1/oauth.py new file mode 100644 index 0000000..deb54a3 --- /dev/null +++ b/app/social/api/routers/v1/oauth.py @@ -0,0 +1,281 @@ +""" +소셜 OAuth API 라우터 + +소셜 미디어 계정 연동 관련 엔드포인트를 제공합니다. +""" + +import logging +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import RedirectResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from config import social_oauth_settings +from app.database.session import get_session +from app.social.constants import SocialPlatform +from app.social.schemas import ( + MessageResponse, + SocialAccountListResponse, + SocialAccountResponse, + SocialConnectResponse, +) +from app.social.services import social_account_service +from app.user.dependencies import get_current_user +from app.user.models import User + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/oauth", tags=["Social OAuth"]) + + +def _build_redirect_url(is_success: bool, params: dict) -> str: + """OAuth 리다이렉트 URL 생성""" + base_url = social_oauth_settings.OAUTH_FRONTEND_URL.rstrip("/") + path = ( + social_oauth_settings.OAUTH_SUCCESS_PATH + if is_success + else social_oauth_settings.OAUTH_ERROR_PATH + ) + return f"{base_url}{path}?{urlencode(params)}" + + +@router.get( + "/{platform}/connect", + response_model=SocialConnectResponse, + summary="소셜 계정 연동 시작", + description=""" +소셜 미디어 계정 연동을 시작합니다. + +## 지원 플랫폼 +- **youtube**: YouTube (Google OAuth) +- instagram, facebook, tiktok: 추후 지원 예정 + +## 플로우 +1. 이 엔드포인트를 호출하여 `auth_url`과 `state`를 받음 +2. 프론트엔드에서 `auth_url`로 사용자를 리다이렉트 +3. 사용자가 플랫폼에서 권한 승인 +4. 플랫폼이 `/callback` 엔드포인트로 리다이렉트 +5. 연동 완료 후 프론트엔드로 리다이렉트 +""", +) +async def start_connect( + platform: SocialPlatform, + current_user: User = Depends(get_current_user), +) -> SocialConnectResponse: + """ + 소셜 계정 연동 시작 + + OAuth 인증 URL을 생성하고 state 토큰을 반환합니다. + 프론트엔드에서 반환된 auth_url로 사용자를 리다이렉트하면 됩니다. + """ + logger.info( + f"[OAUTH_API] 소셜 연동 시작 - " + f"user_uuid: {current_user.user_uuid}, platform: {platform.value}" + ) + + return await social_account_service.start_connect( + user_uuid=current_user.user_uuid, + platform=platform, + ) + + +@router.get( + "/{platform}/callback", + summary="OAuth 콜백", + description=""" +소셜 플랫폼의 OAuth 콜백을 처리합니다. + +이 엔드포인트는 소셜 플랫폼에서 직접 호출되며, +사용자를 프론트엔드로 리다이렉트합니다. +""", +) +async def oauth_callback( + platform: SocialPlatform, + code: str | None = Query(None, description="OAuth 인가 코드"), + state: str | None = Query(None, description="CSRF 방지용 state 토큰"), + error: str | None = Query(None, description="OAuth 에러 코드 (사용자 취소 등)"), + error_description: str | None = Query(None, description="OAuth 에러 설명"), + session: AsyncSession = Depends(get_session), +) -> RedirectResponse: + """ + OAuth 콜백 처리 + + 소셜 플랫폼에서 리다이렉트된 후 호출됩니다. + 인가 코드로 토큰을 교환하고 계정을 연동합니다. + """ + # 사용자가 취소하거나 에러가 발생한 경우 + if error: + logger.info( + f"[OAUTH_API] OAuth 취소/에러 - " + f"platform: {platform.value}, error: {error}, description: {error_description}" + ) + + # 에러 메시지 생성 + if error == "access_denied": + error_message = "사용자가 연동을 취소했습니다." + else: + error_message = error_description or error + + redirect_url = _build_redirect_url( + is_success=False, + params={ + "platform": platform.value, + "error": error_message, + "cancelled": "true" if error == "access_denied" else "false", + }, + ) + return RedirectResponse(url=redirect_url, status_code=302) + + # code나 state가 없는 경우 + if not code or not state: + logger.warning( + f"[OAUTH_API] OAuth 콜백 파라미터 누락 - " + f"platform: {platform.value}, code: {bool(code)}, state: {bool(state)}" + ) + redirect_url = _build_redirect_url( + is_success=False, + params={ + "platform": platform.value, + "error": "잘못된 요청입니다. 다시 시도해주세요.", + }, + ) + return RedirectResponse(url=redirect_url, status_code=302) + + logger.info( + f"[OAUTH_API] OAuth 콜백 수신 - " + f"platform: {platform.value}, code: {code[:20]}..." + ) + + try: + account = await social_account_service.handle_callback( + code=code, + state=state, + session=session, + ) + + # 성공 시 프론트엔드로 리다이렉트 (계정 정보 포함) + redirect_url = _build_redirect_url( + is_success=True, + params={ + "platform": platform.value, + "account_id": account.id, + "channel_name": account.display_name or account.platform_username or "", + "profile_image": account.profile_image_url or "", + }, + ) + logger.info(f"[OAUTH_API] 연동 성공, 리다이렉트 - url: {redirect_url}") + return RedirectResponse(url=redirect_url, status_code=302) + + except Exception as e: + logger.error(f"[OAUTH_API] OAuth 콜백 처리 실패 - error: {e}") + # 실패 시 에러 페이지로 리다이렉트 + redirect_url = _build_redirect_url( + is_success=False, + params={ + "platform": platform.value, + "error": str(e), + }, + ) + return RedirectResponse(url=redirect_url, status_code=302) + + +@router.get( + "/accounts", + response_model=SocialAccountListResponse, + summary="연동된 소셜 계정 목록 조회", + description="현재 사용자가 연동한 모든 소셜 계정 목록을 반환합니다.", +) +async def get_connected_accounts( + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> SocialAccountListResponse: + """ + 연동된 소셜 계정 목록 조회 + + 현재 로그인한 사용자가 연동한 모든 소셜 계정을 조회합니다. + """ + logger.info(f"[OAUTH_API] 연동 계정 목록 조회 - user_uuid: {current_user.user_uuid}") + + accounts = await social_account_service.get_connected_accounts( + user_uuid=current_user.user_uuid, + session=session, + ) + + return SocialAccountListResponse( + accounts=accounts, + total=len(accounts), + ) + + +@router.get( + "/accounts/{platform}", + response_model=SocialAccountResponse, + summary="특정 플랫폼 연동 계정 조회", + description="특정 플랫폼에 연동된 계정 정보를 반환합니다.", +) +async def get_account_by_platform( + platform: SocialPlatform, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> SocialAccountResponse: + """ + 특정 플랫폼 연동 계정 조회 + """ + logger.info( + f"[OAUTH_API] 특정 플랫폼 계정 조회 - " + f"user_uuid: {current_user.user_uuid}, platform: {platform.value}" + ) + + account = await social_account_service.get_account_by_platform( + user_uuid=current_user.user_uuid, + platform=platform, + session=session, + ) + + if account is None: + from app.social.exceptions import SocialAccountNotFoundError + + raise SocialAccountNotFoundError(platform=platform.value) + + return social_account_service._to_response(account) + + +@router.delete( + "/{platform}/disconnect", + response_model=MessageResponse, + summary="소셜 계정 연동 해제", + description=""" +소셜 미디어 계정 연동을 해제합니다. + +연동 해제 시: +- 플랫폼에서 토큰이 폐기됩니다 +- 해당 플랫폼으로의 업로드가 불가능해집니다 +- 기존 업로드 기록은 유지됩니다 +""", +) +async def disconnect( + platform: SocialPlatform, + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> MessageResponse: + """ + 소셜 계정 연동 해제 + + 플랫폼 토큰을 폐기하고 연동을 해제합니다. + """ + logger.info( + f"[OAUTH_API] 소셜 연동 해제 - " + f"user_uuid: {current_user.user_uuid}, platform: {platform.value}" + ) + + await social_account_service.disconnect( + user_uuid=current_user.user_uuid, + platform=platform, + session=session, + ) + + return MessageResponse( + success=True, + message=f"{platform.value} 계정 연동이 해제되었습니다.", + ) diff --git a/app/social/api/routers/v1/upload.py b/app/social/api/routers/v1/upload.py new file mode 100644 index 0000000..b964b28 --- /dev/null +++ b/app/social/api/routers/v1/upload.py @@ -0,0 +1,404 @@ +""" +소셜 업로드 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 필요) + +## 지원 플랫폼 +- **youtube**: YouTube + +## 업로드 상태 +업로드는 백그라운드에서 처리되며, 상태를 폴링하여 확인할 수 있습니다: +- `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"platform: {body.platform.value}" + ) + + # 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. 소셜 계정 조회 + account = await social_account_service.get_account_by_platform( + user_uuid=current_user.user_uuid, + platform=body.platform, + session=session, + ) + + if not account: + logger.warning( + f"[UPLOAD_API] 연동 계정 없음 - " + f"user_uuid: {current_user.user_uuid}, platform: {body.platform.value}" + ) + raise SocialAccountNotFoundError(platform=body.platform.value) + + # 3. 기존 업로드 확인 (동일 video + account 조합) + existing_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] + ), + ) + ) + existing_upload = existing_result.scalar_one_or_none() + + if existing_upload: + logger.info( + f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {existing_upload.id}" + ) + return SocialUploadResponse( + success=True, + upload_id=existing_upload.id, + platform=body.platform.value, + status=existing_upload.status, + message="이미 업로드가 진행 중입니다.", + ) + + # 4. 새 업로드 레코드 생성 + social_upload = SocialUpload( + user_uuid=current_user.user_uuid, + video_id=body.video_id, + social_account_id=account.id, + platform=body.platform.value, + 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, + 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}" + ) + + # 5. 백그라운드 태스크 등록 + background_tasks.add_task(process_social_upload, social_upload.id) + + return SocialUploadResponse( + success=True, + upload_id=social_upload.id, + platform=body.platform.value, + 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, + 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, + 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="업로드가 취소되었습니다.", + ) diff --git a/app/social/constants.py b/app/social/constants.py new file mode 100644 index 0000000..2899c7e --- /dev/null +++ b/app/social/constants.py @@ -0,0 +1,123 @@ +""" +Social Media Constants + +소셜 미디어 플랫폼 관련 상수 및 Enum을 정의합니다. +""" + +from enum import Enum + + +class SocialPlatform(str, Enum): + """지원하는 소셜 미디어 플랫폼""" + + YOUTUBE = "youtube" + INSTAGRAM = "instagram" + FACEBOOK = "facebook" + TIKTOK = "tiktok" + + +class UploadStatus(str, Enum): + """업로드 상태""" + + PENDING = "pending" # 업로드 대기 중 + UPLOADING = "uploading" # 업로드 진행 중 + PROCESSING = "processing" # 플랫폼에서 처리 중 (인코딩 등) + COMPLETED = "completed" # 업로드 완료 + FAILED = "failed" # 업로드 실패 + CANCELLED = "cancelled" # 취소됨 + + +class PrivacyStatus(str, Enum): + """영상 공개 상태""" + + PUBLIC = "public" # 전체 공개 + UNLISTED = "unlisted" # 일부 공개 (링크 있는 사람만) + PRIVATE = "private" # 비공개 + + +# ============================================================================= +# 플랫폼별 설정 +# ============================================================================= + +PLATFORM_CONFIG = { + SocialPlatform.YOUTUBE: { + "name": "YouTube", + "display_name": "유튜브", + "max_file_size_mb": 256000, # 256GB + "supported_formats": ["mp4", "mov", "avi", "wmv", "flv", "3gp", "webm"], + "max_title_length": 100, + "max_description_length": 5000, + "max_tags": 500, + "supported_privacy": ["public", "unlisted", "private"], + "requires_channel": True, + }, + SocialPlatform.INSTAGRAM: { + "name": "Instagram", + "display_name": "인스타그램", + "max_file_size_mb": 4096, # 4GB (Reels) + "supported_formats": ["mp4", "mov"], + "max_duration_seconds": 90, # Reels 최대 90초 + "min_duration_seconds": 3, + "aspect_ratios": ["9:16", "1:1", "4:5"], + "max_caption_length": 2200, + "requires_business_account": True, + }, + SocialPlatform.FACEBOOK: { + "name": "Facebook", + "display_name": "페이스북", + "max_file_size_mb": 10240, # 10GB + "supported_formats": ["mp4", "mov"], + "max_duration_seconds": 14400, # 4시간 + "max_title_length": 255, + "max_description_length": 5000, + "requires_page": True, + }, + SocialPlatform.TIKTOK: { + "name": "TikTok", + "display_name": "틱톡", + "max_file_size_mb": 4096, # 4GB + "supported_formats": ["mp4", "mov", "webm"], + "max_duration_seconds": 600, # 10분 + "min_duration_seconds": 1, + "max_title_length": 150, + "requires_business_account": True, + }, +} + +# ============================================================================= +# YouTube OAuth Scopes +# ============================================================================= + +YOUTUBE_SCOPES = [ + "https://www.googleapis.com/auth/youtube.upload", # 영상 업로드 + "https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기 + "https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필 +] + +# ============================================================================= +# Instagram/Facebook OAuth Scopes (추후 구현) +# ============================================================================= + +# INSTAGRAM_SCOPES = [ +# "instagram_basic", +# "instagram_content_publish", +# "pages_read_engagement", +# "business_management", +# ] + +# FACEBOOK_SCOPES = [ +# "pages_manage_posts", +# "pages_read_engagement", +# "publish_video", +# "pages_show_list", +# ] + +# ============================================================================= +# TikTok OAuth Scopes (추후 구현) +# ============================================================================= + +# TIKTOK_SCOPES = [ +# "user.info.basic", +# "video.upload", +# "video.publish", +# ] diff --git a/app/social/exceptions.py b/app/social/exceptions.py new file mode 100644 index 0000000..f172711 --- /dev/null +++ b/app/social/exceptions.py @@ -0,0 +1,330 @@ +""" +Social Media Exceptions + +소셜 미디어 연동 관련 예외 클래스를 정의합니다. +""" + +from fastapi import status + + +class SocialException(Exception): + """소셜 미디어 기본 예외""" + + def __init__( + self, + message: str, + status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, + code: str = "SOCIAL_ERROR", + ): + self.message = message + self.status_code = status_code + self.code = code + super().__init__(self.message) + + +# ============================================================================= +# OAuth 관련 예외 +# ============================================================================= + + +class OAuthException(SocialException): + """OAuth 관련 예외 기본 클래스""" + + def __init__( + self, + message: str = "OAuth 인증 중 오류가 발생했습니다.", + status_code: int = status.HTTP_401_UNAUTHORIZED, + code: str = "OAUTH_ERROR", + ): + super().__init__(message, status_code, code) + + +class InvalidStateError(OAuthException): + """CSRF state 토큰 불일치""" + + def __init__(self, message: str = "유효하지 않은 인증 세션입니다. 다시 시도해주세요."): + super().__init__( + message=message, + status_code=status.HTTP_400_BAD_REQUEST, + code="INVALID_STATE", + ) + + +class OAuthStateExpiredError(OAuthException): + """OAuth state 토큰 만료""" + + def __init__(self, message: str = "인증 세션이 만료되었습니다. 다시 시도해주세요."): + super().__init__( + message=message, + status_code=status.HTTP_400_BAD_REQUEST, + code="STATE_EXPIRED", + ) + + +class OAuthTokenError(OAuthException): + """OAuth 토큰 교환 실패""" + + def __init__(self, platform: str, message: str = ""): + error_message = f"{platform} 토큰 발급에 실패했습니다." + if message: + error_message += f" ({message})" + super().__init__( + message=error_message, + status_code=status.HTTP_401_UNAUTHORIZED, + code="TOKEN_EXCHANGE_FAILED", + ) + + +class TokenRefreshError(OAuthException): + """토큰 갱신 실패""" + + def __init__(self, platform: str): + super().__init__( + message=f"{platform} 토큰 갱신에 실패했습니다. 재연동이 필요합니다.", + status_code=status.HTTP_401_UNAUTHORIZED, + code="TOKEN_REFRESH_FAILED", + ) + + +class OAuthCodeExchangeError(OAuthException): + """OAuth 인가 코드 교환 실패""" + + def __init__(self, platform: str, detail: str = ""): + error_message = f"{platform} 인가 코드 교환에 실패했습니다." + if detail: + error_message += f" ({detail})" + super().__init__( + message=error_message, + status_code=status.HTTP_401_UNAUTHORIZED, + code="CODE_EXCHANGE_FAILED", + ) + + +class OAuthTokenRefreshError(OAuthException): + """OAuth 토큰 갱신 실패""" + + def __init__(self, platform: str, detail: str = ""): + error_message = f"{platform} 토큰 갱신에 실패했습니다." + if detail: + error_message += f" ({detail})" + super().__init__( + message=error_message, + status_code=status.HTTP_401_UNAUTHORIZED, + code="TOKEN_REFRESH_FAILED", + ) + + +class TokenExpiredError(OAuthException): + """토큰 만료""" + + def __init__(self, platform: str): + super().__init__( + message=f"{platform} 인증이 만료되었습니다. 재연동이 필요합니다.", + status_code=status.HTTP_401_UNAUTHORIZED, + code="TOKEN_EXPIRED", + ) + + +# ============================================================================= +# 소셜 계정 관련 예외 +# ============================================================================= + + +class SocialAccountException(SocialException): + """소셜 계정 관련 예외 기본 클래스""" + + pass + + +class SocialAccountNotFoundError(SocialAccountException): + """연동된 계정을 찾을 수 없음""" + + def __init__(self, platform: str = ""): + message = f"{platform} 계정이 연동되어 있지 않습니다." if platform else "연동된 소셜 계정이 없습니다." + super().__init__( + message=message, + status_code=status.HTTP_404_NOT_FOUND, + code="SOCIAL_ACCOUNT_NOT_FOUND", + ) + + +class SocialAccountAlreadyExistsError(SocialAccountException): + """이미 연동된 계정이 존재함""" + + def __init__(self, platform: str): + super().__init__( + message=f"이미 {platform} 계정이 연동되어 있습니다.", + status_code=status.HTTP_409_CONFLICT, + code="SOCIAL_ACCOUNT_EXISTS", + ) + + +# Alias for backward compatibility +SocialAccountAlreadyConnectedError = SocialAccountAlreadyExistsError + + +class SocialAccountInactiveError(SocialAccountException): + """비활성화된 소셜 계정""" + + def __init__(self, platform: str): + super().__init__( + message=f"{platform} 계정이 비활성화 상태입니다. 재연동이 필요합니다.", + status_code=status.HTTP_403_FORBIDDEN, + code="SOCIAL_ACCOUNT_INACTIVE", + ) + + +class SocialAccountError(SocialAccountException): + """소셜 계정 일반 오류""" + + def __init__(self, platform: str, detail: str = ""): + error_message = f"{platform} 계정 처리 중 오류가 발생했습니다." + if detail: + error_message += f" ({detail})" + super().__init__( + message=error_message, + status_code=status.HTTP_400_BAD_REQUEST, + code="SOCIAL_ACCOUNT_ERROR", + ) + + +# ============================================================================= +# 업로드 관련 예외 +# ============================================================================= + + +class UploadException(SocialException): + """업로드 관련 예외 기본 클래스""" + + pass + + +class UploadError(UploadException): + """업로드 일반 오류""" + + def __init__(self, platform: str, detail: str = ""): + error_message = f"{platform} 업로드 중 오류가 발생했습니다." + if detail: + error_message += f" ({detail})" + super().__init__( + message=error_message, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code="UPLOAD_ERROR", + ) + + +class UploadValidationError(UploadException): + """업로드 유효성 검사 실패""" + + def __init__(self, message: str): + super().__init__( + message=message, + status_code=status.HTTP_400_BAD_REQUEST, + code="UPLOAD_VALIDATION_FAILED", + ) + + +class VideoNotFoundError(UploadException): + """영상을 찾을 수 없음""" + + def __init__(self, video_id: int, detail: str = ""): + message = f"영상을 찾을 수 없습니다. (video_id: {video_id})" + if detail: + message = detail + super().__init__( + message=message, + status_code=status.HTTP_404_NOT_FOUND, + code="VIDEO_NOT_FOUND", + ) + + +class VideoNotReadyError(UploadException): + """영상이 준비되지 않음""" + + def __init__(self, video_id: int): + super().__init__( + message=f"영상이 아직 준비되지 않았습니다. (video_id: {video_id})", + status_code=status.HTTP_400_BAD_REQUEST, + code="VIDEO_NOT_READY", + ) + + +class UploadFailedError(UploadException): + """업로드 실패""" + + def __init__(self, platform: str, message: str = ""): + error_message = f"{platform} 업로드에 실패했습니다." + if message: + error_message += f" ({message})" + super().__init__( + message=error_message, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + code="UPLOAD_FAILED", + ) + + +class UploadQuotaExceededError(UploadException): + """업로드 할당량 초과""" + + def __init__(self, platform: str): + super().__init__( + message=f"{platform} 일일 업로드 할당량이 초과되었습니다.", + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + code="UPLOAD_QUOTA_EXCEEDED", + ) + + +class UploadNotFoundError(UploadException): + """업로드 기록을 찾을 수 없음""" + + def __init__(self, upload_id: int): + super().__init__( + message=f"업로드 기록을 찾을 수 없습니다. (upload_id: {upload_id})", + status_code=status.HTTP_404_NOT_FOUND, + code="UPLOAD_NOT_FOUND", + ) + + +# ============================================================================= +# 플랫폼 API 관련 예외 +# ============================================================================= + + +class PlatformAPIError(SocialException): + """플랫폼 API 호출 오류""" + + def __init__(self, platform: str, message: str = ""): + error_message = f"{platform} API 호출 중 오류가 발생했습니다." + if message: + error_message += f" ({message})" + super().__init__( + message=error_message, + status_code=status.HTTP_502_BAD_GATEWAY, + code="PLATFORM_API_ERROR", + ) + + +class RateLimitError(PlatformAPIError): + """API 요청 한도 초과""" + + def __init__(self, platform: str, retry_after: int | None = None): + message = f"{platform} API 요청 한도가 초과되었습니다." + if retry_after: + message += f" {retry_after}초 후에 다시 시도해주세요." + super().__init__( + platform=platform, + message=message, + ) + self.retry_after = retry_after + self.code = "RATE_LIMIT_EXCEEDED" + + +class UnsupportedPlatformError(SocialException): + """지원하지 않는 플랫폼""" + + def __init__(self, platform: str): + super().__init__( + message=f"지원하지 않는 플랫폼입니다: {platform}", + status_code=status.HTTP_400_BAD_REQUEST, + code="UNSUPPORTED_PLATFORM", + ) diff --git a/app/social/models.py b/app/social/models.py new file mode 100644 index 0000000..81bd2e6 --- /dev/null +++ b/app/social/models.py @@ -0,0 +1,245 @@ +""" +Social Media Models + +소셜 미디어 업로드 관련 SQLAlchemy 모델을 정의합니다. +""" + +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func +from sqlalchemy.dialects.mysql import JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database.session import Base + +if TYPE_CHECKING: + from app.user.models import SocialAccount + from app.video.models import Video + + +class SocialUpload(Base): + """ + 소셜 미디어 업로드 기록 테이블 + + 영상의 소셜 미디어 플랫폼별 업로드 상태를 추적합니다. + + Attributes: + id: 고유 식별자 (자동 증가) + user_uuid: 사용자 UUID (User.user_uuid 참조) + video_id: Video 외래키 + social_account_id: SocialAccount 외래키 + platform: 플랫폼 구분 (youtube, instagram, facebook, tiktok) + status: 업로드 상태 (pending, uploading, processing, completed, failed) + upload_progress: 업로드 진행률 (0-100) + platform_video_id: 플랫폼에서 부여한 영상 ID + platform_url: 플랫폼에서의 영상 URL + title: 영상 제목 + description: 영상 설명 + tags: 태그 목록 (JSON) + privacy_status: 공개 상태 (public, unlisted, private) + platform_options: 플랫폼별 추가 옵션 (JSON) + error_message: 에러 메시지 (실패 시) + retry_count: 재시도 횟수 + uploaded_at: 업로드 완료 시간 + created_at: 생성 일시 + updated_at: 수정 일시 + + Relationships: + video: 연결된 Video + social_account: 연결된 SocialAccount + """ + + __tablename__ = "social_upload" + __table_args__ = ( + Index("idx_social_upload_user_uuid", "user_uuid"), + Index("idx_social_upload_video_id", "video_id"), + Index("idx_social_upload_social_account_id", "social_account_id"), + Index("idx_social_upload_platform", "platform"), + Index("idx_social_upload_status", "status"), + Index("idx_social_upload_created_at", "created_at"), + Index( + "uq_social_upload_video_platform", + "video_id", + "social_account_id", + unique=True, + ), + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + # ========================================================================== + # 기본 식별자 + # ========================================================================== + id: Mapped[int] = mapped_column( + BigInteger, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + # ========================================================================== + # 관계 필드 + # ========================================================================== + user_uuid: Mapped[str] = mapped_column( + String(36), + ForeignKey("user.user_uuid", ondelete="CASCADE"), + nullable=False, + comment="사용자 UUID (User.user_uuid 참조)", + ) + + video_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("video.id", ondelete="CASCADE"), + nullable=False, + comment="Video 외래키", + ) + + social_account_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("social_account.id", ondelete="CASCADE"), + nullable=False, + comment="SocialAccount 외래키", + ) + + # ========================================================================== + # 플랫폼 정보 + # ========================================================================== + platform: Mapped[str] = mapped_column( + String(20), + nullable=False, + comment="플랫폼 구분 (youtube, instagram, facebook, tiktok)", + ) + + # ========================================================================== + # 업로드 상태 + # ========================================================================== + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="pending", + comment="업로드 상태 (pending, uploading, processing, completed, failed)", + ) + + upload_progress: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="업로드 진행률 (0-100)", + ) + + # ========================================================================== + # 플랫폼 결과 + # ========================================================================== + platform_video_id: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + comment="플랫폼에서 부여한 영상 ID", + ) + + platform_url: Mapped[Optional[str]] = mapped_column( + String(500), + nullable=True, + comment="플랫폼에서의 영상 URL", + ) + + # ========================================================================== + # 메타데이터 + # ========================================================================== + title: Mapped[str] = mapped_column( + String(200), + nullable=False, + comment="영상 제목", + ) + + description: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="영상 설명", + ) + + tags: Mapped[Optional[dict]] = mapped_column( + JSON, + nullable=True, + comment="태그 목록 (JSON 배열)", + ) + + privacy_status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="private", + comment="공개 상태 (public, unlisted, private)", + ) + + platform_options: Mapped[Optional[dict]] = mapped_column( + JSON, + nullable=True, + comment="플랫폼별 추가 옵션 (JSON)", + ) + + # ========================================================================== + # 에러 정보 + # ========================================================================== + error_message: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="에러 메시지 (실패 시)", + ) + + retry_count: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + comment="재시도 횟수", + ) + + # ========================================================================== + # 시간 정보 + # ========================================================================== + uploaded_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, + nullable=True, + comment="업로드 완료 시간", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="생성 일시", + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + onupdate=func.now(), + comment="수정 일시", + ) + + # ========================================================================== + # Relationships + # ========================================================================== + video: Mapped["Video"] = relationship( + "Video", + lazy="selectin", + ) + + social_account: Mapped["SocialAccount"] = relationship( + "SocialAccount", + lazy="selectin", + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/app/social/oauth/__init__.py b/app/social/oauth/__init__.py new file mode 100644 index 0000000..992b347 --- /dev/null +++ b/app/social/oauth/__init__.py @@ -0,0 +1,46 @@ +""" +Social OAuth Module + +소셜 미디어 OAuth 클라이언트 모듈입니다. +""" + +from app.social.constants import SocialPlatform +from app.social.oauth.base import BaseOAuthClient + + +def get_oauth_client(platform: SocialPlatform) -> BaseOAuthClient: + """ + 플랫폼에 맞는 OAuth 클라이언트 반환 + + Args: + platform: 소셜 플랫폼 + + Returns: + BaseOAuthClient: OAuth 클라이언트 인스턴스 + + Raises: + ValueError: 지원하지 않는 플랫폼인 경우 + """ + if platform == SocialPlatform.YOUTUBE: + from app.social.oauth.youtube import YouTubeOAuthClient + + return YouTubeOAuthClient() + + # 추후 확장 + # elif platform == SocialPlatform.INSTAGRAM: + # from app.social.oauth.instagram import InstagramOAuthClient + # return InstagramOAuthClient() + # elif platform == SocialPlatform.FACEBOOK: + # from app.social.oauth.facebook import FacebookOAuthClient + # return FacebookOAuthClient() + # elif platform == SocialPlatform.TIKTOK: + # from app.social.oauth.tiktok import TikTokOAuthClient + # return TikTokOAuthClient() + + raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}") + + +__all__ = [ + "BaseOAuthClient", + "get_oauth_client", +] diff --git a/app/social/oauth/base.py b/app/social/oauth/base.py new file mode 100644 index 0000000..3472030 --- /dev/null +++ b/app/social/oauth/base.py @@ -0,0 +1,113 @@ +""" +Base OAuth Client + +소셜 미디어 OAuth 클라이언트의 추상 기본 클래스입니다. +""" + +from abc import ABC, abstractmethod +from typing import Optional + +from app.social.constants import SocialPlatform +from app.social.schemas import OAuthTokenResponse, PlatformUserInfo + + +class BaseOAuthClient(ABC): + """ + 소셜 미디어 OAuth 클라이언트 추상 기본 클래스 + + 모든 플랫폼별 OAuth 클라이언트는 이 클래스를 상속받아 구현합니다. + + Attributes: + platform: 소셜 플랫폼 종류 + """ + + platform: SocialPlatform + + @abstractmethod + def get_authorization_url(self, state: str) -> str: + """ + OAuth 인증 URL 생성 + + Args: + state: CSRF 방지용 state 토큰 + + Returns: + str: OAuth 인증 페이지 URL + """ + pass + + @abstractmethod + async def exchange_code(self, code: str) -> OAuthTokenResponse: + """ + 인가 코드로 액세스 토큰 교환 + + Args: + code: OAuth 인가 코드 + + Returns: + OAuthTokenResponse: 액세스 토큰 및 리프레시 토큰 + + Raises: + OAuthCodeExchangeError: 토큰 교환 실패 시 + """ + pass + + @abstractmethod + async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse: + """ + 리프레시 토큰으로 액세스 토큰 갱신 + + Args: + refresh_token: 리프레시 토큰 + + Returns: + OAuthTokenResponse: 새 액세스 토큰 + + Raises: + OAuthTokenRefreshError: 토큰 갱신 실패 시 + """ + pass + + @abstractmethod + async def get_user_info(self, access_token: str) -> PlatformUserInfo: + """ + 플랫폼 사용자 정보 조회 + + Args: + access_token: 액세스 토큰 + + Returns: + PlatformUserInfo: 플랫폼 사용자 정보 + + Raises: + SocialAccountError: 사용자 정보 조회 실패 시 + """ + pass + + @abstractmethod + async def revoke_token(self, token: str) -> bool: + """ + 토큰 폐기 (연동 해제 시) + + Args: + token: 폐기할 토큰 + + Returns: + bool: 폐기 성공 여부 + """ + pass + + def is_token_expired(self, expires_in: Optional[int]) -> bool: + """ + 토큰 만료 여부 확인 (만료 10분 전이면 True) + + Args: + expires_in: 토큰 만료까지 남은 시간(초) + + Returns: + bool: 갱신 필요 여부 + """ + if expires_in is None: + return False + # 만료 10분(600초) 전이면 갱신 필요 + return expires_in <= 600 diff --git a/app/social/oauth/youtube.py b/app/social/oauth/youtube.py new file mode 100644 index 0000000..9d8a53a --- /dev/null +++ b/app/social/oauth/youtube.py @@ -0,0 +1,326 @@ +""" +YouTube OAuth Client + +Google OAuth를 사용한 YouTube 인증 클라이언트입니다. +""" + +import logging +from urllib.parse import urlencode + +import httpx + +from config import social_oauth_settings +from app.social.constants import SocialPlatform, YOUTUBE_SCOPES +from app.social.exceptions import ( + OAuthCodeExchangeError, + OAuthTokenRefreshError, + SocialAccountError, +) +from app.social.oauth.base import BaseOAuthClient +from app.social.schemas import OAuthTokenResponse, PlatformUserInfo + +logger = logging.getLogger(__name__) + + +class YouTubeOAuthClient(BaseOAuthClient): + """ + YouTube OAuth 클라이언트 + + Google OAuth 2.0을 사용하여 YouTube 계정 인증을 처리합니다. + """ + + platform = SocialPlatform.YOUTUBE + + # Google OAuth 엔드포인트 + AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth" + TOKEN_URL = "https://oauth2.googleapis.com/token" + USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" + YOUTUBE_CHANNEL_URL = "https://www.googleapis.com/youtube/v3/channels" + REVOKE_URL = "https://oauth2.googleapis.com/revoke" + + def __init__(self) -> None: + self.client_id = social_oauth_settings.YOUTUBE_CLIENT_ID + self.client_secret = social_oauth_settings.YOUTUBE_CLIENT_SECRET + self.redirect_uri = social_oauth_settings.YOUTUBE_REDIRECT_URI + + def get_authorization_url(self, state: str) -> str: + """ + Google OAuth 인증 URL 생성 + + Args: + state: CSRF 방지용 state 토큰 + + Returns: + str: Google OAuth 인증 페이지 URL + """ + params = { + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "response_type": "code", + "scope": " ".join(YOUTUBE_SCOPES), + "access_type": "offline", # refresh_token 받기 위해 필요 + "prompt": "select_account", # 계정 선택만 표시 (이전 동의 유지) + "state": state, + } + url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}" + logger.debug(f"[YOUTUBE_OAUTH] 인증 URL 생성: {url[:100]}...") + return url + + async def exchange_code(self, code: str) -> OAuthTokenResponse: + """ + 인가 코드로 액세스 토큰 교환 + + Args: + code: OAuth 인가 코드 + + Returns: + OAuthTokenResponse: 액세스 토큰 및 리프레시 토큰 + + Raises: + OAuthCodeExchangeError: 토큰 교환 실패 시 + """ + logger.info(f"[YOUTUBE_OAUTH] 토큰 교환 시작 - code: {code[:20]}...") + + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": self.redirect_uri, + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + self.TOKEN_URL, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + token_data = response.json() + + logger.info("[YOUTUBE_OAUTH] 토큰 교환 성공") + logger.debug( + f"[YOUTUBE_OAUTH] 토큰 정보 - " + f"expires_in: {token_data.get('expires_in')}, " + f"scope: {token_data.get('scope')}" + ) + + return OAuthTokenResponse( + access_token=token_data["access_token"], + refresh_token=token_data.get("refresh_token"), + expires_in=token_data["expires_in"], + token_type=token_data.get("token_type", "Bearer"), + scope=token_data.get("scope"), + ) + + except httpx.HTTPStatusError as e: + error_detail = e.response.text if e.response else str(e) + logger.error( + f"[YOUTUBE_OAUTH] 토큰 교환 실패 - " + f"status: {e.response.status_code}, error: {error_detail}" + ) + raise OAuthCodeExchangeError( + platform=self.platform.value, + detail=f"토큰 교환 실패: {error_detail}", + ) + except Exception as e: + logger.error(f"[YOUTUBE_OAUTH] 토큰 교환 중 예외 발생: {e}") + raise OAuthCodeExchangeError( + platform=self.platform.value, + detail=str(e), + ) + + async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse: + """ + 리프레시 토큰으로 액세스 토큰 갱신 + + Args: + refresh_token: 리프레시 토큰 + + Returns: + OAuthTokenResponse: 새 액세스 토큰 + + Raises: + OAuthTokenRefreshError: 토큰 갱신 실패 시 + """ + logger.info("[YOUTUBE_OAUTH] 토큰 갱신 시작") + + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + self.TOKEN_URL, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + response.raise_for_status() + token_data = response.json() + + logger.info("[YOUTUBE_OAUTH] 토큰 갱신 성공") + + return OAuthTokenResponse( + access_token=token_data["access_token"], + refresh_token=refresh_token, # Google은 refresh_token 재발급 안함 + expires_in=token_data["expires_in"], + token_type=token_data.get("token_type", "Bearer"), + scope=token_data.get("scope"), + ) + + except httpx.HTTPStatusError as e: + error_detail = e.response.text if e.response else str(e) + logger.error( + f"[YOUTUBE_OAUTH] 토큰 갱신 실패 - " + f"status: {e.response.status_code}, error: {error_detail}" + ) + raise OAuthTokenRefreshError( + platform=self.platform.value, + detail=f"토큰 갱신 실패: {error_detail}", + ) + except Exception as e: + logger.error(f"[YOUTUBE_OAUTH] 토큰 갱신 중 예외 발생: {e}") + raise OAuthTokenRefreshError( + platform=self.platform.value, + detail=str(e), + ) + + async def get_user_info(self, access_token: str) -> PlatformUserInfo: + """ + YouTube 채널 정보 조회 + + Args: + access_token: 액세스 토큰 + + Returns: + PlatformUserInfo: YouTube 채널 정보 + + Raises: + SocialAccountError: 정보 조회 실패 시 + """ + logger.info("[YOUTUBE_OAUTH] 사용자/채널 정보 조회 시작") + + headers = {"Authorization": f"Bearer {access_token}"} + + async with httpx.AsyncClient() as client: + try: + # 1. Google 사용자 기본 정보 조회 + userinfo_response = await client.get( + self.USERINFO_URL, + headers=headers, + ) + userinfo_response.raise_for_status() + userinfo = userinfo_response.json() + + # 2. YouTube 채널 정보 조회 + channel_params = { + "part": "snippet,statistics", + "mine": "true", + } + channel_response = await client.get( + self.YOUTUBE_CHANNEL_URL, + headers=headers, + params=channel_params, + ) + channel_response.raise_for_status() + channel_data = channel_response.json() + + # 채널이 없는 경우 + if not channel_data.get("items"): + logger.warning("[YOUTUBE_OAUTH] YouTube 채널 없음") + raise SocialAccountError( + platform=self.platform.value, + detail="YouTube 채널이 없습니다. 채널을 먼저 생성해주세요.", + ) + + channel = channel_data["items"][0] + snippet = channel.get("snippet", {}) + statistics = channel.get("statistics", {}) + + logger.info( + f"[YOUTUBE_OAUTH] 채널 정보 조회 성공 - " + f"channel_id: {channel['id']}, " + f"title: {snippet.get('title')}" + ) + + return PlatformUserInfo( + platform_user_id=channel["id"], + username=snippet.get("customUrl"), # @username 형태 + display_name=snippet.get("title"), + profile_image_url=snippet.get("thumbnails", {}) + .get("default", {}) + .get("url"), + platform_data={ + "channel_id": channel["id"], + "channel_title": snippet.get("title"), + "channel_description": snippet.get("description"), + "custom_url": snippet.get("customUrl"), + "subscriber_count": statistics.get("subscriberCount"), + "video_count": statistics.get("videoCount"), + "view_count": statistics.get("viewCount"), + "google_user_id": userinfo.get("id"), + "google_email": userinfo.get("email"), + }, + ) + + except httpx.HTTPStatusError as e: + error_detail = e.response.text if e.response else str(e) + logger.error( + f"[YOUTUBE_OAUTH] 정보 조회 실패 - " + f"status: {e.response.status_code}, error: {error_detail}" + ) + raise SocialAccountError( + platform=self.platform.value, + detail=f"사용자 정보 조회 실패: {error_detail}", + ) + except SocialAccountError: + raise + except Exception as e: + logger.error(f"[YOUTUBE_OAUTH] 정보 조회 중 예외 발생: {e}") + raise SocialAccountError( + platform=self.platform.value, + detail=str(e), + ) + + async def revoke_token(self, token: str) -> bool: + """ + 토큰 폐기 (연동 해제 시) + + Args: + token: 폐기할 토큰 (access_token 또는 refresh_token) + + Returns: + bool: 폐기 성공 여부 + """ + logger.info("[YOUTUBE_OAUTH] 토큰 폐기 시작") + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + self.REVOKE_URL, + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if response.status_code == 200: + logger.info("[YOUTUBE_OAUTH] 토큰 폐기 성공") + return True + else: + logger.warning( + f"[YOUTUBE_OAUTH] 토큰 폐기 실패 - " + f"status: {response.status_code}, body: {response.text}" + ) + return False + + except Exception as e: + logger.error(f"[YOUTUBE_OAUTH] 토큰 폐기 중 예외 발생: {e}") + return False + + +# 싱글톤 인스턴스 +youtube_oauth_client = YouTubeOAuthClient() diff --git a/app/social/schemas.py b/app/social/schemas.py new file mode 100644 index 0000000..66cf006 --- /dev/null +++ b/app/social/schemas.py @@ -0,0 +1,288 @@ +""" +Social Media Schemas + +소셜 미디어 연동 관련 Pydantic 스키마를 정의합니다. +""" + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from app.social.constants import PrivacyStatus, SocialPlatform, UploadStatus + + +# ============================================================================= +# OAuth 관련 스키마 +# ============================================================================= + + +class SocialConnectResponse(BaseModel): + """소셜 계정 연동 시작 응답""" + + auth_url: str = Field(..., description="OAuth 인증 URL") + state: str = Field(..., description="CSRF 방지용 state 토큰") + platform: str = Field(..., description="플랫폼명") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...", + "state": "abc123xyz", + "platform": "youtube", + } + } + ) + + +class SocialAccountResponse(BaseModel): + """연동된 소셜 계정 정보""" + + id: int = Field(..., description="소셜 계정 ID") + platform: str = Field(..., description="플랫폼명") + platform_user_id: str = Field(..., description="플랫폼 내 사용자 ID") + platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명") + display_name: Optional[str] = Field(None, description="표시 이름") + profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL") + is_active: bool = Field(..., description="활성화 상태") + connected_at: datetime = Field(..., description="연동 일시") + platform_data: Optional[dict[str, Any]] = Field( + None, description="플랫폼별 추가 정보 (채널ID, 구독자 수 등)" + ) + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "id": 1, + "platform": "youtube", + "platform_user_id": "UC1234567890", + "platform_username": "my_channel", + "display_name": "My Channel", + "profile_image_url": "https://...", + "is_active": True, + "connected_at": "2024-01-15T12:00:00", + "platform_data": { + "channel_id": "UC1234567890", + "channel_title": "My Channel", + "subscriber_count": 1000, + }, + } + } + ) + + +class SocialAccountListResponse(BaseModel): + """연동된 소셜 계정 목록 응답""" + + accounts: list[SocialAccountResponse] = Field(..., description="연동 계정 목록") + total: int = Field(..., description="총 연동 계정 수") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "accounts": [ + { + "id": 1, + "platform": "youtube", + "platform_user_id": "UC1234567890", + "platform_username": "my_channel", + "display_name": "My Channel", + "is_active": True, + "connected_at": "2024-01-15T12:00:00", + } + ], + "total": 1, + } + } + ) + + +# ============================================================================= +# 내부 사용 스키마 (OAuth 토큰 응답) +# ============================================================================= + + +class OAuthTokenResponse(BaseModel): + """OAuth 토큰 응답 (내부 사용)""" + + access_token: str + refresh_token: Optional[str] = None + expires_in: int + token_type: str = "Bearer" + scope: Optional[str] = None + + +class PlatformUserInfo(BaseModel): + """플랫폼 사용자 정보 (내부 사용)""" + + platform_user_id: str + username: Optional[str] = None + display_name: Optional[str] = None + profile_image_url: Optional[str] = None + platform_data: dict[str, Any] = Field(default_factory=dict) + + +# ============================================================================= +# 업로드 관련 스키마 +# ============================================================================= + + +class SocialUploadRequest(BaseModel): + """소셜 업로드 요청""" + + video_id: int = Field(..., description="업로드할 영상 ID") + platform: SocialPlatform = Field(..., description="업로드할 플랫폼") + title: str = Field(..., min_length=1, max_length=100, description="영상 제목") + description: Optional[str] = Field( + None, max_length=5000, description="영상 설명" + ) + tags: Optional[list[str]] = Field(None, description="태그 목록") + privacy_status: PrivacyStatus = Field( + default=PrivacyStatus.PRIVATE, description="공개 상태" + ) + platform_options: Optional[dict[str, Any]] = Field( + None, description="플랫폼별 추가 옵션" + ) + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "video_id": 123, + "platform": "youtube", + "title": "나의 첫 영상", + "description": "영상 설명입니다.", + "tags": ["여행", "vlog"], + "privacy_status": "private", + "platform_options": { + "category_id": "22", # YouTube 카테고리 + }, + } + } + ) + + +class SocialUploadResponse(BaseModel): + """소셜 업로드 요청 응답""" + + success: bool = Field(..., description="요청 성공 여부") + upload_id: int = Field(..., description="업로드 작업 ID") + platform: str = Field(..., description="플랫폼명") + status: str = Field(..., description="업로드 상태") + message: str = Field(..., description="응답 메시지") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "success": True, + "upload_id": 456, + "platform": "youtube", + "status": "pending", + "message": "업로드 요청이 접수되었습니다.", + } + } + ) + + +class SocialUploadStatusResponse(BaseModel): + """업로드 상태 조회 응답""" + + upload_id: int = Field(..., description="업로드 작업 ID") + video_id: int = Field(..., description="영상 ID") + platform: str = Field(..., description="플랫폼명") + status: UploadStatus = Field(..., description="업로드 상태") + upload_progress: int = Field(..., description="업로드 진행률 (0-100)") + title: str = Field(..., description="영상 제목") + platform_video_id: Optional[str] = Field(None, description="플랫폼 영상 ID") + platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL") + error_message: Optional[str] = Field(None, description="에러 메시지") + retry_count: int = Field(default=0, description="재시도 횟수") + created_at: datetime = Field(..., description="생성 일시") + uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시") + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "example": { + "upload_id": 456, + "video_id": 123, + "platform": "youtube", + "status": "completed", + "upload_progress": 100, + "title": "나의 첫 영상", + "platform_video_id": "dQw4w9WgXcQ", + "platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "error_message": None, + "retry_count": 0, + "created_at": "2024-01-15T12:00:00", + "uploaded_at": "2024-01-15T12:05:00", + } + } + ) + + +class SocialUploadHistoryItem(BaseModel): + """업로드 이력 아이템""" + + upload_id: int = Field(..., description="업로드 작업 ID") + video_id: int = Field(..., description="영상 ID") + platform: str = Field(..., description="플랫폼명") + status: str = Field(..., description="업로드 상태") + title: str = Field(..., description="영상 제목") + platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL") + created_at: datetime = Field(..., description="생성 일시") + uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시") + + model_config = ConfigDict(from_attributes=True) + + +class SocialUploadHistoryResponse(BaseModel): + """업로드 이력 목록 응답""" + + items: list[SocialUploadHistoryItem] = Field(..., description="업로드 이력 목록") + total: int = Field(..., description="전체 개수") + page: int = Field(..., description="현재 페이지") + size: int = Field(..., description="페이지 크기") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "items": [ + { + "upload_id": 456, + "video_id": 123, + "platform": "youtube", + "status": "completed", + "title": "나의 첫 영상", + "platform_url": "https://www.youtube.com/watch?v=xxx", + "created_at": "2024-01-15T12:00:00", + "uploaded_at": "2024-01-15T12:05:00", + } + ], + "total": 1, + "page": 1, + "size": 20, + } + } + ) + + +# ============================================================================= +# 공통 응답 스키마 +# ============================================================================= + + +class MessageResponse(BaseModel): + """단순 메시지 응답""" + + success: bool = Field(..., description="성공 여부") + message: str = Field(..., description="응답 메시지") + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "success": True, + "message": "작업이 완료되었습니다.", + } + } + ) diff --git a/app/social/services.py b/app/social/services.py new file mode 100644 index 0000000..e640183 --- /dev/null +++ b/app/social/services.py @@ -0,0 +1,529 @@ +""" +Social Account Service + +소셜 계정 연동 관련 비즈니스 로직을 처리합니다. +""" + +import logging +import secrets +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from redis.asyncio import Redis + +from config import social_oauth_settings, db_settings +from app.social.constants import SocialPlatform + +# Social OAuth용 Redis 클라이언트 (DB 2 사용) +redis_client = Redis( + host=db_settings.REDIS_HOST, + port=db_settings.REDIS_PORT, + db=2, + decode_responses=True, +) +from app.social.exceptions import ( + InvalidStateError, + OAuthStateExpiredError, + SocialAccountAlreadyConnectedError, + SocialAccountNotFoundError, +) +from app.social.oauth import get_oauth_client +from app.social.schemas import ( + OAuthTokenResponse, + PlatformUserInfo, + SocialAccountResponse, + SocialConnectResponse, +) +from app.user.models import SocialAccount + +logger = logging.getLogger(__name__) + + +class SocialAccountService: + """ + 소셜 계정 연동 서비스 + + OAuth 인증, 계정 연동/해제, 토큰 관리 기능을 제공합니다. + """ + + # Redis key prefix for OAuth state + STATE_KEY_PREFIX = "social:oauth:state:" + + async def start_connect( + self, + user_uuid: str, + platform: SocialPlatform, + ) -> SocialConnectResponse: + """ + 소셜 계정 연동 시작 + + OAuth 인증 URL을 생성하고 state 토큰을 저장합니다. + + Args: + user_uuid: 사용자 UUID + platform: 연동할 플랫폼 + + Returns: + SocialConnectResponse: OAuth 인증 URL 및 state 토큰 + """ + logger.info( + f"[SOCIAL] 소셜 계정 연동 시작 - " + f"user_uuid: {user_uuid}, platform: {platform.value}" + ) + + # 1. state 토큰 생성 (CSRF 방지) + state = secrets.token_urlsafe(32) + + # 2. state를 Redis에 저장 (user_uuid 포함) + state_key = f"{self.STATE_KEY_PREFIX}{state}" + state_data = { + "user_uuid": user_uuid, + "platform": platform.value, + } + await redis_client.setex( + state_key, + social_oauth_settings.OAUTH_STATE_TTL_SECONDS, + str(state_data), + ) + logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}") + + # 3. OAuth 클라이언트에서 인증 URL 생성 + oauth_client = get_oauth_client(platform) + auth_url = oauth_client.get_authorization_url(state) + + logger.info(f"[SOCIAL] OAuth URL 생성 완료 - platform: {platform.value}") + + return SocialConnectResponse( + auth_url=auth_url, + state=state, + platform=platform.value, + ) + + async def handle_callback( + self, + code: str, + state: str, + session: AsyncSession, + ) -> SocialAccountResponse: + """ + OAuth 콜백 처리 + + 인가 코드로 토큰을 교환하고 소셜 계정을 저장합니다. + + Args: + code: OAuth 인가 코드 + state: CSRF 방지용 state 토큰 + session: DB 세션 + + Returns: + SocialAccountResponse: 연동된 소셜 계정 정보 + + Raises: + InvalidStateError: state 토큰이 유효하지 않은 경우 + OAuthStateExpiredError: state 토큰이 만료된 경우 + SocialAccountAlreadyConnectedError: 이미 연동된 계정인 경우 + """ + logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...") + + # 1. state 검증 및 사용자 정보 추출 + state_key = f"{self.STATE_KEY_PREFIX}{state}" + state_data_str = await redis_client.get(state_key) + + if state_data_str is None: + logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...") + raise OAuthStateExpiredError() + + # state 데이터 파싱 + state_data = eval(state_data_str) # {"user_uuid": "...", "platform": "..."} + user_uuid = state_data["user_uuid"] + platform = SocialPlatform(state_data["platform"]) + + # state 삭제 (일회성) + await redis_client.delete(state_key) + logger.debug(f"[SOCIAL] state 토큰 사용 완료 및 삭제 - user_uuid: {user_uuid}") + + # 2. OAuth 클라이언트로 토큰 교환 + oauth_client = get_oauth_client(platform) + token_response = await oauth_client.exchange_code(code) + + # 3. 플랫폼 사용자 정보 조회 + user_info = await oauth_client.get_user_info(token_response.access_token) + + # 4. 기존 연동 확인 (소프트 삭제된 계정 포함) + existing_account = await self._get_social_account( + user_uuid=user_uuid, + platform=platform, + platform_user_id=user_info.platform_user_id, + session=session, + ) + + if existing_account: + # 기존 계정 존재 (활성화 또는 비활성화 상태) + is_reactivation = False + if existing_account.is_active and not existing_account.is_deleted: + # 이미 활성화된 계정 - 토큰만 갱신 + logger.info( + f"[SOCIAL] 기존 활성 계정 토큰 갱신 - " + f"account_id: {existing_account.id}" + ) + else: + # 비활성화(소프트 삭제)된 계정 - 재활성화 + logger.info( + f"[SOCIAL] 비활성 계정 재활성화 - " + f"account_id: {existing_account.id}" + ) + existing_account.is_active = True + existing_account.is_deleted = False + is_reactivation = True + + # 토큰 및 정보 업데이트 + existing_account = await self._update_tokens( + account=existing_account, + token_response=token_response, + user_info=user_info, + session=session, + update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트 + ) + return self._to_response(existing_account) + + # 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만) + social_account = await self._create_social_account( + user_uuid=user_uuid, + platform=platform, + token_response=token_response, + user_info=user_info, + session=session, + ) + + logger.info( + f"[SOCIAL] 소셜 계정 연동 완료 - " + f"account_id: {social_account.id}, platform: {platform.value}" + ) + + return self._to_response(social_account) + + async def get_connected_accounts( + self, + user_uuid: str, + session: AsyncSession, + ) -> list[SocialAccountResponse]: + """ + 연동된 소셜 계정 목록 조회 + + Args: + user_uuid: 사용자 UUID + session: DB 세션 + + Returns: + list[SocialAccountResponse]: 연동된 계정 목록 + """ + logger.info(f"[SOCIAL] 연동 계정 목록 조회 - user_uuid: {user_uuid}") + + result = await session.execute( + select(SocialAccount).where( + SocialAccount.user_uuid == user_uuid, + SocialAccount.is_active == True, # noqa: E712 + SocialAccount.is_deleted == False, # noqa: E712 + ) + ) + accounts = result.scalars().all() + + logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨") + + return [self._to_response(account) for account in accounts] + + async def get_account_by_platform( + self, + user_uuid: str, + platform: SocialPlatform, + session: AsyncSession, + ) -> Optional[SocialAccount]: + """ + 특정 플랫폼의 연동 계정 조회 + + Args: + user_uuid: 사용자 UUID + platform: 플랫폼 + session: DB 세션 + + Returns: + SocialAccount: 소셜 계정 (없으면 None) + """ + result = await session.execute( + select(SocialAccount).where( + SocialAccount.user_uuid == user_uuid, + SocialAccount.platform == platform.value, + SocialAccount.is_active == True, # noqa: E712 + SocialAccount.is_deleted == False, # noqa: E712 + ) + ) + return result.scalar_one_or_none() + + async def disconnect( + self, + user_uuid: str, + platform: SocialPlatform, + session: AsyncSession, + ) -> bool: + """ + 소셜 계정 연동 해제 + + Args: + user_uuid: 사용자 UUID + platform: 연동 해제할 플랫폼 + session: DB 세션 + + Returns: + bool: 성공 여부 + + Raises: + SocialAccountNotFoundError: 연동된 계정이 없는 경우 + """ + logger.info( + f"[SOCIAL] 소셜 계정 연동 해제 시작 - " + f"user_uuid: {user_uuid}, platform: {platform.value}" + ) + + # 1. 연동된 계정 조회 + account = await self.get_account_by_platform(user_uuid, platform, session) + + if account is None: + logger.warning( + f"[SOCIAL] 연동된 계정 없음 - " + f"user_uuid: {user_uuid}, platform: {platform.value}" + ) + raise SocialAccountNotFoundError(platform=platform.value) + + # 2. 소프트 삭제 (토큰 폐기하지 않음 - 재연결 시 동의 화면 스킵을 위해) + # 참고: 사용자가 완전히 앱 연결을 끊으려면 Google 계정 설정에서 직접 해제해야 함 + account.is_active = False + account.is_deleted = True + await session.commit() + + logger.info(f"[SOCIAL] 소셜 계정 연동 해제 완료 - account_id: {account.id}") + return True + + async def ensure_valid_token( + self, + account: SocialAccount, + session: AsyncSession, + ) -> str: + """ + 토큰 유효성 확인 및 필요시 갱신 + + Args: + account: 소셜 계정 + session: DB 세션 + + Returns: + str: 유효한 access_token + """ + # 만료 시간 확인 (만료 10분 전이면 갱신) + if account.token_expires_at: + buffer_time = datetime.now() + timedelta(minutes=10) + if account.token_expires_at <= buffer_time: + logger.info( + f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" + ) + return await self._refresh_account_token(account, session) + + return account.access_token + + async def _refresh_account_token( + self, + account: SocialAccount, + session: AsyncSession, + ) -> str: + """ + 계정 토큰 갱신 + + Args: + account: 소셜 계정 + session: DB 세션 + + Returns: + str: 새 access_token + """ + if not account.refresh_token: + logger.warning( + f"[SOCIAL] refresh_token 없음, 갱신 불가 - account_id: {account.id}" + ) + return account.access_token + + platform = SocialPlatform(account.platform) + oauth_client = get_oauth_client(platform) + + token_response = await oauth_client.refresh_token(account.refresh_token) + + # 토큰 업데이트 + account.access_token = token_response.access_token + if token_response.refresh_token: + account.refresh_token = token_response.refresh_token + if token_response.expires_in: + account.token_expires_at = datetime.now() + timedelta( + seconds=token_response.expires_in + ) + + await session.commit() + + logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}") + return account.access_token + + async def _get_social_account( + self, + user_uuid: str, + platform: SocialPlatform, + platform_user_id: str, + session: AsyncSession, + ) -> Optional[SocialAccount]: + """ + 소셜 계정 조회 (platform_user_id 포함) + + Args: + user_uuid: 사용자 UUID + platform: 플랫폼 + platform_user_id: 플랫폼 사용자 ID + session: DB 세션 + + Returns: + SocialAccount: 소셜 계정 (없으면 None) + """ + result = await session.execute( + select(SocialAccount).where( + SocialAccount.user_uuid == user_uuid, + SocialAccount.platform == platform.value, + SocialAccount.platform_user_id == platform_user_id, + ) + ) + return result.scalar_one_or_none() + + async def _create_social_account( + self, + user_uuid: str, + platform: SocialPlatform, + token_response: OAuthTokenResponse, + user_info: PlatformUserInfo, + session: AsyncSession, + ) -> SocialAccount: + """ + 새 소셜 계정 생성 + + Args: + user_uuid: 사용자 UUID + platform: 플랫폼 + token_response: OAuth 토큰 응답 + user_info: 플랫폼 사용자 정보 + session: DB 세션 + + Returns: + SocialAccount: 생성된 소셜 계정 + """ + # 토큰 만료 시간 계산 + token_expires_at = None + if token_response.expires_in: + token_expires_at = datetime.now() + timedelta( + seconds=token_response.expires_in + ) + + social_account = SocialAccount( + user_uuid=user_uuid, + platform=platform.value, + access_token=token_response.access_token, + refresh_token=token_response.refresh_token, + token_expires_at=token_expires_at, + scope=token_response.scope, + platform_user_id=user_info.platform_user_id, + platform_username=user_info.username, + platform_data={ + "display_name": user_info.display_name, + "profile_image_url": user_info.profile_image_url, + **user_info.platform_data, + }, + is_active=True, + is_deleted=False, + ) + + session.add(social_account) + await session.commit() + await session.refresh(social_account) + + return social_account + + async def _update_tokens( + self, + account: SocialAccount, + token_response: OAuthTokenResponse, + user_info: PlatformUserInfo, + session: AsyncSession, + update_connected_at: bool = False, + ) -> SocialAccount: + """ + 기존 계정 토큰 업데이트 + + Args: + account: 기존 소셜 계정 + token_response: 새 OAuth 토큰 응답 + user_info: 플랫폼 사용자 정보 + session: DB 세션 + update_connected_at: 연결 시간 업데이트 여부 (재연결 시 True) + + Returns: + SocialAccount: 업데이트된 소셜 계정 + """ + account.access_token = token_response.access_token + if token_response.refresh_token: + account.refresh_token = token_response.refresh_token + if token_response.expires_in: + account.token_expires_at = datetime.now() + timedelta( + seconds=token_response.expires_in + ) + if token_response.scope: + account.scope = token_response.scope + + # 플랫폼 정보 업데이트 + account.platform_username = user_info.username + account.platform_data = { + "display_name": user_info.display_name, + "profile_image_url": user_info.profile_image_url, + **user_info.platform_data, + } + + # 재연결 시 연결 시간 업데이트 + if update_connected_at: + account.connected_at = datetime.now() + + await session.commit() + await session.refresh(account) + + return account + + def _to_response(self, account: SocialAccount) -> SocialAccountResponse: + """ + SocialAccount를 SocialAccountResponse로 변환 + + Args: + account: 소셜 계정 + + Returns: + SocialAccountResponse: 응답 스키마 + """ + platform_data = account.platform_data or {} + + return SocialAccountResponse( + id=account.id, + platform=account.platform, + platform_user_id=account.platform_user_id, + platform_username=account.platform_username, + display_name=platform_data.get("display_name"), + profile_image_url=platform_data.get("profile_image_url"), + is_active=account.is_active, + connected_at=account.connected_at, + platform_data=platform_data, + ) + + +# 싱글톤 인스턴스 +social_account_service = SocialAccountService() diff --git a/app/social/uploader/__init__.py b/app/social/uploader/__init__.py new file mode 100644 index 0000000..306555d --- /dev/null +++ b/app/social/uploader/__init__.py @@ -0,0 +1,47 @@ +""" +Social Uploader Module + +소셜 미디어 영상 업로더 모듈입니다. +""" + +from app.social.constants import SocialPlatform +from app.social.uploader.base import BaseSocialUploader, UploadResult + + +def get_uploader(platform: SocialPlatform) -> BaseSocialUploader: + """ + 플랫폼에 맞는 업로더 반환 + + Args: + platform: 소셜 플랫폼 + + Returns: + BaseSocialUploader: 업로더 인스턴스 + + Raises: + ValueError: 지원하지 않는 플랫폼인 경우 + """ + if platform == SocialPlatform.YOUTUBE: + from app.social.uploader.youtube import YouTubeUploader + + return YouTubeUploader() + + # 추후 확장 + # elif platform == SocialPlatform.INSTAGRAM: + # from app.social.uploader.instagram import InstagramUploader + # return InstagramUploader() + # elif platform == SocialPlatform.FACEBOOK: + # from app.social.uploader.facebook import FacebookUploader + # return FacebookUploader() + # elif platform == SocialPlatform.TIKTOK: + # from app.social.uploader.tiktok import TikTokUploader + # return TikTokUploader() + + raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}") + + +__all__ = [ + "BaseSocialUploader", + "UploadResult", + "get_uploader", +] diff --git a/app/social/uploader/base.py b/app/social/uploader/base.py new file mode 100644 index 0000000..eacb43e --- /dev/null +++ b/app/social/uploader/base.py @@ -0,0 +1,168 @@ +""" +Base Social Uploader + +소셜 미디어 영상 업로더의 추상 기본 클래스입니다. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Callable, Optional + +from app.social.constants import PrivacyStatus, SocialPlatform + + +@dataclass +class UploadMetadata: + """ + 업로드 메타데이터 + + 영상 업로드 시 필요한 메타데이터를 정의합니다. + + Attributes: + title: 영상 제목 + description: 영상 설명 + tags: 태그 목록 + privacy_status: 공개 상태 + platform_options: 플랫폼별 추가 옵션 + """ + + title: str + description: Optional[str] = None + tags: Optional[list[str]] = None + privacy_status: PrivacyStatus = PrivacyStatus.PRIVATE + platform_options: Optional[dict[str, Any]] = None + + +@dataclass +class UploadResult: + """ + 업로드 결과 + + Attributes: + success: 성공 여부 + platform_video_id: 플랫폼에서 부여한 영상 ID + platform_url: 플랫폼에서의 영상 URL + error_message: 에러 메시지 (실패 시) + platform_response: 플랫폼 원본 응답 (디버깅용) + """ + + success: bool + platform_video_id: Optional[str] = None + platform_url: Optional[str] = None + error_message: Optional[str] = None + platform_response: Optional[dict[str, Any]] = None + + +class BaseSocialUploader(ABC): + """ + 소셜 미디어 영상 업로더 추상 기본 클래스 + + 모든 플랫폼별 업로더는 이 클래스를 상속받아 구현합니다. + + Attributes: + platform: 소셜 플랫폼 종류 + """ + + platform: SocialPlatform + + @abstractmethod + async def upload( + self, + video_path: str, + access_token: str, + metadata: UploadMetadata, + progress_callback: Optional[Callable[[int], None]] = None, + ) -> UploadResult: + """ + 영상 업로드 + + Args: + video_path: 업로드할 영상 파일 경로 (로컬 또는 URL) + access_token: OAuth 액세스 토큰 + metadata: 업로드 메타데이터 + progress_callback: 진행률 콜백 함수 (0-100) + + Returns: + UploadResult: 업로드 결과 + """ + pass + + @abstractmethod + async def get_upload_status( + self, + platform_video_id: str, + access_token: str, + ) -> dict[str, Any]: + """ + 업로드 상태 조회 + + 플랫폼에서 영상 처리 상태를 조회합니다. + + Args: + platform_video_id: 플랫폼 영상 ID + access_token: OAuth 액세스 토큰 + + Returns: + dict: 업로드 상태 정보 + """ + pass + + @abstractmethod + async def delete_video( + self, + platform_video_id: str, + access_token: str, + ) -> bool: + """ + 업로드된 영상 삭제 + + Args: + platform_video_id: 플랫폼 영상 ID + access_token: OAuth 액세스 토큰 + + Returns: + bool: 삭제 성공 여부 + """ + pass + + def validate_metadata(self, metadata: UploadMetadata) -> None: + """ + 메타데이터 유효성 검증 + + 플랫폼별 제한사항을 확인합니다. + + Args: + metadata: 검증할 메타데이터 + + Raises: + ValueError: 유효하지 않은 메타데이터 + """ + if not metadata.title or len(metadata.title) == 0: + raise ValueError("제목은 필수입니다.") + + if len(metadata.title) > 100: + raise ValueError("제목은 100자를 초과할 수 없습니다.") + + if metadata.description and len(metadata.description) > 5000: + raise ValueError("설명은 5000자를 초과할 수 없습니다.") + + def get_video_url(self, platform_video_id: str) -> str: + """ + 플랫폼 영상 URL 생성 + + Args: + platform_video_id: 플랫폼 영상 ID + + Returns: + str: 영상 URL + """ + if self.platform == SocialPlatform.YOUTUBE: + return f"https://www.youtube.com/watch?v={platform_video_id}" + elif self.platform == SocialPlatform.INSTAGRAM: + return f"https://www.instagram.com/reel/{platform_video_id}/" + elif self.platform == SocialPlatform.FACEBOOK: + return f"https://www.facebook.com/watch/?v={platform_video_id}" + elif self.platform == SocialPlatform.TIKTOK: + return f"https://www.tiktok.com/video/{platform_video_id}" + else: + return "" diff --git a/app/social/uploader/youtube.py b/app/social/uploader/youtube.py new file mode 100644 index 0000000..d2baf20 --- /dev/null +++ b/app/social/uploader/youtube.py @@ -0,0 +1,420 @@ +""" +YouTube Uploader + +YouTube Data API v3를 사용한 영상 업로더입니다. +Resumable Upload를 지원합니다. +""" + +import json +import logging +import os +from typing import Any, Callable, Optional + +import httpx + +from config import social_upload_settings +from app.social.constants import PrivacyStatus, SocialPlatform +from app.social.exceptions import UploadError, UploadQuotaExceededError +from app.social.uploader.base import BaseSocialUploader, UploadMetadata, UploadResult + +logger = logging.getLogger(__name__) + + +class YouTubeUploader(BaseSocialUploader): + """ + YouTube 영상 업로더 + + YouTube Data API v3의 Resumable Upload를 사용하여 + 대용량 영상을 안정적으로 업로드합니다. + """ + + platform = SocialPlatform.YOUTUBE + + # YouTube API 엔드포인트 + UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos" + VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos" + + # 청크 크기 (5MB - YouTube 권장) + CHUNK_SIZE = 5 * 1024 * 1024 + + def __init__(self) -> None: + self.timeout = social_upload_settings.UPLOAD_TIMEOUT_SECONDS + + async def upload( + self, + video_path: str, + access_token: str, + metadata: UploadMetadata, + progress_callback: Optional[Callable[[int], None]] = None, + ) -> UploadResult: + """ + YouTube에 영상 업로드 (Resumable Upload) + + Args: + video_path: 업로드할 영상 파일 경로 + access_token: OAuth 액세스 토큰 + metadata: 업로드 메타데이터 + progress_callback: 진행률 콜백 함수 (0-100) + + Returns: + UploadResult: 업로드 결과 + """ + logger.info(f"[YOUTUBE_UPLOAD] 업로드 시작 - video_path: {video_path}") + + # 1. 메타데이터 유효성 검증 + self.validate_metadata(metadata) + + # 2. 파일 크기 확인 + if not os.path.exists(video_path): + logger.error(f"[YOUTUBE_UPLOAD] 파일 없음 - path: {video_path}") + return UploadResult( + success=False, + error_message=f"파일을 찾을 수 없습니다: {video_path}", + ) + + file_size = os.path.getsize(video_path) + logger.info(f"[YOUTUBE_UPLOAD] 파일 크기: {file_size / (1024*1024):.2f} MB") + + try: + # 3. Resumable upload 세션 시작 + upload_url = await self._init_resumable_upload( + access_token=access_token, + metadata=metadata, + file_size=file_size, + ) + + # 4. 파일 업로드 + video_id = await self._upload_file( + upload_url=upload_url, + video_path=video_path, + file_size=file_size, + progress_callback=progress_callback, + ) + + video_url = self.get_video_url(video_id) + + logger.info( + f"[YOUTUBE_UPLOAD] 업로드 성공 - video_id: {video_id}, url: {video_url}" + ) + + return UploadResult( + success=True, + platform_video_id=video_id, + platform_url=video_url, + ) + + except UploadQuotaExceededError: + raise + except UploadError as e: + logger.error(f"[YOUTUBE_UPLOAD] 업로드 실패 - error: {e}") + return UploadResult( + success=False, + error_message=str(e), + ) + except Exception as e: + logger.error(f"[YOUTUBE_UPLOAD] 예상치 못한 에러 - error: {e}") + return UploadResult( + success=False, + error_message=f"업로드 중 에러 발생: {str(e)}", + ) + + async def _init_resumable_upload( + self, + access_token: str, + metadata: UploadMetadata, + file_size: int, + ) -> str: + """ + Resumable upload 세션 시작 + + Args: + access_token: OAuth 액세스 토큰 + metadata: 업로드 메타데이터 + file_size: 파일 크기 + + Returns: + str: 업로드 URL + + Raises: + UploadError: 세션 시작 실패 + """ + logger.debug("[YOUTUBE_UPLOAD] Resumable upload 세션 시작") + + # YouTube API 요청 본문 + body = { + "snippet": { + "title": metadata.title, + "description": metadata.description or "", + "tags": metadata.tags or [], + "categoryId": self._get_category_id(metadata), + }, + "status": { + "privacyStatus": self._convert_privacy_status(metadata.privacy_status), + "selfDeclaredMadeForKids": False, + }, + } + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json; charset=utf-8", + "X-Upload-Content-Type": "video/*", + "X-Upload-Content-Length": str(file_size), + } + + params = { + "uploadType": "resumable", + "part": "snippet,status", + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + self.UPLOAD_URL, + params=params, + headers=headers, + json=body, + ) + + if response.status_code == 200: + upload_url = response.headers.get("location") + if upload_url: + logger.debug( + f"[YOUTUBE_UPLOAD] 세션 시작 성공 - upload_url: {upload_url[:50]}..." + ) + return upload_url + + # 에러 처리 + error_data = response.json() if response.content else {} + error_reason = ( + error_data.get("error", {}).get("errors", [{}])[0].get("reason", "") + ) + + if error_reason == "quotaExceeded": + logger.error("[YOUTUBE_UPLOAD] API 할당량 초과") + raise UploadQuotaExceededError(platform=self.platform.value) + + error_message = error_data.get("error", {}).get( + "message", f"HTTP {response.status_code}" + ) + logger.error(f"[YOUTUBE_UPLOAD] 세션 시작 실패 - error: {error_message}") + raise UploadError( + platform=self.platform.value, + detail=f"Resumable upload 세션 시작 실패: {error_message}", + ) + + async def _upload_file( + self, + upload_url: str, + video_path: str, + file_size: int, + progress_callback: Optional[Callable[[int], None]] = None, + ) -> str: + """ + 파일 청크 업로드 + + Args: + upload_url: Resumable upload URL + video_path: 영상 파일 경로 + file_size: 파일 크기 + progress_callback: 진행률 콜백 + + Returns: + str: YouTube 영상 ID + + Raises: + UploadError: 업로드 실패 + """ + uploaded_bytes = 0 + + async with httpx.AsyncClient(timeout=self.timeout) as client: + with open(video_path, "rb") as video_file: + while uploaded_bytes < file_size: + # 청크 읽기 + chunk = video_file.read(self.CHUNK_SIZE) + chunk_size = len(chunk) + end_byte = uploaded_bytes + chunk_size - 1 + + headers = { + "Content-Type": "video/*", + "Content-Length": str(chunk_size), + "Content-Range": f"bytes {uploaded_bytes}-{end_byte}/{file_size}", + } + + response = await client.put( + upload_url, + headers=headers, + content=chunk, + ) + + if response.status_code == 200 or response.status_code == 201: + # 업로드 완료 + result = response.json() + video_id = result.get("id") + if video_id: + return video_id + raise UploadError( + platform=self.platform.value, + detail="응답에서 video ID를 찾을 수 없습니다.", + ) + + elif response.status_code == 308: + # 청크 업로드 성공, 계속 진행 + uploaded_bytes += chunk_size + progress = int((uploaded_bytes / file_size) * 100) + + if progress_callback: + progress_callback(progress) + + logger.debug( + f"[YOUTUBE_UPLOAD] 청크 업로드 완료 - " + f"progress: {progress}%, " + f"uploaded: {uploaded_bytes}/{file_size}" + ) + + else: + # 에러 + error_data = response.json() if response.content else {} + error_message = error_data.get("error", {}).get( + "message", f"HTTP {response.status_code}" + ) + logger.error( + f"[YOUTUBE_UPLOAD] 청크 업로드 실패 - error: {error_message}" + ) + raise UploadError( + platform=self.platform.value, + detail=f"청크 업로드 실패: {error_message}", + ) + + raise UploadError( + platform=self.platform.value, + detail="업로드가 완료되지 않았습니다.", + ) + + async def get_upload_status( + self, + platform_video_id: str, + access_token: str, + ) -> dict[str, Any]: + """ + 업로드 상태 조회 + + Args: + platform_video_id: YouTube 영상 ID + access_token: OAuth 액세스 토큰 + + Returns: + dict: 업로드 상태 정보 + """ + logger.info(f"[YOUTUBE_UPLOAD] 상태 조회 - video_id: {platform_video_id}") + + headers = {"Authorization": f"Bearer {access_token}"} + params = { + "part": "status,processingDetails", + "id": platform_video_id, + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + self.VIDEOS_URL, + headers=headers, + params=params, + ) + + if response.status_code == 200: + data = response.json() + items = data.get("items", []) + + if items: + item = items[0] + status = item.get("status", {}) + processing = item.get("processingDetails", {}) + + return { + "upload_status": status.get("uploadStatus"), + "privacy_status": status.get("privacyStatus"), + "processing_status": processing.get( + "processingStatus", "processing" + ), + "processing_progress": processing.get( + "processingProgress", {} + ), + } + + return {"error": "영상을 찾을 수 없습니다."} + + return {"error": f"상태 조회 실패: HTTP {response.status_code}"} + + async def delete_video( + self, + platform_video_id: str, + access_token: str, + ) -> bool: + """ + 업로드된 영상 삭제 + + Args: + platform_video_id: YouTube 영상 ID + access_token: OAuth 액세스 토큰 + + Returns: + bool: 삭제 성공 여부 + """ + logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 - video_id: {platform_video_id}") + + headers = {"Authorization": f"Bearer {access_token}"} + params = {"id": platform_video_id} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.delete( + self.VIDEOS_URL, + headers=headers, + params=params, + ) + + if response.status_code == 204: + logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 성공 - video_id: {platform_video_id}") + return True + else: + logger.warning( + f"[YOUTUBE_UPLOAD] 영상 삭제 실패 - " + f"video_id: {platform_video_id}, status: {response.status_code}" + ) + return False + + def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str: + """ + PrivacyStatus를 YouTube API 형식으로 변환 + + Args: + privacy_status: 공개 상태 + + Returns: + str: YouTube API 공개 상태 + """ + mapping = { + PrivacyStatus.PUBLIC: "public", + PrivacyStatus.UNLISTED: "unlisted", + PrivacyStatus.PRIVATE: "private", + } + return mapping.get(privacy_status, "private") + + def _get_category_id(self, metadata: UploadMetadata) -> str: + """ + 카테고리 ID 추출 + + platform_options에서 category_id를 추출하거나 기본값 반환 + + Args: + metadata: 업로드 메타데이터 + + Returns: + str: YouTube 카테고리 ID + """ + if metadata.platform_options and "category_id" in metadata.platform_options: + return str(metadata.platform_options["category_id"]) + + # 기본값: "22" (People & Blogs) + return "22" + + +# 싱글톤 인스턴스 +youtube_uploader = YouTubeUploader() diff --git a/app/social/worker/__init__.py b/app/social/worker/__init__.py new file mode 100644 index 0000000..58f258d --- /dev/null +++ b/app/social/worker/__init__.py @@ -0,0 +1,9 @@ +""" +Social Worker Module + +소셜 미디어 백그라운드 태스크 모듈입니다. +""" + +from app.social.worker.upload_task import process_social_upload + +__all__ = ["process_social_upload"] diff --git a/app/social/worker/upload_task.py b/app/social/worker/upload_task.py new file mode 100644 index 0000000..4326687 --- /dev/null +++ b/app/social/worker/upload_task.py @@ -0,0 +1,374 @@ +""" +Social Upload Background Task + +소셜 미디어 영상 업로드 백그라운드 태스크입니다. +""" + +import logging +import os +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Optional + +import aiofiles +import httpx +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from config import social_upload_settings +from app.database.session import BackgroundSessionLocal +from app.social.constants import SocialPlatform, UploadStatus +from app.social.exceptions import UploadError, UploadQuotaExceededError +from app.social.models import SocialUpload +from app.social.services import social_account_service +from app.social.uploader import get_uploader +from app.social.uploader.base import UploadMetadata +from app.user.models import SocialAccount +from app.video.models import Video + +logger = logging.getLogger(__name__) + + +async def _update_upload_status( + upload_id: int, + status: UploadStatus, + upload_progress: int = 0, + platform_video_id: Optional[str] = None, + platform_url: Optional[str] = None, + error_message: Optional[str] = None, +) -> bool: + """ + 업로드 상태 업데이트 + + Args: + upload_id: SocialUpload ID + status: 업로드 상태 + upload_progress: 업로드 진행률 (0-100) + platform_video_id: 플랫폼 영상 ID + platform_url: 플랫폼 영상 URL + error_message: 에러 메시지 + + Returns: + bool: 업데이트 성공 여부 + """ + try: + async with BackgroundSessionLocal() as session: + result = await session.execute( + select(SocialUpload).where(SocialUpload.id == upload_id) + ) + upload = result.scalar_one_or_none() + + if upload: + upload.status = status.value + upload.upload_progress = upload_progress + + if platform_video_id: + upload.platform_video_id = platform_video_id + if platform_url: + upload.platform_url = platform_url + if error_message: + upload.error_message = error_message + if status == UploadStatus.COMPLETED: + upload.uploaded_at = datetime.now() + + await session.commit() + logger.info( + f"[SOCIAL_UPLOAD] 상태 업데이트 - " + f"upload_id: {upload_id}, status: {status.value}, progress: {upload_progress}%" + ) + return True + else: + logger.warning(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}") + return False + + except SQLAlchemyError as e: + logger.error(f"[SOCIAL_UPLOAD] DB 에러 - upload_id: {upload_id}, error: {e}") + return False + + +async def _download_video(video_url: str, upload_id: int) -> bytes: + """ + 영상 파일 다운로드 + + Args: + video_url: 영상 URL + upload_id: 업로드 ID (로그용) + + Returns: + bytes: 영상 파일 내용 + + Raises: + httpx.HTTPError: 다운로드 실패 + """ + logger.info(f"[SOCIAL_UPLOAD] 영상 다운로드 시작 - upload_id: {upload_id}") + + async with httpx.AsyncClient(timeout=300.0) as client: + response = await client.get(video_url) + response.raise_for_status() + + logger.info( + f"[SOCIAL_UPLOAD] 영상 다운로드 완료 - " + f"upload_id: {upload_id}, size: {len(response.content)} bytes" + ) + return response.content + + +async def _increment_retry_count(upload_id: int) -> int: + """ + 재시도 횟수 증가 + + Args: + upload_id: SocialUpload ID + + Returns: + int: 현재 재시도 횟수 + """ + try: + async with BackgroundSessionLocal() as session: + result = await session.execute( + select(SocialUpload).where(SocialUpload.id == upload_id) + ) + upload = result.scalar_one_or_none() + + if upload: + upload.retry_count += 1 + await session.commit() + return upload.retry_count + + return 0 + + except SQLAlchemyError: + return 0 + + +async def process_social_upload(upload_id: int) -> None: + """ + 소셜 미디어 업로드 처리 + + 백그라운드에서 실행되며, 영상을 소셜 플랫폼에 업로드합니다. + + Args: + upload_id: SocialUpload ID + """ + logger.info(f"[SOCIAL_UPLOAD] 업로드 태스크 시작 - upload_id: {upload_id}") + + temp_file_path: Optional[Path] = None + + try: + # 1. 업로드 정보 조회 + async with BackgroundSessionLocal() as session: + result = await session.execute( + select(SocialUpload).where(SocialUpload.id == upload_id) + ) + upload = result.scalar_one_or_none() + + if not upload: + logger.error(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}") + return + + # 2. Video 정보 조회 + video_result = await session.execute( + select(Video).where(Video.id == upload.video_id) + ) + video = video_result.scalar_one_or_none() + + if not video or not video.result_movie_url: + logger.error( + f"[SOCIAL_UPLOAD] 영상 없음 또는 URL 없음 - " + f"upload_id: {upload_id}, video_id: {upload.video_id}" + ) + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.FAILED, + error_message="영상을 찾을 수 없거나 URL이 없습니다.", + ) + return + + # 3. SocialAccount 정보 조회 + account_result = await session.execute( + select(SocialAccount).where(SocialAccount.id == upload.social_account_id) + ) + account = account_result.scalar_one_or_none() + + if not account or not account.is_active: + logger.error( + f"[SOCIAL_UPLOAD] 소셜 계정 없음 또는 비활성화 - " + f"upload_id: {upload_id}, account_id: {upload.social_account_id}" + ) + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.FAILED, + error_message="연동된 소셜 계정이 없거나 비활성화 상태입니다.", + ) + return + + # 필요한 정보 저장 + video_url = video.result_movie_url + platform = SocialPlatform(upload.platform) + upload_title = upload.title + upload_description = upload.description + upload_tags = upload.tags if isinstance(upload.tags, list) else None + upload_privacy = upload.privacy_status + upload_options = upload.platform_options + + # 4. 상태 업데이트: uploading + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.UPLOADING, + upload_progress=0, + ) + + # 5. 토큰 유효성 확인 및 갱신 + async with BackgroundSessionLocal() as session: + # account 다시 조회 (세션이 닫혔으므로) + account_result = await session.execute( + select(SocialAccount).where(SocialAccount.id == upload.social_account_id) + ) + account = account_result.scalar_one_or_none() + + if not account: + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.FAILED, + error_message="소셜 계정을 찾을 수 없습니다.", + ) + return + + access_token = await social_account_service.ensure_valid_token( + account=account, + session=session, + ) + + # 6. 영상 다운로드 + video_content = await _download_video(video_url, upload_id) + + # 7. 임시 파일 저장 + temp_dir = Path(social_upload_settings.UPLOAD_TEMP_DIR) / str(upload_id) + temp_dir.mkdir(parents=True, exist_ok=True) + temp_file_path = temp_dir / "video.mp4" + + async with aiofiles.open(str(temp_file_path), "wb") as f: + await f.write(video_content) + + logger.info( + f"[SOCIAL_UPLOAD] 임시 파일 저장 완료 - " + f"upload_id: {upload_id}, path: {temp_file_path}" + ) + + # 8. 메타데이터 준비 + from app.social.constants import PrivacyStatus + + metadata = UploadMetadata( + title=upload_title, + description=upload_description, + tags=upload_tags, + privacy_status=PrivacyStatus(upload_privacy), + platform_options=upload_options, + ) + + # 9. 진행률 콜백 함수 + async def progress_callback(progress: int) -> None: + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.UPLOADING, + upload_progress=progress, + ) + + # 10. 플랫폼에 업로드 + uploader = get_uploader(platform) + + # 동기 콜백으로 변환 (httpx 청크 업로드 내에서 호출되므로) + def sync_progress_callback(progress: int) -> None: + import asyncio + + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + asyncio.create_task( + _update_upload_status( + upload_id=upload_id, + status=UploadStatus.UPLOADING, + upload_progress=progress, + ) + ) + except Exception: + pass + + result = await uploader.upload( + video_path=str(temp_file_path), + access_token=access_token, + metadata=metadata, + progress_callback=sync_progress_callback, + ) + + # 11. 결과 처리 + if result.success: + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.COMPLETED, + upload_progress=100, + platform_video_id=result.platform_video_id, + platform_url=result.platform_url, + ) + logger.info( + f"[SOCIAL_UPLOAD] 업로드 완료 - " + f"upload_id: {upload_id}, " + f"platform_video_id: {result.platform_video_id}, " + f"url: {result.platform_url}" + ) + else: + retry_count = await _increment_retry_count(upload_id) + + if retry_count < social_upload_settings.UPLOAD_MAX_RETRIES: + # 재시도 가능 + logger.warning( + f"[SOCIAL_UPLOAD] 업로드 실패, 재시도 예정 - " + f"upload_id: {upload_id}, retry: {retry_count}" + ) + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.PENDING, + upload_progress=0, + error_message=f"업로드 실패 (재시도 {retry_count}/{social_upload_settings.UPLOAD_MAX_RETRIES}): {result.error_message}", + ) + else: + # 최대 재시도 초과 + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.FAILED, + error_message=f"최대 재시도 횟수 초과: {result.error_message}", + ) + logger.error( + f"[SOCIAL_UPLOAD] 업로드 최종 실패 - " + f"upload_id: {upload_id}, error: {result.error_message}" + ) + + except UploadQuotaExceededError as e: + logger.error(f"[SOCIAL_UPLOAD] API 할당량 초과 - upload_id: {upload_id}") + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.FAILED, + error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.", + ) + + except Exception as e: + logger.error( + f"[SOCIAL_UPLOAD] 예상치 못한 에러 - " + f"upload_id: {upload_id}, error: {e}" + ) + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.FAILED, + error_message=f"업로드 중 에러 발생: {str(e)}", + ) + + finally: + # 임시 파일 정리 + if temp_file_path and temp_file_path.exists(): + try: + temp_file_path.unlink() + temp_file_path.parent.rmdir() + logger.debug(f"[SOCIAL_UPLOAD] 임시 파일 삭제 - path: {temp_file_path}") + except Exception as e: + logger.warning(f"[SOCIAL_UPLOAD] 임시 파일 삭제 실패 - error: {e}") diff --git a/config.py b/config.py index a3be1b1..8fbedb4 100644 --- a/config.py +++ b/config.py @@ -448,6 +448,138 @@ class LogSettings(BaseSettings): return default_log_dir +# ============================================================================= +# Social Media OAuth Settings +# ============================================================================= + + +class SocialOAuthSettings(BaseSettings): + """소셜 미디어 OAuth 설정 + + YouTube, Instagram, Facebook 등 소셜 미디어 플랫폼 OAuth 인증 설정입니다. + 각 플랫폼의 개발자 콘솔에서 앱을 생성하고 클라이언트 ID/Secret을 발급받아야 합니다. + + YouTube (Google Cloud Console): + - https://console.cloud.google.com/ + - YouTube Data API v3 활성화 필요 + - OAuth 동의 화면 설정 필요 + + Instagram/Facebook (Meta for Developers): + - https://developers.facebook.com/ + - Instagram Graph API 사용을 위해 Facebook 앱 필요 + - 비즈니스/크리에이터 계정 필요 + + TikTok (TikTok for Developers): + - https://developers.tiktok.com/ + - Content Posting API 권한 필요 + """ + + # ============================================================ + # YouTube (Google OAuth) + # ============================================================ + YOUTUBE_CLIENT_ID: str = Field( + default="", + description="Google OAuth 클라이언트 ID (Google Cloud Console에서 발급)", + ) + YOUTUBE_CLIENT_SECRET: str = Field( + default="", + description="Google OAuth 클라이언트 Secret", + ) + YOUTUBE_REDIRECT_URI: str = Field( + default="http://localhost:8000/social/oauth/youtube/callback", + description="YouTube OAuth 콜백 URI", + ) + + # ============================================================ + # Instagram (Meta/Facebook OAuth) - 추후 구현 + # ============================================================ + # INSTAGRAM_APP_ID: str = Field(default="", description="Facebook App ID") + # INSTAGRAM_APP_SECRET: str = Field(default="", description="Facebook App Secret") + # INSTAGRAM_REDIRECT_URI: str = Field( + # default="http://localhost:8000/social/instagram/callback", + # description="Instagram OAuth 콜백 URI", + # ) + + # ============================================================ + # Facebook (Meta OAuth) - 추후 구현 + # ============================================================ + # FACEBOOK_APP_ID: str = Field(default="", description="Facebook App ID") + # FACEBOOK_APP_SECRET: str = Field(default="", description="Facebook App Secret") + # FACEBOOK_REDIRECT_URI: str = Field( + # default="http://localhost:8000/social/facebook/callback", + # description="Facebook OAuth 콜백 URI", + # ) + + # ============================================================ + # TikTok - 추후 구현 + # ============================================================ + # TIKTOK_CLIENT_KEY: str = Field(default="", description="TikTok Client Key") + # TIKTOK_CLIENT_SECRET: str = Field(default="", description="TikTok Client Secret") + # TIKTOK_REDIRECT_URI: str = Field( + # default="http://localhost:8000/social/tiktok/callback", + # description="TikTok OAuth 콜백 URI", + # ) + + # ============================================================ + # 공통 설정 + # ============================================================ + OAUTH_STATE_TTL_SECONDS: int = Field( + default=300, + description="OAuth state 토큰 유효 시간 (초). CSRF 방지용 state는 Redis에 저장됨", + ) + + # ============================================================ + # 프론트엔드 리다이렉트 설정 + # ============================================================ + OAUTH_FRONTEND_URL: str = Field( + default="http://localhost:3000", + description="OAuth 완료 후 리다이렉트할 프론트엔드 URL (프로토콜 포함)", + ) + OAUTH_SUCCESS_PATH: str = Field( + default="/social/connect/success", + description="OAuth 성공 시 리다이렉트 경로", + ) + OAUTH_ERROR_PATH: str = Field( + default="/social/connect/error", + description="OAuth 실패/취소 시 리다이렉트 경로", + ) + + model_config = _base_config + + +class SocialUploadSettings(BaseSettings): + """소셜 미디어 업로드 설정 + + 영상 업로드 관련 타임아웃, 재시도, 임시 파일 관리 설정입니다. + """ + + # ============================================================ + # 업로드 설정 + # ============================================================ + UPLOAD_MAX_RETRIES: int = Field( + default=3, + description="업로드 실패 시 최대 재시도 횟수", + ) + UPLOAD_RETRY_DELAY_SECONDS: int = Field( + default=10, + description="재시도 전 대기 시간 (초)", + ) + UPLOAD_TIMEOUT_SECONDS: float = Field( + default=600.0, + description="업로드 타임아웃 (초). 대용량 영상 업로드를 위해 충분한 시간 설정", + ) + + # ============================================================ + # 임시 파일 설정 + # ============================================================ + UPLOAD_TEMP_DIR: str = Field( + default="media/temp/social", + description="업로드 임시 파일 저장 디렉토리", + ) + + model_config = _base_config + + prj_settings = ProjectSettings() apikey_settings = APIKeySettings() db_settings = DatabaseSettings() @@ -461,3 +593,5 @@ log_settings = LogSettings() kakao_settings = KakaoSettings() jwt_settings = JWTSettings() recovery_settings = RecoverySettings() +social_oauth_settings = SocialOAuthSettings() +social_upload_settings = SocialUploadSettings() diff --git a/docs/api/social_api_spec.json b/docs/api/social_api_spec.json new file mode 100644 index 0000000..8eb861b --- /dev/null +++ b/docs/api/social_api_spec.json @@ -0,0 +1,601 @@ +{ + "info": { + "title": "Social Media Integration API", + "version": "1.0.0", + "description": "소셜 미디어 연동 및 영상 업로드 API 명세서", + "baseUrl": "http://localhost:8000" + }, + "authentication": { + "type": "Bearer Token", + "header": "Authorization", + "format": "Bearer {access_token}", + "description": "카카오 로그인 후 발급받은 JWT access_token 사용" + }, + "endpoints": { + "oauth": { + "connect": { + "name": "소셜 계정 연동 시작", + "method": "GET", + "url": "/social/oauth/{platform}/connect", + "description": "OAuth 인증 URL을 생성합니다. 반환된 auth_url로 사용자를 리다이렉트하세요.", + "authentication": true, + "pathParameters": { + "platform": { + "type": "string", + "enum": ["youtube"], + "description": "연동할 플랫폼 (현재 youtube만 지원)" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=xxx&redirect_uri=xxx&response_type=code&scope=xxx&state=xxx", + "state": "abc123xyz789", + "platform": "youtube" + } + }, + "error": { + "401": { + "detail": "인증이 필요합니다." + }, + "422": { + "detail": "지원하지 않는 플랫폼입니다." + } + } + }, + "frontendAction": "auth_url로 window.location.href 또는 새 창으로 리다이렉트" + }, + "callback": { + "name": "OAuth 콜백 (백엔드 자동 처리)", + "method": "GET", + "url": "/social/oauth/{platform}/callback", + "description": "Google에서 자동으로 호출됩니다. 프론트엔드에서 직접 호출하지 마세요.", + "authentication": false, + "note": "연동 성공 시 프론트엔드의 /social/connect/success 페이지로 리다이렉트됩니다.", + "redirectOnSuccess": { + "url": "{PROJECT_DOMAIN}/social/connect/success", + "queryParams": { + "platform": "youtube", + "account_id": 1, + "channel_name": "My YouTube Channel", + "profile_image": "https://yt3.ggpht.com/..." + }, + "example": "/social/connect/success?platform=youtube&account_id=1&channel_name=My+YouTube+Channel&profile_image=https%3A%2F%2Fyt3.ggpht.com%2F..." + }, + "redirectOnError": { + "url": "{PROJECT_DOMAIN}/social/connect/error", + "queryParams": { + "platform": "youtube", + "error": "에러 메시지" + } + } + }, + "getAccounts": { + "name": "연동된 계정 목록 조회", + "method": "GET", + "url": "/social/oauth/accounts", + "description": "현재 사용자가 연동한 모든 소셜 계정 목록을 반환합니다.", + "authentication": true, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "accounts": [ + { + "id": 1, + "platform": "youtube", + "platform_user_id": "UC1234567890abcdef", + "platform_username": "@mychannel", + "display_name": "My YouTube Channel", + "profile_image_url": "https://yt3.ggpht.com/...", + "is_active": true, + "connected_at": "2024-01-15T12:00:00", + "platform_data": { + "channel_id": "UC1234567890abcdef", + "channel_title": "My YouTube Channel", + "subscriber_count": "1000", + "video_count": "50" + } + } + ], + "total": 1 + } + } + } + }, + "getAccountByPlatform": { + "name": "특정 플랫폼 연동 계정 조회", + "method": "GET", + "url": "/social/oauth/accounts/{platform}", + "description": "특정 플랫폼에 연동된 계정 정보를 반환합니다.", + "authentication": true, + "pathParameters": { + "platform": { + "type": "string", + "enum": ["youtube"], + "description": "조회할 플랫폼" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "id": 1, + "platform": "youtube", + "platform_user_id": "UC1234567890abcdef", + "platform_username": "@mychannel", + "display_name": "My YouTube Channel", + "profile_image_url": "https://yt3.ggpht.com/...", + "is_active": true, + "connected_at": "2024-01-15T12:00:00", + "platform_data": { + "channel_id": "UC1234567890abcdef", + "channel_title": "My YouTube Channel", + "subscriber_count": "1000", + "video_count": "50" + } + } + }, + "error": { + "404": { + "detail": "youtube 플랫폼에 연동된 계정이 없습니다." + } + } + } + }, + "disconnect": { + "name": "소셜 계정 연동 해제", + "method": "DELETE", + "url": "/social/oauth/{platform}/disconnect", + "description": "소셜 미디어 계정 연동을 해제합니다.", + "authentication": true, + "pathParameters": { + "platform": { + "type": "string", + "enum": ["youtube"], + "description": "연동 해제할 플랫폼" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "success": true, + "message": "youtube 계정 연동이 해제되었습니다." + } + }, + "error": { + "404": { + "detail": "youtube 플랫폼에 연동된 계정이 없습니다." + } + } + } + } + }, + "upload": { + "create": { + "name": "소셜 플랫폼에 영상 업로드 요청", + "method": "POST", + "url": "/social/upload", + "description": "영상을 소셜 미디어 플랫폼에 업로드합니다. 백그라운드에서 처리됩니다.", + "authentication": true, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "Content-Type": "application/json" + }, + "body": { + "video_id": { + "type": "integer", + "required": true, + "description": "업로드할 영상 ID (Video 테이블의 id)", + "example": 123 + }, + "platform": { + "type": "string", + "required": true, + "enum": ["youtube"], + "description": "업로드할 플랫폼", + "example": "youtube" + }, + "title": { + "type": "string", + "required": true, + "maxLength": 100, + "description": "영상 제목", + "example": "나의 첫 영상" + }, + "description": { + "type": "string", + "required": false, + "maxLength": 5000, + "description": "영상 설명", + "example": "이 영상은 테스트 영상입니다." + }, + "tags": { + "type": "array", + "required": false, + "items": "string", + "description": "태그 목록", + "example": ["여행", "vlog", "일상"] + }, + "privacy_status": { + "type": "string", + "required": false, + "enum": ["public", "unlisted", "private"], + "default": "private", + "description": "공개 상태", + "example": "private" + }, + "platform_options": { + "type": "object", + "required": false, + "description": "플랫폼별 추가 옵션", + "example": { + "category_id": "22" + } + } + }, + "example": { + "video_id": 123, + "platform": "youtube", + "title": "나의 첫 영상", + "description": "이 영상은 테스트 영상입니다.", + "tags": ["여행", "vlog"], + "privacy_status": "private", + "platform_options": { + "category_id": "22" + } + } + }, + "response": { + "success": { + "status": 200, + "body": { + "success": true, + "upload_id": 456, + "platform": "youtube", + "status": "pending", + "message": "업로드 요청이 접수되었습니다." + } + }, + "error": { + "404_video": { + "detail": "영상을 찾을 수 없습니다." + }, + "404_account": { + "detail": "youtube 플랫폼에 연동된 계정이 없습니다." + }, + "400": { + "detail": "영상이 아직 준비되지 않았습니다." + } + } + }, + "youtubeCategoryIds": { + "1": "Film & Animation", + "2": "Autos & Vehicles", + "10": "Music", + "15": "Pets & Animals", + "17": "Sports", + "19": "Travel & Events", + "20": "Gaming", + "22": "People & Blogs (기본값)", + "23": "Comedy", + "24": "Entertainment", + "25": "News & Politics", + "26": "Howto & Style", + "27": "Education", + "28": "Science & Technology", + "29": "Nonprofits & Activism" + } + }, + "getStatus": { + "name": "업로드 상태 조회", + "method": "GET", + "url": "/social/upload/{upload_id}/status", + "description": "특정 업로드 작업의 상태를 조회합니다. 폴링으로 상태를 확인하세요.", + "authentication": true, + "pathParameters": { + "upload_id": { + "type": "integer", + "description": "업로드 ID" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "upload_id": 456, + "video_id": 123, + "platform": "youtube", + "status": "completed", + "upload_progress": 100, + "title": "나의 첫 영상", + "platform_video_id": "dQw4w9WgXcQ", + "platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "error_message": null, + "retry_count": 0, + "created_at": "2024-01-15T12:00:00", + "uploaded_at": "2024-01-15T12:05:00" + } + }, + "error": { + "404": { + "detail": "업로드 정보를 찾을 수 없습니다." + } + } + }, + "statusValues": { + "pending": "업로드 대기 중", + "uploading": "업로드 진행 중 (upload_progress 확인)", + "processing": "플랫폼에서 처리 중", + "completed": "업로드 완료 (platform_url 사용 가능)", + "failed": "업로드 실패 (error_message 확인)", + "cancelled": "업로드 취소됨" + }, + "pollingRecommendation": { + "interval": "3초", + "maxAttempts": 100, + "stopConditions": ["completed", "failed", "cancelled"] + } + }, + "getHistory": { + "name": "업로드 이력 조회", + "method": "GET", + "url": "/social/upload/history", + "description": "사용자의 소셜 미디어 업로드 이력을 조회합니다.", + "authentication": true, + "queryParameters": { + "platform": { + "type": "string", + "required": false, + "enum": ["youtube"], + "description": "플랫폼 필터" + }, + "status": { + "type": "string", + "required": false, + "enum": ["pending", "uploading", "processing", "completed", "failed", "cancelled"], + "description": "상태 필터" + }, + "page": { + "type": "integer", + "required": false, + "default": 1, + "description": "페이지 번호" + }, + "size": { + "type": "integer", + "required": false, + "default": 20, + "min": 1, + "max": 100, + "description": "페이지 크기" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "exampleUrl": "/social/upload/history?platform=youtube&status=completed&page=1&size=20" + }, + "response": { + "success": { + "status": 200, + "body": { + "items": [ + { + "upload_id": 456, + "video_id": 123, + "platform": "youtube", + "status": "completed", + "title": "나의 첫 영상", + "platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "created_at": "2024-01-15T12:00:00", + "uploaded_at": "2024-01-15T12:05:00" + } + ], + "total": 1, + "page": 1, + "size": 20 + } + } + } + }, + "retry": { + "name": "업로드 재시도", + "method": "POST", + "url": "/social/upload/{upload_id}/retry", + "description": "실패한 업로드를 재시도합니다.", + "authentication": true, + "pathParameters": { + "upload_id": { + "type": "integer", + "description": "업로드 ID" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "success": true, + "upload_id": 456, + "platform": "youtube", + "status": "pending", + "message": "업로드 재시도가 요청되었습니다." + } + }, + "error": { + "400": { + "detail": "실패하거나 취소된 업로드만 재시도할 수 있습니다." + }, + "404": { + "detail": "업로드 정보를 찾을 수 없습니다." + } + } + } + }, + "cancel": { + "name": "업로드 취소", + "method": "DELETE", + "url": "/social/upload/{upload_id}", + "description": "대기 중인 업로드를 취소합니다.", + "authentication": true, + "pathParameters": { + "upload_id": { + "type": "integer", + "description": "업로드 ID" + } + }, + "request": { + "headers": { + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "response": { + "success": { + "status": 200, + "body": { + "success": true, + "message": "업로드가 취소되었습니다." + } + }, + "error": { + "400": { + "detail": "대기 중인 업로드만 취소할 수 있습니다." + }, + "404": { + "detail": "업로드 정보를 찾을 수 없습니다." + } + } + } + } + } + }, + "frontendPages": { + "required": [ + { + "path": "/social/connect/success", + "description": "OAuth 연동 성공 후 리다이렉트되는 페이지", + "queryParams": { + "platform": "플랫폼명 (youtube)", + "account_id": "연동된 계정 ID", + "channel_name": "YouTube 채널 이름 (URL 인코딩됨)", + "profile_image": "프로필 이미지 URL (URL 인코딩됨)" + }, + "action": "연동 성공 메시지 표시, 채널 정보 즉시 표시 가능, 이후 GET /social/oauth/accounts 호출로 전체 목록 갱신" + }, + { + "path": "/social/connect/error", + "description": "OAuth 연동 실패 시 리다이렉트되는 페이지", + "queryParams": { + "platform": "플랫폼명 (youtube)", + "error": "에러 메시지 (URL 인코딩됨)" + }, + "action": "에러 메시지 표시 및 재시도 옵션 제공" + } + ], + "recommended": [ + { + "path": "/settings/social", + "description": "소셜 계정 관리 페이지", + "features": ["연동된 계정 목록", "연동/해제 버튼", "업로드 이력"] + } + ] + }, + "flowExamples": { + "connectYouTube": { + "description": "YouTube 계정 연동 플로우", + "steps": [ + { + "step": 1, + "action": "GET /social/oauth/youtube/connect 호출", + "result": "auth_url 반환" + }, + { + "step": 2, + "action": "window.location.href = auth_url", + "result": "Google 로그인 페이지로 이동" + }, + { + "step": 3, + "action": "사용자가 권한 승인", + "result": "백엔드 콜백 URL로 자동 리다이렉트" + }, + { + "step": 4, + "action": "백엔드가 토큰 교환 후 프론트엔드로 리다이렉트", + "result": "/social/connect/success?platform=youtube&account_id=1&channel_name=My+Channel&profile_image=https%3A%2F%2F..." + }, + { + "step": 5, + "action": "URL 파라미터에서 채널 정보 추출하여 즉시 표시", + "code": "const params = new URLSearchParams(window.location.search); const channelName = params.get('channel_name'); const profileImage = params.get('profile_image');" + }, + { + "step": 6, + "action": "GET /social/oauth/accounts 호출로 전체 계정 목록 갱신", + "result": "연동된 계정 정보 표시" + } + ] + }, + "uploadVideo": { + "description": "YouTube 영상 업로드 플로우", + "steps": [ + { + "step": 1, + "action": "POST /social/upload 호출", + "request": { + "video_id": 123, + "platform": "youtube", + "title": "영상 제목", + "privacy_status": "private" + }, + "result": "upload_id 반환" + }, + { + "step": 2, + "action": "GET /social/upload/{upload_id}/status 폴링 (3초 간격)", + "result": "status, upload_progress 확인" + }, + { + "step": 3, + "action": "status === 'completed' 확인", + "result": "platform_url로 YouTube 링크 표시" + } + ], + "pollingCode": "setInterval(() => checkUploadStatus(uploadId), 3000)" + } + } +} diff --git a/main.py b/main.py index 43763a7..02c8263 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,8 @@ from app.user.api.routers.v1.auth import router as auth_router, test_router as a from app.lyric.api.routers.v1.lyric import router as lyric_router from app.song.api.routers.v1.song import router as song_router from app.video.api.routers.v1.video import router as video_router +from app.social.api.routers.v1.oauth import router as social_oauth_router +from app.social.api.routers.v1.upload import router as social_upload_router from app.utils.cors import CustomCORSMiddleware from config import prj_settings @@ -151,6 +153,55 @@ tags_metadata = [ - created_at 기준 내림차순 정렬됩니다. - 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다. - 삭제 대상: Video, SongTimestamp, Song, Lyric, Image, Project +""", + }, + { + "name": "Social OAuth", + "description": """소셜 미디어 계정 연동 API + +**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수 + +## 지원 플랫폼 + +- **YouTube**: Google OAuth 2.0 기반 연동 + +## 연동 흐름 + +1. `GET /social/oauth/{platform}/connect` - OAuth 인증 URL 획득 +2. 사용자를 auth_url로 리다이렉트 → 플랫폼 로그인 +3. 플랫폼에서 권한 승인 후 콜백 URL로 리다이렉트 +4. 연동 완료 후 프론트엔드로 리다이렉트 + +## 계정 관리 + +- `GET /social/oauth/accounts` - 연동된 계정 목록 조회 +- `DELETE /social/oauth/{platform}/disconnect` - 계정 연동 해제 +""", + }, + { + "name": "Social Upload", + "description": """소셜 미디어 영상 업로드 API + +**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수 + +## 사전 조건 + +- 해당 플랫폼에 계정이 연동되어 있어야 합니다 +- 영상이 completed 상태여야 합니다 + +## 업로드 흐름 + +1. `POST /social/upload` - 업로드 요청 (백그라운드 처리) +2. `GET /social/upload/{upload_id}/status` - 업로드 상태 폴링 +3. `GET /social/upload/history` - 업로드 이력 조회 + +## 업로드 상태 + +- `pending`: 업로드 대기 중 +- `uploading`: 업로드 진행 중 +- `processing`: 플랫폼에서 처리 중 +- `completed`: 업로드 완료 +- `failed`: 업로드 실패 """, }, ] @@ -218,6 +269,7 @@ def custom_openapi(): "/crawling", "/autocomplete", "/search", # 숙박 검색 자동완성 + "/social/oauth/youtube/callback", # OAuth 콜백 (플랫폼에서 직접 호출) ] # 보안이 필요한 엔드포인트에 security 적용 @@ -267,6 +319,8 @@ app.include_router(lyric_router) app.include_router(song_router) app.include_router(video_router) app.include_router(archive_router) # Archive API 라우터 추가 +app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가 +app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가 # DEBUG 모드에서만 테스트 라우터 등록 if prj_settings.DEBUG: