o2o-castad-backend/app/sns/services/facebook.py

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()