530 lines
17 KiB
Python
530 lines
17 KiB
Python
"""
|
|
Social Account Service
|
|
|
|
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import select
|
|
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,
|
|
SocialAccountAlreadyConnectedError,
|
|
SocialAccountNotFoundError,
|
|
)
|
|
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,
|
|
) -> list[SocialAccountResponse]:
|
|
"""
|
|
연동된 소셜 계정 목록 조회
|
|
|
|
Args:
|
|
user_uuid: 사용자 UUID
|
|
session: DB 세션
|
|
|
|
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)}개 조회됨")
|
|
|
|
return [self._to_response(account) for account in accounts]
|
|
|
|
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 disconnect(
|
|
self,
|
|
user_uuid: str,
|
|
platform: SocialPlatform,
|
|
session: AsyncSession,
|
|
) -> bool:
|
|
"""
|
|
소셜 계정 연동 해제
|
|
|
|
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
|
|
"""
|
|
# 만료 시간 확인 (만료 10분 전이면 갱신)
|
|
if account.token_expires_at:
|
|
buffer_time = datetime.now() + timedelta(minutes=10)
|
|
if account.token_expires_at <= buffer_time:
|
|
logger.info(
|
|
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
|
|
)
|
|
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
|
|
"""
|
|
if not account.refresh_token:
|
|
logger.warning(
|
|
f"[SOCIAL] refresh_token 없음, 갱신 불가 - account_id: {account.id}"
|
|
)
|
|
return account.access_token
|
|
|
|
platform = SocialPlatform(account.platform)
|
|
oauth_client = get_oauth_client(platform)
|
|
|
|
token_response = await oauth_client.refresh_token(account.refresh_token)
|
|
|
|
# 토큰 업데이트
|
|
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 = datetime.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 = datetime.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 = datetime.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 = datetime.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()
|