o2o-castad-backend/app/social/api/routers/v1/oauth.py

282 lines
9.1 KiB
Python

"""
소셜 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} 계정 연동이 해제되었습니다.",
)