Merge branch 'main' into youtube-description

main
jaehwang 2026-02-12 05:10:32 +00:00
commit f1dd675ecb
12 changed files with 417 additions and 133 deletions

View File

@ -53,7 +53,7 @@ GET /archive/videos/?page=1&page_size=10
## 참고 ## 참고
- **본인이 소유한 프로젝트의 영상만 반환됩니다.** - **본인이 소유한 프로젝트의 영상만 반환됩니다.**
- status가 'completed' 영상만 반환됩니다. - status가 'completed' 영상만 반환됩니다.
- 재생성된 영상 포함 모든 영상이 반환됩니다. - 동일 task_id의 영상이 여러 개인 경우, 가장 최근에 생성된 영상만 반환됩니다.
- created_at 기준 내림차순 정렬됩니다. - created_at 기준 내림차순 정렬됩니다.
""", """,
response_model=PaginatedResponse[VideoListItem], response_model=PaginatedResponse[VideoListItem],
@ -70,112 +70,50 @@ async def get_videos(
) -> PaginatedResponse[VideoListItem]: ) -> PaginatedResponse[VideoListItem]:
"""완료된 영상 목록을 페이지네이션하여 반환합니다.""" """완료된 영상 목록을 페이지네이션하여 반환합니다."""
logger.info( 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: try:
offset = (pagination.page - 1) * pagination.page_size offset = (pagination.page - 1) * pagination.page_size
# DEBUG: 각 조건별 데이터 수 확인 # 서브쿼리: task_id별 최신 Video ID 추출
# 1) 전체 Video 수 # id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치
all_videos_result = await session.execute(select(func.count(Video.id))) latest_video_ids = (
all_videos_count = all_videos_result.scalar() or 0 select(func.max(Video.id).label("latest_id"))
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))
.join(Project, Video.project_id == Project.id) .join(Project, Video.project_id == Project.id)
.where( .where(
Project.user_uuid == current_user.user_uuid, Project.user_uuid == current_user.user_uuid,
Video.status == "completed", Video.status == "completed",
Video.is_deleted == False, Video.is_deleted == False, # noqa: E712
Project.is_deleted == False, Project.is_deleted == False, # noqa: E712
) )
) .group_by(Video.task_id)
user_completed_videos_count = user_completed_videos_result.scalar() or 0 .subquery()
logger.debug(
f"[get_videos] DEBUG - 현재 사용자 소유 + completed + 미삭제 Video 수: {user_completed_videos_count}"
) )
# 기본 조건: 현재 사용자 소유, completed 상태, 미삭제 # 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만)
base_conditions = [ count_query = select(func.count(Video.id)).where(
Project.user_uuid == current_user.user_uuid, Video.id.in_(select(latest_video_ids.c.latest_id))
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)
) )
total_result = await session.execute(count_query) total_result = await session.execute(count_query)
total = total_result.scalar() or 0 total = total_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - 전체 영상 개수 (total): {total}")
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회 (모든 영상) # 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
query = ( data_query = (
select(Video, Project) select(Video, Project)
.join(Project, Video.project_id == Project.id) .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()) .order_by(Video.created_at.desc())
.offset(offset) .offset(offset)
.limit(pagination.page_size) .limit(pagination.page_size)
) )
result = await session.execute(query) result = await session.execute(data_query)
rows = result.all() rows = result.all()
logger.debug(f"[get_videos] DEBUG - 최종 조회 결과 수: {len(rows)}")
# VideoListItem으로 변환 (JOIN 결과에서 바로 추출) # VideoListItem으로 변환
items = [] items = [
for video, project in rows: VideoListItem(
item = VideoListItem(
video_id=video.id, video_id=video.id,
store_name=project.store_name, store_name=project.store_name,
region=project.region, region=project.region,
@ -183,7 +121,8 @@ async def get_videos(
result_movie_url=video.result_movie_url, result_movie_url=video.result_movie_url,
created_at=video.created_at, created_at=video.created_at,
) )
items.append(item) for video, project in rows
]
response = PaginatedResponse.create( response = PaginatedResponse.create(
items=items, items=items,

View File

@ -291,15 +291,22 @@ def add_exception_handlers(app: FastAPI):
# SocialException 핸들러 추가 # SocialException 핸들러 추가
from app.social.exceptions import SocialException from app.social.exceptions import SocialException
from app.social.exceptions import TokenExpiredError
@app.exception_handler(SocialException) @app.exception_handler(SocialException)
def social_exception_handler(request: Request, exc: SocialException) -> Response: def social_exception_handler(request: Request, exc: SocialException) -> Response:
logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}") logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}")
return JSONResponse( content = {
status_code=exc.status_code,
content={
"detail": exc.message, "detail": exc.message,
"code": exc.code, "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=content,
) )
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR) @app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

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

View File

@ -59,7 +59,7 @@ class YouTubeOAuthClient(BaseOAuthClient):
"response_type": "code", "response_type": "code",
"scope": " ".join(YOUTUBE_SCOPES), "scope": " ".join(YOUTUBE_SCOPES),
"access_type": "offline", # refresh_token 받기 위해 필요 "access_type": "offline", # refresh_token 받기 위해 필요
"prompt": "select_account", # 계정 선택만 표시 (이전 동의 유지) "prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만)
"state": state, "state": state,
} }
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}" url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"

