""" 소셜 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( "/accounts/{account_id}", response_model=MessageResponse, summary="소셜 계정 연동 해제 (account_id)", description=""" 소셜 미디어 계정 연동을 해제합니다. ## 경로 파라미터 - **account_id**: 연동 해제할 소셜 계정 ID (SocialAccount.id) ## 연동 해제 시 - 해당 플랫폼으로의 업로드가 불가능해집니다 - 기존 업로드 기록은 유지됩니다 - 재연동 시 동의 화면이 스킵됩니다 """, ) async def disconnect_by_account_id( account_id: int, current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> MessageResponse: """ 소셜 계정 연동 해제 (account_id 기준) account_id로 특정 소셜 계정의 연동을 해제합니다. """ logger.info( f"[OAUTH_API] 소셜 연동 해제 (by account_id) - " f"user_uuid: {current_user.user_uuid}, account_id: {account_id}" ) platform = await social_account_service.disconnect_by_account_id( user_uuid=current_user.user_uuid, account_id=account_id, session=session, ) return MessageResponse( success=True, message=f"{platform} 계정 연동이 해제되었습니다.", ) @router.delete( "/{platform}/disconnect", response_model=MessageResponse, summary="소셜 계정 연동 해제 (platform)", description=""" 소셜 미디어 계정 연동을 해제합니다. **주의**: 이 API는 플랫폼당 1개의 계정만 연동된 경우에 사용합니다. 여러 채널이 연동된 경우 `DELETE /accounts/{account_id}`를 사용하세요. 연동 해제 시: - 해당 플랫폼으로의 업로드가 불가능해집니다 - 기존 업로드 기록은 유지됩니다 """, deprecated=True, ) async def disconnect( platform: SocialPlatform, current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ) -> MessageResponse: """ 소셜 계정 연동 해제 (platform 기준) 플랫폼으로 연동된 첫 번째 계정을 해제합니다. """ 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} 계정 연동이 해제되었습니다.", )