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