View File

@ -4,6 +4,7 @@ Social Account Service
소셜 계정 연동 관련 비즈니스 로직을 처리합니다. 소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
""" """
import json
import logging import logging
import secrets import secrets
from datetime import timedelta from datetime import timedelta
@ -27,10 +28,10 @@ redis_client = Redis(
decode_responses=True, decode_responses=True,
) )
from app.social.exceptions import ( from app.social.exceptions import (
InvalidStateError,
OAuthStateExpiredError, OAuthStateExpiredError,
SocialAccountAlreadyConnectedError, OAuthTokenRefreshError,
SocialAccountNotFoundError, SocialAccountNotFoundError,
TokenExpiredError,
) )
from app.social.oauth import get_oauth_client from app.social.oauth import get_oauth_client
from app.social.schemas import ( from app.social.schemas import (
@ -88,7 +89,7 @@ class SocialAccountService:
await redis_client.setex( await redis_client.setex(
state_key, state_key,
social_oauth_settings.OAUTH_STATE_TTL_SECONDS, social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
str(state_data), json.dumps(state_data), # JSON으로 직렬화
) )
logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}") logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}")
@ -124,9 +125,7 @@ class SocialAccountService:
SocialAccountResponse: 연동된 소셜 계정 정보 SocialAccountResponse: 연동된 소셜 계정 정보
Raises: Raises:
InvalidStateError: state 토큰이 유효하지 않은 경우 OAuthStateExpiredError: state 토큰이 만료되거나 유효하지 않은 경우
OAuthStateExpiredError: state 토큰이 만료된 경우
SocialAccountAlreadyConnectedError: 이미 연동된 계정인 경우
""" """
logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...") logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...")
@ -138,8 +137,8 @@ class SocialAccountService:
logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...") logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...")
raise OAuthStateExpiredError() raise OAuthStateExpiredError()
# state 데이터 파싱 # state 데이터 파싱 (JSON 역직렬화)
state_data = eval(state_data_str) # {"user_uuid": "...", "platform": "..."} state_data = json.loads(state_data_str)
user_uuid = state_data["user_uuid"] user_uuid = state_data["user_uuid"]
platform = SocialPlatform(state_data["platform"]) platform = SocialPlatform(state_data["platform"])
@ -211,13 +210,15 @@ class SocialAccountService:
self, self,
user_uuid: str, user_uuid: str,
session: AsyncSession, session: AsyncSession,
auto_refresh: bool = True,
) -> list[SocialAccountResponse]: ) -> list[SocialAccountResponse]:
""" """
연동된 소셜 계정 목록 조회 연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함)
Args: Args:
user_uuid: 사용자 UUID user_uuid: 사용자 UUID
session: DB 세션 session: DB 세션
auto_refresh: 토큰 자동 갱신 여부 (기본 True)
Returns: Returns:
list[SocialAccountResponse]: 연동된 계정 목록 list[SocialAccountResponse]: 연동된 계정 목록
@ -235,8 +236,97 @@ class SocialAccountService:
logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨") 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] 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( async def get_account_by_platform(
self, self,
user_uuid: str, user_uuid: str,
@ -404,18 +494,38 @@ class SocialAccountService:
Returns: Returns:
str: 유효한 access_token str: 유효한 access_token
Raises:
TokenExpiredError: 토큰 갱신 실패 (재연동 필요)
""" """
# 만료 시간 확인 (만료 10분 전이면 갱신) # 만료 시간 확인
if account.token_expires_at: is_expired = False
buffer_time = now() + timedelta(minutes=10) 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: 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( logger.info(
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
) )
return await self._refresh_account_token(account, session) return await self._refresh_account_token(account, session)
return account.access_token
async def _refresh_account_token( async def _refresh_account_token(
self, self,
account: SocialAccount, account: SocialAccount,
@ -430,28 +540,46 @@ class SocialAccountService:
Returns: Returns:
str: access_token str: access_token
Raises:
TokenExpiredError: 갱신 실패 (재연동 필요)
""" """
if not account.refresh_token: if not account.refresh_token:
logger.warning( 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) platform = SocialPlatform(account.platform)
oauth_client = get_oauth_client(platform) oauth_client = get_oauth_client(platform)
try:
token_response = await oauth_client.refresh_token(account.refresh_token) 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 account.access_token = token_response.access_token
if token_response.refresh_token: if token_response.refresh_token:
account.refresh_token = token_response.refresh_token account.refresh_token = token_response.refresh_token
if token_response.expires_in: 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 seconds=token_response.expires_in
) )
await session.commit() await session.commit()
await session.refresh(account)
logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}") logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}")
return account.access_token return account.access_token
@ -505,10 +633,10 @@ class SocialAccountService:
Returns: Returns:
SocialAccount: 생성된 소셜 계정 SocialAccount: 생성된 소셜 계정
""" """
# 토큰 만료 시간 계산 # 토큰 만료 시간 계산 (DB에 naive datetime으로 저장)
token_expires_at = None token_expires_at = None
if token_response.expires_in: if token_response.expires_in:
token_expires_at = now() + timedelta( token_expires_at = now().replace(tzinfo=None) + timedelta(
seconds=token_response.expires_in seconds=token_response.expires_in
) )
@ -561,7 +689,8 @@ class SocialAccountService:
if token_response.refresh_token: if token_response.refresh_token:
account.refresh_token = token_response.refresh_token account.refresh_token = token_response.refresh_token
if token_response.expires_in: 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 seconds=token_response.expires_in
) )
if token_response.scope: if token_response.scope:
@ -577,7 +706,7 @@ class SocialAccountService:
# 재연결 시 연결 시간 업데이트 # 재연결 시 연결 시간 업데이트
if update_connected_at: if update_connected_at:
account.connected_at = now() account.connected_at = now().replace(tzinfo=None)
await session.commit() await session.commit()
await session.refresh(account) await session.refresh(account)

