social 계정 연동 refresh 기능 추가 .

get_video
hbyang 2026-02-09 10:59:44 +09:00
parent 89ea0c783e
commit 325fb9af69
4 changed files with 175 additions and 11 deletions

View File

@ -123,6 +123,7 @@ class TokenExpiredError(OAuthException):
status_code=status.HTTP_401_UNAUTHORIZED,
code="TOKEN_EXPIRED",
)
self.platform = platform
# =============================================================================

View File

@ -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

View File

@ -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] 예상치 못한 에러 - "

View File

@ -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