diff --git a/app/social/exceptions.py b/app/social/exceptions.py index f172711..712b6d6 100644 --- a/app/social/exceptions.py +++ b/app/social/exceptions.py @@ -123,6 +123,7 @@ class TokenExpiredError(OAuthException): status_code=status.HTTP_401_UNAUTHORIZED, code="TOKEN_EXPIRED", ) + self.platform = platform # ============================================================================= diff --git a/app/social/services.py b/app/social/services.py index 931e1a9..2ee4fcd 100644 --- a/app/social/services.py +++ b/app/social/services.py @@ -27,8 +27,10 @@ redis_client = Redis( from app.social.exceptions import ( InvalidStateError, OAuthStateExpiredError, + OAuthTokenRefreshError, SocialAccountAlreadyConnectedError, SocialAccountNotFoundError, + TokenExpiredError, ) from app.social.oauth import get_oauth_client from app.social.schemas import ( @@ -209,13 +211,15 @@ class SocialAccountService: self, user_uuid: str, session: AsyncSession, + auto_refresh: bool = True, ) -> list[SocialAccountResponse]: """ - 연동된 소셜 계정 목록 조회 + 연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함) Args: user_uuid: 사용자 UUID session: DB 세션 + auto_refresh: 토큰 자동 갱신 여부 (기본 True) Returns: list[SocialAccountResponse]: 연동된 계정 목록 @@ -233,8 +237,95 @@ class SocialAccountService: 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 = datetime.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, @@ -402,15 +493,34 @@ class SocialAccountService: Returns: str: 유효한 access_token + + Raises: + TokenExpiredError: 토큰 갱신 실패 시 (재연동 필요) """ - # 만료 시간 확인 (만료 10분 전이면 갱신) - if account.token_expires_at: + # 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 = 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) + should_refresh = True + + if should_refresh: + return await self._refresh_account_token(account, session) return account.access_token @@ -428,17 +538,33 @@ class SocialAccountService: Returns: str: 새 access_token + + Raises: + TokenExpiredError: 갱신 실패 시 (재연동 필요) """ if not account.refresh_token: logger.warning( - f"[SOCIAL] refresh_token 없음, 갱신 불가 - account_id: {account.id}" + f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}" ) - return account.access_token + raise TokenExpiredError(platform=account.platform) platform = SocialPlatform(account.platform) oauth_client = get_oauth_client(platform) - token_response = await oauth_client.refresh_token(account.refresh_token) + 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 diff --git a/app/social/worker/upload_task.py b/app/social/worker/upload_task.py index 4326687..7e00a90 100644 --- a/app/social/worker/upload_task.py +++ b/app/social/worker/upload_task.py @@ -19,7 +19,7 @@ from sqlalchemy.exc import SQLAlchemyError from config import social_upload_settings from app.database.session import BackgroundSessionLocal from app.social.constants import SocialPlatform, UploadStatus -from app.social.exceptions import UploadError, UploadQuotaExceededError +from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError from app.social.models import SocialUpload from app.social.services import social_account_service from app.social.uploader import get_uploader @@ -352,6 +352,17 @@ async def process_social_upload(upload_id: int) -> None: error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.", ) + except TokenExpiredError as e: + logger.error( + f"[SOCIAL_UPLOAD] 토큰 만료, 재연동 필요 - " + f"upload_id: {upload_id}, platform: {e.platform}" + ) + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.FAILED, + error_message=f"{e.platform} 계정 인증이 만료되었습니다. 계정을 다시 연동해주세요.", + ) + except Exception as e: logger.error( f"[SOCIAL_UPLOAD] 예상치 못한 에러 - " diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index 2c730f9..79796a2 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -33,10 +33,12 @@ from app.user.services import auth_service, kakao_client from app.user.services.jwt import ( create_access_token, create_refresh_token, + decode_token, get_access_token_expire_seconds, get_refresh_token_expires_at, get_token_hash, ) +from app.social.services import social_account_service from app.utils.common import generate_uuid @@ -140,6 +142,19 @@ async def kakao_callback( ip_address=ip_address, ) + # 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신 + try: + payload = decode_token(result.access_token) + if payload and payload.get("sub"): + user_uuid = payload.get("sub") + await social_account_service.refresh_all_tokens( + user_uuid=user_uuid, + session=session, + ) + except Exception as e: + # 토큰 갱신 실패해도 로그인은 성공 처리 + logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}") + # 프론트엔드로 토큰과 함께 리다이렉트 redirect_url = ( f"{prj_settings.PROJECT_DOMAIN}" @@ -205,9 +220,20 @@ async def kakao_verify( ip_address=ip_address, ) - logger.info( - f"[ROUTER] 카카오 인가 코드 검증 완료 - user_id: {result.user.id}, is_new_user: {result.user.is_new_user}" - ) + # 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신 + try: + payload = decode_token(result.access_token) + if payload and payload.get("sub"): + user_uuid = payload.get("sub") + await social_account_service.refresh_all_tokens( + user_uuid=user_uuid, + session=session, + ) + except Exception as e: + # 토큰 갱신 실패해도 로그인은 성공 처리 + logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}") + + logger.info(f"[ROUTER] 카카오 인가 코드 검증 완료 - is_new_user: {result.is_new_user}") return result