View File

@ -20,7 +20,7 @@ from sqlalchemy.exc import SQLAlchemyError
from config import social_upload_settings from config import social_upload_settings
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.social.constants import SocialPlatform, UploadStatus 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.models import SocialUpload
from app.social.services import social_account_service from app.social.services import social_account_service
from app.social.uploader import get_uploader from app.social.uploader import get_uploader
@ -71,7 +71,7 @@ async def _update_upload_status(
if error_message: if error_message:
upload.error_message = error_message upload.error_message = error_message
if status == UploadStatus.COMPLETED: if status == UploadStatus.COMPLETED:
upload.uploaded_at = now() upload.uploaded_at = now().replace(tzinfo=None)
await session.commit() await session.commit()
logger.info( logger.info(
@ -353,6 +353,17 @@ async def process_social_upload(upload_id: int) -> None:
error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.", 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: except Exception as e:
logger.error( logger.error(
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - " f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "

View File

@ -34,10 +34,12 @@ from app.user.services import auth_service, kakao_client
from app.user.services.jwt import ( from app.user.services.jwt import (
create_access_token, create_access_token,
create_refresh_token, create_refresh_token,
decode_token,
get_access_token_expire_seconds, get_access_token_expire_seconds,
get_refresh_token_expires_at, get_refresh_token_expires_at,
get_token_hash, get_token_hash,
) )
from app.social.services import social_account_service
from app.utils.common import generate_uuid from app.utils.common import generate_uuid
@ -141,6 +143,19 @@ async def kakao_callback(
ip_address=ip_address, 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 = ( redirect_url = (
f"{prj_settings.PROJECT_DOMAIN}" f"{prj_settings.PROJECT_DOMAIN}"
@ -206,9 +221,20 @@ async def kakao_verify(
ip_address=ip_address, 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 return result
@ -435,7 +461,7 @@ async def generate_test_token(
session.add(db_refresh_token) session.add(db_refresh_token)
# 마지막 로그인 시간 업데이트 # 마지막 로그인 시간 업데이트
user.last_login_at = now() user.last_login_at = now().replace(tzinfo=None)
await session.commit() await session.commit()
logger.info( logger.info(

View File

@ -391,6 +391,7 @@ class RefreshToken(Base):
user: Mapped["User"] = relationship( user: Mapped["User"] = relationship(
"User", "User",
back_populates="refresh_tokens", back_populates="refresh_tokens",
lazy="selectin", # lazy loading 방지
) )
def __repr__(self) -> str: def __repr__(self) -> str:
@ -591,6 +592,7 @@ class SocialAccount(Base):
user: Mapped["User"] = relationship( user: Mapped["User"] = relationship(
"User", "User",
back_populates="social_accounts", back_populates="social_accounts",
lazy="selectin", # lazy loading 방지
) )
def __repr__(self) -> str: def __repr__(self) -> str:

View File

@ -168,7 +168,7 @@ class AuthService:
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}") logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
# 7. 마지막 로그인 시간 업데이트 # 7. 마지막 로그인 시간 업데이트
user.last_login_at = now() user.last_login_at = now().replace(tzinfo=None)
await session.commit() await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}" redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
@ -223,7 +223,7 @@ class AuthService:
if db_token.is_revoked: if db_token.is_revoked:
raise TokenRevokedError() raise TokenRevokedError()
if db_token.expires_at < now(): if db_token.expires_at < now().replace(tzinfo=None):
raise TokenExpiredError() raise TokenExpiredError()
# 4. 사용자 확인 # 4. 사용자 확인
@ -483,7 +483,7 @@ class AuthService:
.where(RefreshToken.token_hash == token_hash) .where(RefreshToken.token_hash == token_hash)
.values( .values(
is_revoked=True, is_revoked=True,
revoked_at=now(), revoked_at=now().replace(tzinfo=None),
) )
) )
await session.commit() await session.commit()
@ -508,7 +508,7 @@ class AuthService:
) )
.values( .values(
is_revoked=True, is_revoked=True,
revoked_at=now(), revoked_at=now().replace(tzinfo=None),
) )
) )
await session.commit() await session.commit()

View File

@ -107,7 +107,7 @@ def get_refresh_token_expires_at() -> datetime:
Returns: Returns:
리프레시 토큰 만료 datetime (로컬 시간) 리프레시 토큰 만료 datetime (로컬 시간)
""" """
return now() + timedelta( return now().replace(tzinfo=None) + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
) )

View File

@ -15,7 +15,8 @@ class NvMapPwScraper():
_context = None _context = None
_win_width = 1280 _win_width = 1280
_win_height = 720 _win_height = 720
_max_retry = 60 # place id timeout threshold seconds _max_retry = 3
_timeout = 60 # place id timeout threshold seconds
# instance var # instance var
page = None page = None
@ -97,7 +98,7 @@ patchedGetter.toString();''')
async def get_place_id_url(self, selected): async def get_place_id_url(self, selected):
count = 0 count = 0
get_place_id_url_start = time.perf_counter() get_place_id_url_start = time.perf_counter()
while (count <= 1): while (count <= self._max_retry):
title = selected['title'].replace("<b>", "").replace("</b>", "") title = selected['title'].replace("<b>", "").replace("</b>", "")
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "") address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
encoded_query = parse.quote(f"{address} {title}") encoded_query = parse.quote(f"{address} {title}")
@ -106,9 +107,12 @@ patchedGetter.toString();''')
wait_first_start = time.perf_counter() wait_first_start = time.perf_counter()
try: 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: 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 wait_first_time = (time.perf_counter() - wait_first_start) * 1000
@ -123,9 +127,11 @@ patchedGetter.toString();''')
url = self.page.url.replace("?","?isCorrectAnswer=true&") url = self.page.url.replace("?","?isCorrectAnswer=true&")
try: 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: 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 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") logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms")

163
timezone-check.md Normal file
View File

@ -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`에 주석 존재).