288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""
|
|
Facebook OAuth 서비스
|
|
|
|
Facebook OAuth 연동 관련 비즈니스 로직을 처리합니다.
|
|
모든 Facebook API 호출은 FacebookOAuthClient를 통해서만 수행됩니다.
|
|
"""
|
|
|
|
import json
|
|
import secrets
|
|
from datetime import timedelta
|
|
|
|
from redis.asyncio import Redis
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.sns.schemas.facebook_schema import (
|
|
FacebookAccountResponse,
|
|
FacebookConnectResponse,
|
|
FacebookPageInfo,
|
|
)
|
|
from app.user.models import Platform, SocialAccount
|
|
from app.utils.facebook_oauth import FacebookOAuthClient
|
|
from app.utils.logger import get_logger
|
|
from app.utils.timezone import now
|
|
from config import db_settings, social_oauth_settings
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Facebook OAuth용 Redis 클라이언트 (DB 3 사용 - social과 분리)
|
|
_redis_client = Redis(
|
|
host=db_settings.REDIS_HOST,
|
|
port=db_settings.REDIS_PORT,
|
|
db=3,
|
|
decode_responses=True,
|
|
)
|
|
|
|
|
|
class FacebookService:
|
|
"""
|
|
Facebook OAuth 연동 서비스
|
|
|
|
OAuth 인증 시작, 콜백 처리, 연동 해제 기능을 제공합니다.
|
|
모든 Facebook Graph API 호출은 FacebookOAuthClient를 통해 수행됩니다.
|
|
"""
|
|
|
|
# Redis key prefix for Facebook OAuth state
|
|
STATE_KEY_PREFIX = "facebook:oauth:state:"
|
|
|
|
def __init__(self) -> None:
|
|
# FacebookOAuthClient 인스턴스 생성
|
|
self.oauth_client = FacebookOAuthClient()
|
|
|
|
async def start_connect(self, user_uuid: str) -> FacebookConnectResponse:
|
|
"""
|
|
Facebook OAuth 연동 시작
|
|
|
|
CSRF state 토큰을 생성하고 Redis에 저장한 뒤,
|
|
Facebook 인증 페이지 URL을 반환합니다.
|
|
|
|
Args:
|
|
user_uuid: 사용자 UUID
|
|
|
|
Returns:
|
|
FacebookConnectResponse: 인증 URL 및 state 토큰
|
|
"""
|
|
logger.info(f"[FACEBOOK_SVC] 연동 시작 - user_uuid: {user_uuid}")
|
|
|
|
# 1. CSRF state 토큰 생성
|
|
state = secrets.token_urlsafe(32)
|
|
logger.debug(f"[FACEBOOK_SVC] state 토큰 생성 - state: {state[:20]}...")
|
|
|
|
# 2. Redis에 state:user_uuid 매핑 저장 (TTL 적용)
|
|
state_key = f"{self.STATE_KEY_PREFIX}{state}"
|
|
state_data = json.dumps({"user_uuid": user_uuid})
|
|
await _redis_client.setex(
|
|
state_key,
|
|
social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
|
|
state_data,
|
|
)
|
|
logger.debug(
|
|
f"[FACEBOOK_SVC] Redis state 저장 - "
|
|
f"key: {state_key}, ttl: {social_oauth_settings.OAUTH_STATE_TTL_SECONDS}초"
|
|
)
|
|
|
|
# 3. Facebook 인증 페이지 URL 생성
|
|
auth_url = self.oauth_client.get_authorization_url(state)
|
|
|
|
logger.info("[FACEBOOK_SVC] 연동 시작 완료 - 인증 URL 생성됨")
|
|
|
|
return FacebookConnectResponse(auth_url=auth_url, state=state)
|
|
|
|
async def handle_callback(
|
|
self, code: str, state: str, session: AsyncSession
|
|
) -> FacebookAccountResponse:
|
|
"""
|
|
Facebook OAuth 콜백 처리
|
|
|
|
인가 코드를 토큰으로 교환하고, 장기 토큰 발급 후
|
|
사용자 정보를 조회하여 SocialAccount에 저장합니다.
|
|
|
|
Args:
|
|
code: Facebook OAuth 인가 코드
|
|
state: CSRF 방지용 state 토큰
|
|
session: DB 세션
|
|
|
|
Returns:
|
|
FacebookAccountResponse: 연동 완료 정보
|
|
|
|
Raises:
|
|
HTTPException: state 무효, 토큰 교환 실패 등
|
|
"""
|
|
logger.info(f"[FACEBOOK_SVC] 콜백 처리 시작 - state: {state[:20]}...")
|
|
|
|
# 1. Redis에서 state로 user_uuid 조회 및 검증
|
|
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"[FACEBOOK_SVC] state 토큰 없음 또는 만료 - state: {state[:20]}...")
|
|
from fastapi import HTTPException, status
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={
|
|
"code": "FACEBOOK_STATE_EXPIRED",
|
|
"message": "인증 세션이 만료되었습니다. 다시 시도해주세요.",
|
|
},
|
|
)
|
|
|
|
# state 데이터 파싱 및 삭제 (일회성)
|
|
state_data = json.loads(state_data_str)
|
|
user_uuid = state_data["user_uuid"]
|
|
await _redis_client.delete(state_key)
|
|
logger.debug(f"[FACEBOOK_SVC] state 검증 완료 및 삭제 - user_uuid: {user_uuid}")
|
|
|
|
# 2. 인가 코드를 단기 액세스 토큰으로 교환
|
|
short_token_data = await self.oauth_client.get_access_token(code)
|
|
short_lived_token = short_token_data["access_token"]
|
|
logger.debug("[FACEBOOK_SVC] 단기 토큰 획득 완료")
|
|
|
|
# 3. 단기 토큰을 장기 토큰으로 교환 (약 60일)
|
|
long_token_data = await self.oauth_client.exchange_long_lived_token(short_lived_token)
|
|
long_lived_token = long_token_data["access_token"]
|
|
expires_in = long_token_data.get("expires_in", 5184000) # 기본 60일
|
|
logger.debug(f"[FACEBOOK_SVC] 장기 토큰 교환 완료 - expires_in: {expires_in}초")
|
|
|
|
# 4. 사용자 정보 조회
|
|
user_info = await self.oauth_client.get_user_info(long_lived_token)
|
|
facebook_user_id = user_info["id"]
|
|
facebook_user_name = user_info.get("name", "")
|
|
facebook_picture = user_info.get("picture", {})
|
|
logger.debug(
|
|
f"[FACEBOOK_SVC] 사용자 정보 조회 완료 - "
|
|
f"id: {facebook_user_id}, name: {facebook_user_name}"
|
|
)
|
|
|
|
# 5. 사용자 관리 페이지 목록 조회
|
|
pages = []
|
|
try:
|
|
pages_data = await self.oauth_client.get_user_pages(facebook_user_id, long_lived_token)
|
|
pages = [
|
|
FacebookPageInfo(
|
|
id=page["id"],
|
|
name=page.get("name", ""),
|
|
access_token=page.get("access_token", ""),
|
|
category=page.get("category"),
|
|
)
|
|
for page in pages_data
|
|
]
|
|
logger.debug(f"[FACEBOOK_SVC] 페이지 목록 조회 완료 - 페이지 수: {len(pages)}")
|
|
except Exception as e:
|
|
# 페이지 조회 실패는 연동 실패로 처리하지 않음
|
|
logger.warning(f"[FACEBOOK_SVC] 페이지 목록 조회 실패 (무시) - error: {str(e)}")
|
|
|
|
# 6. SocialAccount 생성 또는 업데이트 (UPSERT)
|
|
token_expires_at = now() + timedelta(seconds=expires_in)
|
|
platform_data = {
|
|
"picture_url": facebook_picture.get("data", {}).get("url") if isinstance(facebook_picture, dict) else None,
|
|
"pages": [page.model_dump() for page in pages],
|
|
}
|
|
|
|
# 기존 계정 조회 (user_uuid + platform + platform_user_id 기준)
|
|
existing_result = await session.execute(
|
|
select(SocialAccount).where(
|
|
SocialAccount.user_uuid == user_uuid,
|
|
SocialAccount.platform == Platform.FACEBOOK,
|
|
SocialAccount.platform_user_id == facebook_user_id,
|
|
)
|
|
)
|
|
existing_account = existing_result.scalar_one_or_none()
|
|
|
|
if existing_account:
|
|
# 기존 계정 업데이트 (토큰 갱신 + 재활성화)
|
|
logger.info(f"[FACEBOOK_SVC] 기존 계정 업데이트 - account_id: {existing_account.id}")
|
|
existing_account.access_token = long_lived_token
|
|
existing_account.token_expires_at = token_expires_at
|
|
existing_account.platform_username = facebook_user_name
|
|
existing_account.platform_data = platform_data
|
|
existing_account.scope = self.oauth_client.scope
|
|
existing_account.is_active = True
|
|
existing_account.is_deleted = False
|
|
existing_account.connected_at = now()
|
|
await session.commit()
|
|
await session.refresh(existing_account)
|
|
account = existing_account
|
|
else:
|
|
# 새 소셜 계정 생성
|
|
logger.info(f"[FACEBOOK_SVC] 새 계정 생성 - user_uuid: {user_uuid}")
|
|
account = SocialAccount(
|
|
user_uuid=user_uuid,
|
|
platform=Platform.FACEBOOK,
|
|
access_token=long_lived_token,
|
|
refresh_token=None, # Facebook은 refresh_token 미지원
|
|
token_expires_at=token_expires_at,
|
|
scope=self.oauth_client.scope,
|
|
platform_user_id=facebook_user_id,
|
|
platform_username=facebook_user_name,
|
|
platform_data=platform_data,
|
|
is_active=True,
|
|
is_deleted=False,
|
|
)
|
|
session.add(account)
|
|
await session.commit()
|
|
await session.refresh(account)
|
|
|
|
logger.info(
|
|
f"[FACEBOOK_SVC] 연동 완료 - "
|
|
f"account_id: {account.id}, platform_user_id: {facebook_user_id}"
|
|
)
|
|
|
|
return FacebookAccountResponse(
|
|
success=True,
|
|
message="Facebook 계정 연동이 완료되었습니다.",
|
|
account_id=account.id,
|
|
platform_user_id=facebook_user_id,
|
|
platform_username=facebook_user_name,
|
|
pages=pages if pages else None,
|
|
)
|
|
|
|
async def disconnect(self, user_uuid: str, session: AsyncSession) -> None:
|
|
"""
|
|
Facebook 계정 연동 해제
|
|
|
|
SocialAccount를 소프트 삭제 처리합니다.
|
|
|
|
Args:
|
|
user_uuid: 사용자 UUID
|
|
session: DB 세션
|
|
|
|
Raises:
|
|
HTTPException: 연동된 Facebook 계정이 없는 경우
|
|
"""
|
|
logger.info(f"[FACEBOOK_SVC] 연동 해제 시작 - user_uuid: {user_uuid}")
|
|
|
|
# 활성 Facebook 계정 조회
|
|
result = await session.execute(
|
|
select(SocialAccount).where(
|
|
SocialAccount.user_uuid == user_uuid,
|
|
SocialAccount.platform == Platform.FACEBOOK,
|
|
SocialAccount.is_active == True, # noqa: E712
|
|
SocialAccount.is_deleted == False, # noqa: E712
|
|
)
|
|
)
|
|
account = result.scalar_one_or_none()
|
|
|
|
if account is None:
|
|
logger.warning(f"[FACEBOOK_SVC] 연동 해제 대상 없음 - user_uuid: {user_uuid}")
|
|
from fastapi import HTTPException, status
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={
|
|
"code": "FACEBOOK_ACCOUNT_NOT_FOUND",
|
|
"message": "연동된 Facebook 계정을 찾을 수 없습니다.",
|
|
},
|
|
)
|
|
|
|
# 소프트 삭제 처리
|
|
account.is_active = False
|
|
account.is_deleted = True
|
|
await session.commit()
|
|
|
|
logger.info(f"[FACEBOOK_SVC] 연동 해제 완료 - account_id: {account.id}")
|
|
|
|
|
|
# 모듈 레벨 싱글턴 인스턴스
|
|
facebook_service = FacebookService()
|