o2o-castad-backend/app/social/services.py

740 lines
24 KiB
Python

"""
Social Account Service
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
"""
import logging
import secrets
from datetime import timedelta
from typing import Optional
from sqlalchemy import select
from app.utils.timezone import now
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,
OAuthTokenRefreshError,
SocialAccountAlreadyConnectedError,
SocialAccountNotFoundError,
TokenExpiredError,
)
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,
auto_refresh: bool = True,
) -> list[SocialAccountResponse]:
"""
연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함)
Args:
user_uuid: 사용자 UUID
session: DB 세션
auto_refresh: 토큰 자동 갱신 여부 (기본 True)
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)}개 조회됨")
# 토큰 자동 갱신
if auto_refresh:
for account in accounts:
await self._try_refresh_token(account, session)
return [self._to_response(account) for account in accounts]
async def refresh_all_tokens(
self,
user_uuid: str,
session: AsyncSession,
) -> dict[str, bool]:
"""
사용자의 모든 연동 계정 토큰 갱신 (로그인 시 호출)
Args:
user_uuid: 사용자 UUID
session: DB 세션
Returns:
dict[str, bool]: 플랫폼별 갱신 성공 여부
"""
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()
refresh_results = {}
for account in accounts:
success = await self._try_refresh_token(account, session)
refresh_results[f"{account.platform}_{account.id}"] = success
logger.info(f"[SOCIAL] 토큰 갱신 완료 - results: {refresh_results}")
return refresh_results
async def _try_refresh_token(
self,
account: SocialAccount,
session: AsyncSession,
) -> bool:
"""
토큰 갱신 시도 (실패해도 예외 발생하지 않음)
Args:
account: 소셜 계정
session: DB 세션
Returns:
bool: 갱신 성공 여부
"""
# refresh_token이 없으면 갱신 불가
if not account.refresh_token:
logger.debug(
f"[SOCIAL] refresh_token 없음, 갱신 스킵 - account_id: {account.id}"
)
return False
# 만료 시간 확인 (만료 1시간 전이면 갱신)
should_refresh = False
if account.token_expires_at is None:
should_refresh = True
else:
buffer_time = now() + timedelta(hours=1)
if account.token_expires_at <= buffer_time:
should_refresh = True
if not should_refresh:
logger.debug(
f"[SOCIAL] 토큰 아직 유효, 갱신 스킵 - account_id: {account.id}"
)
return True
# 갱신 시도
try:
await self._refresh_account_token(account, session)
return True
except Exception as e:
logger.warning(
f"[SOCIAL] 토큰 갱신 실패 (재연동 필요) - "
f"account_id: {account.id}, error: {e}"
)
return False
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 get_account_by_id(
self,
user_uuid: str,
account_id: int,
session: AsyncSession,
) -> Optional[SocialAccount]:
"""
account_id로 연동 계정 조회 (소유권 검증 포함)
Args:
user_uuid: 사용자 UUID
account_id: 소셜 계정 ID
session: DB 세션
Returns:
SocialAccount: 소셜 계정 (없으면 None)
"""
result = await session.execute(
select(SocialAccount).where(
SocialAccount.id == account_id,
SocialAccount.user_uuid == user_uuid,
SocialAccount.is_active == True, # noqa: E712
SocialAccount.is_deleted == False, # noqa: E712
)
)
return result.scalar_one_or_none()
async def disconnect_by_account_id(
self,
user_uuid: str,
account_id: int,
session: AsyncSession,
) -> str:
"""
account_id로 소셜 계정 연동 해제
Args:
user_uuid: 사용자 UUID
account_id: 소셜 계정 ID
session: DB 세션
Returns:
str: 연동 해제된 플랫폼 이름
Raises:
SocialAccountNotFoundError: 연동된 계정이 없는 경우
"""
logger.info(
f"[SOCIAL] 소셜 계정 연동 해제 시작 (by account_id) - "
f"user_uuid: {user_uuid}, account_id: {account_id}"
)
# 1. account_id로 계정 조회 (user_uuid 소유권 확인 포함)
result = await session.execute(
select(SocialAccount).where(
SocialAccount.id == account_id,
SocialAccount.user_uuid == user_uuid,
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"[SOCIAL] 연동된 계정 없음 - "
f"user_uuid: {user_uuid}, account_id: {account_id}"
)
raise SocialAccountNotFoundError()
# 2. 소프트 삭제
platform = account.platform
account.is_active = False
account.is_deleted = True
await session.commit()
logger.info(
f"[SOCIAL] 소셜 계정 연동 해제 완료 - "
f"account_id: {account.id}, platform: {platform}"
)
return platform
async def disconnect(
self,
user_uuid: str,
platform: SocialPlatform,
session: AsyncSession,
) -> bool:
"""
소셜 계정 연동 해제 (platform 기준, deprecated)
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
Raises:
TokenExpiredError: 토큰 갱신 실패 시 (재연동 필요)
"""
# refresh_token이 없으면 갱신 불가 → 재연동 필요
if not account.refresh_token:
logger.warning(
f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}"
)
raise TokenExpiredError(platform=account.platform)
# 만료 시간 확인 (만료 10분 전이면 갱신, 만료 시간 없어도 갱신 시도)
should_refresh = False
if account.token_expires_at is None:
logger.info(
f"[SOCIAL] token_expires_at 없음, 갱신 시도 - account_id: {account.id}"
)
should_refresh = True
else:
buffer_time = now() + timedelta(minutes=10)
if account.token_expires_at <= buffer_time:
logger.info(
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
)
should_refresh = True
if should_refresh:
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
Raises:
TokenExpiredError: 갱신 실패 시 (재연동 필요)
"""
if not account.refresh_token:
logger.warning(
f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}"
)
raise TokenExpiredError(platform=account.platform)
platform = SocialPlatform(account.platform)
oauth_client = get_oauth_client(platform)
try:
token_response = await oauth_client.refresh_token(account.refresh_token)
except OAuthTokenRefreshError as e:
logger.error(
f"[SOCIAL] 토큰 갱신 실패, 재연동 필요 - "
f"account_id: {account.id}, error: {e}"
)
raise TokenExpiredError(platform=account.platform)
except Exception as e:
logger.error(
f"[SOCIAL] 토큰 갱신 중 예외 발생, 재연동 필요 - "
f"account_id: {account.id}, error: {e}"
)
raise TokenExpiredError(platform=account.platform)
# 토큰 업데이트
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 = 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 = 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 = 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 = 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()