Merge branch 'main' into youtube-description
commit
f1dd675ecb
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
|
||||||
status_code=exc.status_code,
|
|
||||||
content = {
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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)}"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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] 예상치 못한 에러 - "
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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`에 주석 존재).
|
||||||
Loading…
Reference in New Issue