diff --git a/app/archive/api/routers/v1/archive.py b/app/archive/api/routers/v1/archive.py index 48eaefb..3885712 100644 --- a/app/archive/api/routers/v1/archive.py +++ b/app/archive/api/routers/v1/archive.py @@ -53,7 +53,7 @@ GET /archive/videos/?page=1&page_size=10 ## 참고 - **본인이 소유한 프로젝트의 영상만 반환됩니다.** - status가 'completed'인 영상만 반환됩니다. -- 재생성된 영상 포함 모든 영상이 반환됩니다. +- 동일 task_id의 영상이 여러 개인 경우, 가장 최근에 생성된 영상만 반환됩니다. - created_at 기준 내림차순 정렬됩니다. """, response_model=PaginatedResponse[VideoListItem], @@ -70,112 +70,50 @@ async def get_videos( ) -> PaginatedResponse[VideoListItem]: """완료된 영상 목록을 페이지네이션하여 반환합니다.""" logger.info( - f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}" + f"[get_videos] START - user: {current_user.user_uuid}, " + f"page: {pagination.page}, page_size: {pagination.page_size}" ) - logger.debug(f"[get_videos] current_user.user_uuid: {current_user.user_uuid}") try: offset = (pagination.page - 1) * pagination.page_size - # DEBUG: 각 조건별 데이터 수 확인 - # 1) 전체 Video 수 - all_videos_result = await session.execute(select(func.count(Video.id))) - all_videos_count = all_videos_result.scalar() or 0 - logger.debug(f"[get_videos] DEBUG - 전체 Video 수: {all_videos_count}") - - # 2) completed 상태 Video 수 - completed_videos_result = await session.execute( - select(func.count(Video.id)).where(Video.status == "completed") - ) - completed_videos_count = completed_videos_result.scalar() or 0 - logger.debug(f"[get_videos] DEBUG - completed 상태 Video 수: {completed_videos_count}") - - # 3) is_deleted=False인 Video 수 - not_deleted_videos_result = await session.execute( - select(func.count(Video.id)).where(Video.is_deleted == False) - ) - not_deleted_videos_count = not_deleted_videos_result.scalar() or 0 - logger.debug(f"[get_videos] DEBUG - is_deleted=False Video 수: {not_deleted_videos_count}") - - # 4) 전체 Project 수 및 user_uuid 값 확인 - all_projects_result = await session.execute( - select(Project.id, Project.user_uuid, Project.is_deleted) - ) - all_projects = all_projects_result.all() - logger.debug(f"[get_videos] DEBUG - 전체 Project 수: {len(all_projects)}") - for p in all_projects: - logger.debug( - f"[get_videos] DEBUG - Project: id={p.id}, user_uuid={p.user_uuid}, " - f"user_uuid_type={type(p.user_uuid)}, is_deleted={p.is_deleted}" - ) - - # 4-1) 현재 사용자 UUID 타입 확인 - logger.debug( - f"[get_videos] DEBUG - current_user.user_uuid={current_user.user_uuid}, " - f"type={type(current_user.user_uuid)}" - ) - - # 4-2) 현재 사용자 소유 Project 수 - user_projects_result = await session.execute( - select(func.count(Project.id)).where( - Project.user_uuid == current_user.user_uuid, - Project.is_deleted == False, - ) - ) - user_projects_count = user_projects_result.scalar() or 0 - logger.debug(f"[get_videos] DEBUG - 현재 사용자 소유 Project 수: {user_projects_count}") - - # 5) 현재 사용자 소유 + completed + 미삭제 Video 수 - user_completed_videos_result = await session.execute( - select(func.count(Video.id)) + # 서브쿼리: task_id별 최신 Video ID 추출 + # id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치 + latest_video_ids = ( + select(func.max(Video.id).label("latest_id")) .join(Project, Video.project_id == Project.id) .where( Project.user_uuid == current_user.user_uuid, Video.status == "completed", - Video.is_deleted == False, - Project.is_deleted == False, + Video.is_deleted == False, # noqa: E712 + Project.is_deleted == False, # noqa: E712 ) - ) - user_completed_videos_count = user_completed_videos_result.scalar() or 0 - logger.debug( - f"[get_videos] DEBUG - 현재 사용자 소유 + completed + 미삭제 Video 수: {user_completed_videos_count}" + .group_by(Video.task_id) + .subquery() ) - # 기본 조건: 현재 사용자 소유, completed 상태, 미삭제 - base_conditions = [ - Project.user_uuid == current_user.user_uuid, - Video.status == "completed", - Video.is_deleted == False, - Project.is_deleted == False, - ] - - # 쿼리 1: 전체 개수 조회 (모든 영상) - count_query = ( - select(func.count(Video.id)) - .join(Project, Video.project_id == Project.id) - .where(*base_conditions) + # 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만) + count_query = select(func.count(Video.id)).where( + Video.id.in_(select(latest_video_ids.c.latest_id)) ) total_result = await session.execute(count_query) total = total_result.scalar() or 0 - logger.debug(f"[get_videos] DEBUG - 전체 영상 개수 (total): {total}") - # 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회 (모든 영상) - query = ( + # 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만) + data_query = ( select(Video, Project) .join(Project, Video.project_id == Project.id) - .where(*base_conditions) + .where(Video.id.in_(select(latest_video_ids.c.latest_id))) .order_by(Video.created_at.desc()) .offset(offset) .limit(pagination.page_size) ) - result = await session.execute(query) + result = await session.execute(data_query) rows = result.all() - logger.debug(f"[get_videos] DEBUG - 최종 조회 결과 수: {len(rows)}") - # VideoListItem으로 변환 (JOIN 결과에서 바로 추출) - items = [] - for video, project in rows: - item = VideoListItem( + # VideoListItem으로 변환 + items = [ + VideoListItem( video_id=video.id, store_name=project.store_name, region=project.region, @@ -183,7 +121,8 @@ async def get_videos( result_movie_url=video.result_movie_url, created_at=video.created_at, ) - items.append(item) + for video, project in rows + ] response = PaginatedResponse.create( items=items, diff --git a/app/core/exceptions.py b/app/core/exceptions.py index abf8b26..4a4e277 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -291,15 +291,22 @@ def add_exception_handlers(app: FastAPI): # SocialException 핸들러 추가 from app.social.exceptions import SocialException + from app.social.exceptions import TokenExpiredError + @app.exception_handler(SocialException) def social_exception_handler(request: Request, exc: SocialException) -> Response: logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}") + content = { + "detail": exc.message, + "code": exc.code, + } + # TokenExpiredError인 경우 재연동 정보 추가 + if isinstance(exc, TokenExpiredError): + content["platform"] = exc.platform + content["reconnect_url"] = f"/social/oauth/{exc.platform}/connect" return JSONResponse( status_code=exc.status_code, - content={ - "detail": exc.message, - "code": exc.code, - }, + content=content, ) @app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR) 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/oauth/youtube.py b/app/social/oauth/youtube.py index 9d8a53a..e2b89e2 100644 --- a/app/social/oauth/youtube.py +++ b/app/social/oauth/youtube.py @@ -59,7 +59,7 @@ class YouTubeOAuthClient(BaseOAuthClient): "response_type": "code", "scope": " ".join(YOUTUBE_SCOPES), "access_type": "offline", # refresh_token 받기 위해 필요 - "prompt": "select_account", # 계정 선택만 표시 (이전 동의 유지) + "prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만) "state": state, } url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}" diff --git a/app/social/services.py b/app/social/services.py index 06e7a58..498eab7 100644 --- a/app/social/services.py +++ b/app/social/services.py @@ -4,6 +4,7 @@ Social Account Service 소셜 계정 연동 관련 비즈니스 로직을 처리합니다. """ +import json import logging import secrets from datetime import timedelta @@ -27,10 +28,10 @@ redis_client = Redis( decode_responses=True, ) from app.social.exceptions import ( - InvalidStateError, OAuthStateExpiredError, - SocialAccountAlreadyConnectedError, + OAuthTokenRefreshError, SocialAccountNotFoundError, + TokenExpiredError, ) from app.social.oauth import get_oauth_client from app.social.schemas import ( @@ -88,7 +89,7 @@ class SocialAccountService: await redis_client.setex( state_key, social_oauth_settings.OAUTH_STATE_TTL_SECONDS, - str(state_data), + json.dumps(state_data), # JSON으로 직렬화 ) logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}") @@ -124,9 +125,7 @@ class SocialAccountService: SocialAccountResponse: 연동된 소셜 계정 정보 Raises: - InvalidStateError: state 토큰이 유효하지 않은 경우 - OAuthStateExpiredError: state 토큰이 만료된 경우 - SocialAccountAlreadyConnectedError: 이미 연동된 계정인 경우 + OAuthStateExpiredError: state 토큰이 만료되거나 유효하지 않은 경우 """ logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...") @@ -138,8 +137,8 @@ class SocialAccountService: logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...") raise OAuthStateExpiredError() - # state 데이터 파싱 - state_data = eval(state_data_str) # {"user_uuid": "...", "platform": "..."} + # state 데이터 파싱 (JSON 역직렬화) + state_data = json.loads(state_data_str) user_uuid = state_data["user_uuid"] platform = SocialPlatform(state_data["platform"]) @@ -211,13 +210,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]: 연동된 계정 목록 @@ -235,8 +236,97 @@ 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: + # DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교 + current_time = now().replace(tzinfo=None) + buffer_time = current_time + 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, @@ -404,17 +494,37 @@ class SocialAccountService: Returns: str: 유효한 access_token - """ - # 만료 시간 확인 (만료 10분 전이면 갱신) - if account.token_expires_at: - buffer_time = 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 + Raises: + TokenExpiredError: 토큰 갱신 실패 시 (재연동 필요) + """ + # 만료 시간 확인 + is_expired = False + if account.token_expires_at is None: + is_expired = True + else: + current_time = now().replace(tzinfo=None) + buffer_time = current_time + timedelta(minutes=10) + if account.token_expires_at <= buffer_time: + is_expired = True + + # 아직 유효하면 그대로 사용 + if not is_expired: + return account.access_token + + # 만료됐는데 refresh_token이 없으면 재연동 필요 + if not account.refresh_token: + logger.warning( + f"[SOCIAL] access_token 만료 + refresh_token 없음, 재연동 필요 - " + f"account_id: {account.id}" + ) + raise TokenExpiredError(platform=account.platform) + + # refresh_token으로 갱신 + logger.info( + f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" + ) + return await self._refresh_account_token(account, session) async def _refresh_account_token( self, @@ -430,28 +540,46 @@ 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 if token_response.refresh_token: account.refresh_token = token_response.refresh_token if token_response.expires_in: - account.token_expires_at = now() + timedelta( + # DB에 naive datetime으로 저장 (MySQL DateTime은 timezone 미지원) + account.token_expires_at = now().replace(tzinfo=None) + timedelta( seconds=token_response.expires_in ) await session.commit() + await session.refresh(account) logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}") return account.access_token @@ -505,10 +633,10 @@ class SocialAccountService: Returns: SocialAccount: 생성된 소셜 계정 """ - # 토큰 만료 시간 계산 + # 토큰 만료 시간 계산 (DB에 naive datetime으로 저장) token_expires_at = None if token_response.expires_in: - token_expires_at = now() + timedelta( + token_expires_at = now().replace(tzinfo=None) + timedelta( seconds=token_response.expires_in ) @@ -561,7 +689,8 @@ class SocialAccountService: if token_response.refresh_token: account.refresh_token = token_response.refresh_token if token_response.expires_in: - account.token_expires_at = now() + timedelta( + # DB에 naive datetime으로 저장 + account.token_expires_at = now().replace(tzinfo=None) + timedelta( seconds=token_response.expires_in ) if token_response.scope: @@ -577,7 +706,7 @@ class SocialAccountService: # 재연결 시 연결 시간 업데이트 if update_connected_at: - account.connected_at = now() + account.connected_at = now().replace(tzinfo=None) await session.commit() await session.refresh(account) diff --git a/app/social/worker/upload_task.py b/app/social/worker/upload_task.py index 15b4b1a..6e48f89 100644 --- a/app/social/worker/upload_task.py +++ b/app/social/worker/upload_task.py @@ -20,7 +20,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 @@ -71,7 +71,7 @@ async def _update_upload_status( if error_message: upload.error_message = error_message if status == UploadStatus.COMPLETED: - upload.uploaded_at = now() + upload.uploaded_at = now().replace(tzinfo=None) await session.commit() logger.info( @@ -353,6 +353,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 c31df39..3981621 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -34,10 +34,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 @@ -141,6 +143,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}" @@ -206,9 +221,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 @@ -435,7 +461,7 @@ async def generate_test_token( session.add(db_refresh_token) # 마지막 로그인 시간 업데이트 - user.last_login_at = now() + user.last_login_at = now().replace(tzinfo=None) await session.commit() logger.info( diff --git a/app/user/models.py b/app/user/models.py index 2c734dc..fea0cc0 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -391,6 +391,7 @@ class RefreshToken(Base): user: Mapped["User"] = relationship( "User", back_populates="refresh_tokens", + lazy="selectin", # lazy loading 방지 ) def __repr__(self) -> str: @@ -591,6 +592,7 @@ class SocialAccount(Base): user: Mapped["User"] = relationship( "User", back_populates="social_accounts", + lazy="selectin", # lazy loading 방지 ) def __repr__(self) -> str: diff --git a/app/user/services/auth.py b/app/user/services/auth.py index aa648c1..35269e7 100644 --- a/app/user/services/auth.py +++ b/app/user/services/auth.py @@ -168,7 +168,7 @@ class AuthService: logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}") # 7. 마지막 로그인 시간 업데이트 - user.last_login_at = now() + user.last_login_at = now().replace(tzinfo=None) await session.commit() redirect_url = f"{prj_settings.PROJECT_DOMAIN}" @@ -223,7 +223,7 @@ class AuthService: if db_token.is_revoked: raise TokenRevokedError() - if db_token.expires_at < now(): + if db_token.expires_at < now().replace(tzinfo=None): raise TokenExpiredError() # 4. 사용자 확인 @@ -483,7 +483,7 @@ class AuthService: .where(RefreshToken.token_hash == token_hash) .values( is_revoked=True, - revoked_at=now(), + revoked_at=now().replace(tzinfo=None), ) ) await session.commit() @@ -508,7 +508,7 @@ class AuthService: ) .values( is_revoked=True, - revoked_at=now(), + revoked_at=now().replace(tzinfo=None), ) ) await session.commit() diff --git a/app/user/services/jwt.py b/app/user/services/jwt.py index 4f38658..ebcf2d6 100644 --- a/app/user/services/jwt.py +++ b/app/user/services/jwt.py @@ -107,7 +107,7 @@ def get_refresh_token_expires_at() -> datetime: Returns: 리프레시 토큰 만료 datetime (로컬 시간) """ - return now() + timedelta( + return now().replace(tzinfo=None) + timedelta( days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS ) diff --git a/app/utils/nvMapPwScraper.py b/app/utils/nvMapPwScraper.py index 2885242..3fc6468 100644 --- a/app/utils/nvMapPwScraper.py +++ b/app/utils/nvMapPwScraper.py @@ -15,7 +15,8 @@ class NvMapPwScraper(): _context = None _win_width = 1280 _win_height = 720 - _max_retry = 60 # place id timeout threshold seconds + _max_retry = 3 + _timeout = 60 # place id timeout threshold seconds # instance var page = None @@ -97,7 +98,7 @@ patchedGetter.toString();''') async def get_place_id_url(self, selected): count = 0 get_place_id_url_start = time.perf_counter() - while (count <= 1): + while (count <= self._max_retry): title = selected['title'].replace("", "").replace("", "") address = selected.get('roadAddress', selected['address']).replace("", "").replace("", "") encoded_query = parse.quote(f"{address} {title}") @@ -106,9 +107,12 @@ patchedGetter.toString();''') wait_first_start = time.perf_counter() try: - await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000) except: - await self.page.reload(wait_until="networkidle", timeout = self._max_retry/2*1000) + if "/place/" in self.page.url: + return self.page.url + logger.error(f"[ERROR] Can't Finish networkidle") + wait_first_time = (time.perf_counter() - wait_first_start) * 1000 @@ -123,9 +127,11 @@ patchedGetter.toString();''') url = self.page.url.replace("?","?isCorrectAnswer=true&") try: - await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000) except: - await self.page.reload(wait_until="networkidle", timeout = self._max_retry/2*1000) + if "/place/" in self.page.url: + return self.page.url + logger.error(f"[ERROR] Can't Finish networkidle") wait_forced_correct_time = (time.perf_counter() - wait_forced_correct_start) * 1000 logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms") diff --git a/timezone-check.md b/timezone-check.md new file mode 100644 index 0000000..c9a2baa --- /dev/null +++ b/timezone-check.md @@ -0,0 +1,163 @@ +# 타임존 검수 보고서 + +**검수일**: 2026-02-10 +**대상**: o2o-castad-backend 전체 프로젝트 +**기준**: 서울 타임존(Asia/Seoul, KST +09:00) + +--- + +## 1. 타임존 설정 현황 + +### 1.1 FastAPI 전역 타임존 (`config.py:15`) +```python +TIMEZONE = ZoneInfo(os.getenv("TIMEZONE", "Asia/Seoul")) +``` +- `ZoneInfo`를 사용한 aware datetime 기반 +- `.env`로 오버라이드 가능 (기본값: `Asia/Seoul`) + +### 1.2 타임존 유틸리티 (`app/utils/timezone.py`) +```python +def now() -> datetime: + return datetime.now(TIMEZONE) # aware datetime (tzinfo=Asia/Seoul) + +def today_str(fmt="%Y-%m-%d") -> str: + return datetime.now(TIMEZONE).strftime(fmt) +``` +- 모든 모듈에서 `from app.utils.timezone import now` 사용 권장 +- 반환값은 **aware datetime** (tzinfo 포함) + +### 1.3 데이터베이스 타임존 +- MySQL `server_default=func.now()` → DB 서버의 시스템 타임존 사용 +- DB 생성 시 서울 타임존으로 설정됨 → `func.now()`는 KST 반환 + +--- + +## 2. 검수 결과 요약 + +| 구분 | 상태 | 비고 | +|------|------|------| +| `datetime.now()` 직접 호출 | ✅ 정상 | 앱 코드에서 bare `datetime.now()` 사용 없음 | +| `datetime.utcnow()` 사용 | ✅ 정상 | 프로젝트 전체에서 사용하지 않음 | +| `app.utils.timezone.now()` 사용 | ✅ 정상 | 필요한 모든 곳에서 사용 중 | +| 모델 `server_default=func.now()` | ✅ 정상 | DB 서버 타임존(서울) 기준 | +| naive/aware datetime 혼합 비교 | ⚠️ 주의 | `now().replace(tzinfo=None)` 패턴으로 처리됨 | + +--- + +## 3. 모듈별 상세 검수 + +### 3.1 `app/user/services/jwt.py` — ✅ 정상 + +| 위치 | 코드 | 판정 | +|------|------|------| +| L27 | `now() + timedelta(minutes=...)` | ✅ 토큰 만료시간 계산에 서울 타임존 사용 | +| L52 | `now() + timedelta(days=...)` | ✅ 리프레시 토큰 만료시간 | +| L110 | `now().replace(tzinfo=None) + timedelta(days=...)` | ✅ DB 저장용 naive datetime 변환 | + +### 3.2 `app/user/services/auth.py` — ✅ 정상 + +| 위치 | 코드 | 판정 | +|------|------|------| +| L171 | `user.last_login_at = now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 | +| L226 | `db_token.expires_at < now().replace(tzinfo=None)` | ✅ naive datetime끼리 비교 | +| L486 | `revoked_at=now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 | +| L511 | `revoked_at=now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 | + +### 3.3 `app/user/api/routers/v1/auth.py` — ✅ 정상 + +| 위치 | 코드 | 판정 | +|------|------|------| +| L464 | `user.last_login_at = now().replace(tzinfo=None)` | ✅ 테스트 엔드포인트, DB 저장용 | + +### 3.4 `app/social/services.py` — ✅ 정상 + +| 위치 | 코드 | 판정 | +|------|------|------| +| L308 | `current_time = now().replace(tzinfo=None)` | ✅ DB datetime과 비교용 | +| L506 | `current_time = now().replace(tzinfo=None)` | ✅ 토큰 만료 확인 | +| L577 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 토큰 만료시간 DB 저장 | +| L639 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 신규 계정 토큰 만료시간 | +| L693 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 토큰 업데이트 시 만료시간 | +| L709 | `account.connected_at = now().replace(tzinfo=None)` | ✅ 재연결 시간 DB 저장 | + +### 3.5 `app/social/worker/upload_task.py` — ✅ 정상 + +| 위치 | 코드 | 판정 | +|------|------|------| +| L74 | `upload.uploaded_at = now().replace(tzinfo=None)` | ✅ 업로드 완료시간 DB 저장 | + +### 3.6 `app/utils/logger.py` — ✅ 정상 + +| 위치 | 코드 | 판정 | +|------|------|------| +| L27 | `from app.utils.timezone import today_str` | ✅ 로그 파일명에 서울 기준 날짜 사용 | +| L89 | `today = today_str()` | ✅ `{날짜}_app.log` 파일명 | + +--- + +## 4. 모델 `created_at` / `updated_at` 패턴 검수 + +모든 모델의 `created_at`, `updated_at` 컬럼은 `server_default=func.now()`를 사용합니다. + +| 모델 | 파일 | `created_at` | `updated_at` | 판정 | +|------|------|:---:|:---:|:---:| +| User | `app/user/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ | +| RefreshToken | `app/user/models.py` | `func.now()` | — | ✅ | +| SocialAccount | `app/user/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ | +| Project | `app/home/models.py` | `func.now()` | — | ✅ | +| Image | `app/home/models.py` | `func.now()` | — | ✅ | +| Lyric | `app/lyric/models.py` | `func.now()` | — | ✅ | +| Song | `app/song/models.py` | `func.now()` | — | ✅ | +| SongTimestamp | `app/song/models.py` | `func.now()` | — | ✅ | +| Video | `app/video/models.py` | `func.now()` | — | ✅ | +| SNSUploadTask | `app/sns/models.py` | `func.now()` | — | ✅ | +| SocialUpload | `app/social/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ | + +> `func.now()`는 MySQL 서버의 `NOW()` 함수를 호출하므로 DB 서버 타임존(서울)이 적용됩니다. + +--- + +## 5. `now().replace(tzinfo=None)` 패턴 분석 + +이 프로젝트에서는 **aware datetime → naive datetime** 변환 패턴이 일관되게 사용됩니다: + +```python +now().replace(tzinfo=None) # Asia/Seoul aware → naive (값은 KST 유지) +``` + +**이유**: MySQL의 `DateTime` 타입은 타임존 정보를 저장하지 않으므로(naive datetime), DB에 저장하거나 DB 값과 비교할 때 `tzinfo`를 제거해야 합니다. + +**검증**: 이 패턴은 `now()`가 이미 서울 타임존 기준이므로, `.replace(tzinfo=None)` 후에도 **값 자체는 KST 시간**을 유지합니다. DB의 `func.now()`도 KST이므로 비교 시 일관성이 보장됩니다. + +| 사용처 | 목적 | 일관성 | +|--------|------|:------:| +| `jwt.py:110` | refresh token 만료시간 DB 저장 | ✅ | +| `auth.py:171` | 마지막 로그인 시간 DB 저장 | ✅ | +| `auth.py:226` | refresh token 만료 여부 비교 | ✅ | +| `auth.py:486,511` | token 폐기 시간 DB 저장 | ✅ | +| `auth.py(router):464` | 테스트 엔드포인트 로그인 시간 | ✅ | +| `social/services.py:308,506` | 토큰 만료 비교 | ✅ | +| `social/services.py:577,639,693` | 토큰 만료시간 DB 저장 | ✅ | +| `social/services.py:709` | 재연결 시간 DB 저장 | ✅ | +| `social/worker/upload_task.py:74` | 업로드 완료시간 DB 저장 | ✅ | + +--- + +## 6. 최종 결론 + +### ✅ 전체 판정: 정상 (PASS) + +프로젝트 전반에 걸쳐 타임존 처리가 **일관되게** 구현되어 있습니다: + +1. **bare `datetime.now()` 미사용** — 앱 코드에서 타임존 없는 `datetime.now()` 직접 호출이 없음 +2. **`datetime.utcnow()` 미사용** — UTC 기반 시간 생성 없음 +3. **`app.utils.timezone.now()` 일관 사용** — 모든 서비스/라우터에서 유틸리티 함수 사용 +4. **DB 저장 시 naive 변환 일관** — `now().replace(tzinfo=None)` 패턴 통일 +5. **모델 기본값 `func.now()` 통일** — DB 서버 타임존(서울) 기준으로 자동 설정 +6. **비교 연산 안전** — DB의 naive datetime과 비교 시 항상 naive로 변환 후 비교 + +### 주의사항 (현재 문제 아님, 향후 참고용) + +1. **DB 서버 타임존 변경 주의**: `func.now()`는 DB 서버 타임존에 의존하므로, DB 서버의 타임존이 변경되면 `created_at`/`updated_at` 등의 자동 생성 시간이 영향을 받습니다. +2. **다중 타임존 확장 시**: 현재는 단일 타임존(서울)만 사용하므로 문제없지만, 다국적 서비스 확장 시 UTC 기반 저장 + 표시 시 변환 패턴으로 전환을 고려할 수 있습니다. +3. **`replace(tzinfo=None)` 패턴**: 값은 유지하면서 타임존 정보만 제거하므로 안전하지만, 코드 리뷰 시 의도를 명확히 하기 위해 주석을 유지하는 것이 좋습니다(현재 `social/services.py:307`에 주석 존재).