diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index e0cb037..ceafe89 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -33,7 +33,7 @@ from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError from app.utils.common import generate_task_id from app.utils.logger import get_logger from app.utils.nvMapScraper import NvMapScraper, GraphQLException -from app.utils.nvMapPwScraper import NvMapPwScraper +from app.utils.nvMapPwScraper import NvMapPwScraper from app.utils.prompts.prompts import marketing_prompt from config import MEDIA_ROOT diff --git a/app/sns/api/routers/v1/sns.py b/app/sns/api/routers/v1/sns.py index e282b79..3d398ea 100644 --- a/app/sns/api/routers/v1/sns.py +++ b/app/sns/api/routers/v1/sns.py @@ -10,6 +10,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_session from app.sns.schemas.sns_schema import InstagramUploadRequest, InstagramUploadResponse +from app.user.dependencies.auth import get_current_user +from app.user.models import Platform, SocialAccount, User +from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error +from app.utils.logger import get_logger +from app.video.models import Video + +logger = get_logger(__name__) # ============================================================================= @@ -80,13 +87,7 @@ class InstagramContainerError(SNSException): def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."): super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message) -from app.user.dependencies.auth import get_current_user -from app.user.models import Platform, SocialAccount, User -from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error -from app.utils.logger import get_logger -from app.video.models import Video -logger = get_logger(__name__) router = APIRouter(prefix="/sns", tags=["SNS"]) diff --git a/app/social/services.py b/app/social/services.py index 931e1a9..06e7a58 100644 --- a/app/social/services.py +++ b/app/social/services.py @@ -6,10 +6,12 @@ Social Account Service import logging import secrets -from datetime import datetime, timedelta +from datetime import timedelta from typing import Optional from sqlalchemy import select + +from app.utils.timezone import now from sqlalchemy.ext.asyncio import AsyncSession from redis.asyncio import Redis @@ -405,7 +407,7 @@ class SocialAccountService: """ # 만료 시간 확인 (만료 10분 전이면 갱신) if account.token_expires_at: - buffer_time = datetime.now() + timedelta(minutes=10) + buffer_time = now() + timedelta(minutes=10) if account.token_expires_at <= buffer_time: logger.info( f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" @@ -445,7 +447,7 @@ class SocialAccountService: if token_response.refresh_token: account.refresh_token = token_response.refresh_token if token_response.expires_in: - account.token_expires_at = datetime.now() + timedelta( + account.token_expires_at = now() + timedelta( seconds=token_response.expires_in ) @@ -506,7 +508,7 @@ class SocialAccountService: # 토큰 만료 시간 계산 token_expires_at = None if token_response.expires_in: - token_expires_at = datetime.now() + timedelta( + token_expires_at = now() + timedelta( seconds=token_response.expires_in ) @@ -559,7 +561,7 @@ class SocialAccountService: if token_response.refresh_token: account.refresh_token = token_response.refresh_token if token_response.expires_in: - account.token_expires_at = datetime.now() + timedelta( + account.token_expires_at = now() + timedelta( seconds=token_response.expires_in ) if token_response.scope: @@ -575,7 +577,7 @@ class SocialAccountService: # 재연결 시 연결 시간 업데이트 if update_connected_at: - account.connected_at = datetime.now() + account.connected_at = now() await session.commit() await session.refresh(account) diff --git a/app/social/worker/upload_task.py b/app/social/worker/upload_task.py index 4326687..15b4b1a 100644 --- a/app/social/worker/upload_task.py +++ b/app/social/worker/upload_task.py @@ -7,11 +7,12 @@ Social Upload Background Task import logging import os import tempfile -from datetime import datetime from pathlib import Path from typing import Optional import aiofiles + +from app.utils.timezone import now import httpx from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError @@ -70,7 +71,7 @@ async def _update_upload_status( if error_message: upload.error_message = error_message if status == UploadStatus.COMPLETED: - upload.uploaded_at = datetime.now() + upload.uploaded_at = now() await session.commit() logger.info( diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index 2c730f9..c31df39 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -6,10 +6,11 @@ import logging import random -from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, Header, HTTPException, Request, status + +from app.utils.timezone import now from fastapi.responses import RedirectResponse, Response from pydantic import BaseModel from sqlalchemy import select @@ -434,7 +435,7 @@ async def generate_test_token( session.add(db_refresh_token) # 마지막 로그인 시간 업데이트 - user.last_login_at = datetime.now(timezone.utc) + user.last_login_at = now() await session.commit() logger.info( diff --git a/app/user/models.py b/app/user/models.py index 79cba9a..2e00f14 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -522,7 +522,7 @@ class SocialAccount(Base): # ========================================================================== # 플랫폼 계정 식별 정보 # ========================================================================== - platform_user_id: Mapped[str] = mapped_column( + platform_user_id: Mapped[Optional[str]] = mapped_column( String(100), nullable=True, comment="플랫폼 내 사용자 고유 ID", diff --git a/app/user/services/auth.py b/app/user/services/auth.py index bdea105..aa648c1 100644 --- a/app/user/services/auth.py +++ b/app/user/services/auth.py @@ -5,10 +5,11 @@ """ import logging -from datetime import datetime from typing import Optional from fastapi import HTTPException, status + +from app.utils.timezone import now from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -167,7 +168,7 @@ class AuthService: logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}") # 7. 마지막 로그인 시간 업데이트 - user.last_login_at = datetime.now() + user.last_login_at = now() await session.commit() redirect_url = f"{prj_settings.PROJECT_DOMAIN}" @@ -222,7 +223,7 @@ class AuthService: if db_token.is_revoked: raise TokenRevokedError() - if db_token.expires_at < datetime.now(): + if db_token.expires_at < now(): raise TokenExpiredError() # 4. 사용자 확인 @@ -482,7 +483,7 @@ class AuthService: .where(RefreshToken.token_hash == token_hash) .values( is_revoked=True, - revoked_at=datetime.now(), + revoked_at=now(), ) ) await session.commit() @@ -507,7 +508,7 @@ class AuthService: ) .values( is_revoked=True, - revoked_at=datetime.now(), + revoked_at=now(), ) ) await session.commit() diff --git a/app/user/services/jwt.py b/app/user/services/jwt.py index 12b60c6..4f38658 100644 --- a/app/user/services/jwt.py +++ b/app/user/services/jwt.py @@ -10,6 +10,7 @@ from typing import Optional from jose import JWTError, jwt +from app.utils.timezone import now from config import jwt_settings @@ -23,7 +24,7 @@ def create_access_token(user_uuid: str) -> str: Returns: JWT 액세스 토큰 문자열 """ - expire = datetime.now() + timedelta( + expire = now() + timedelta( minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES ) to_encode = { @@ -48,7 +49,7 @@ def create_refresh_token(user_uuid: str) -> str: Returns: JWT 리프레시 토큰 문자열 """ - expire = datetime.now() + timedelta( + expire = now() + timedelta( days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS ) to_encode = { @@ -106,7 +107,7 @@ def get_refresh_token_expires_at() -> datetime: Returns: 리프레시 토큰 만료 datetime (로컬 시간) """ - return datetime.now() + timedelta( + return now() + timedelta( days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS ) diff --git a/app/utils/logger.py b/app/utils/logger.py index 24ac7e4..2226a1a 100644 --- a/app/utils/logger.py +++ b/app/utils/logger.py @@ -20,11 +20,11 @@ Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템. import logging import sys -from datetime import datetime from functools import lru_cache from logging.handlers import RotatingFileHandler from typing import Literal +from app.utils.timezone import today_str from config import log_settings # 로그 디렉토리 설정 (config.py의 LogSettings에서 관리) @@ -86,7 +86,7 @@ def _get_shared_file_handler() -> RotatingFileHandler: global _shared_file_handler if _shared_file_handler is None: - today = datetime.today().strftime("%Y-%m-%d") + today = today_str() log_file = LOG_DIR / f"{today}_app.log" _shared_file_handler = RotatingFileHandler( @@ -116,7 +116,7 @@ def _get_shared_error_handler() -> RotatingFileHandler: global _shared_error_handler if _shared_error_handler is None: - today = datetime.today().strftime("%Y-%m-%d") + today = today_str() log_file = LOG_DIR / f"{today}_error.log" _shared_error_handler = RotatingFileHandler( diff --git a/app/utils/timezone.py b/app/utils/timezone.py new file mode 100644 index 0000000..1ba1091 --- /dev/null +++ b/app/utils/timezone.py @@ -0,0 +1,42 @@ +""" +타임존 유틸리티 + +프로젝트 전역에서 일관된 서울 타임존(Asia/Seoul) 시간을 사용하기 위한 유틸리티입니다. +모든 datetime.now() 호출은 이 모듈의 함수로 대체해야 합니다. +""" + +from datetime import datetime + +from config import TIMEZONE + + +def now() -> datetime: + """ + 서울 타임존(Asia/Seoul) 기준 현재 시간을 반환합니다. + + Returns: + datetime: 서울 타임존이 적용된 현재 시간 (aware datetime) + + Example: + >>> from app.utils.timezone import now + >>> current_time = now() # 2024-01-15 15:30:00+09:00 + """ + return datetime.now(TIMEZONE) + + +def today_str(fmt: str = "%Y-%m-%d") -> str: + """ + 서울 타임존 기준 오늘 날짜를 문자열로 반환합니다. + + Args: + fmt: 날짜 포맷 (기본값: YYYY-MM-DD) + + Returns: + str: 포맷된 날짜 문자열 + + Example: + >>> from app.utils.timezone import today_str + >>> today_str() # "2024-01-15" + >>> today_str("%Y/%m/%d") # "2024/01/15" + """ + return datetime.now(TIMEZONE).strftime(fmt) diff --git a/config.py b/config.py index 003de5f..b0c94aa 100644 --- a/config.py +++ b/config.py @@ -1,8 +1,12 @@ from pathlib import Path +from zoneinfo import ZoneInfo from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +# 프로젝트 전역 타임존 설정 (서울) +TIMEZONE = ZoneInfo("Asia/Seoul") + PROJECT_DIR = Path(__file__).resolve().parent # 미디어 파일 저장 디렉토리 @@ -156,8 +160,8 @@ class CreatomateSettings(BaseSettings): description="가로형 템플릿 기본 duration (초)", ) DEBUG_AUTO_LYRIC: bool = Field( - default = False, - description = "Creatomate 자동 가사 생성 기능 사용 여부" + default=False, + description="Creatomate 자동 가사 생성 기능 사용 여부", ) model_config = _base_config diff --git a/docs/plan/timezone_plan.md b/docs/plan/timezone_plan.md new file mode 100644 index 0000000..00d215c --- /dev/null +++ b/docs/plan/timezone_plan.md @@ -0,0 +1,226 @@ +# 타임존 설정 작업 계획 + +## 개요 +프로젝트 전체에 서울 타임존(Asia/Seoul, UTC+9)을 일관되게 적용합니다. + +--- + +## 현재 상태 + +### 완료된 설정 +| 구성 요소 | 상태 | 비고 | +|----------|------|------| +| **MySQL 데이터베이스** | ✅ 완료 | DB 생성 시 Asia/Seoul로 설정됨 | +| **config.py** | ✅ 완료 | `TIMEZONE = ZoneInfo("Asia/Seoul")` 설정됨 | +| **SQLAlchemy 모델** | ✅ 정상 | `server_default=func.now()` → DB 타임존(서울) 따름 | + +### 문제점 +- Python 코드에서 `datetime.now()` (naive) 또는 `datetime.now(timezone.utc)` (UTC) 혼용 +- 타임존이 적용된 현재 시간을 얻는 공통 유틸리티 없음 + +--- + +## 수정 대상 파일 + +| 파일 | 라인 | 현재 코드 | 문제 | +|------|------|----------|------| +| `app/user/services/jwt.py` | 26, 51, 109 | `datetime.now()` | naive datetime | +| `app/user/services/auth.py` | 170, 225, 485, 510 | `datetime.now()` | naive datetime | +| `app/user/api/routers/v1/auth.py` | 437 | `datetime.now(timezone.utc)` | UTC 사용 (서울 아님) | +| `app/social/services.py` | 408, 448, 509, 562, 578 | `datetime.now()` | naive datetime | +| `app/social/worker/upload_task.py` | 73 | `datetime.now()` | naive datetime | +| `app/utils/logger.py` | 89, 119 | `datetime.today()` | naive datetime | + +--- + +## 작업 단계 + +### 1단계: 타임존 유틸리티 생성 (신규 파일) + +**파일**: `app/utils/timezone.py` + +```python +""" +타임존 유틸리티 + +프로젝트 전역에서 일관된 서울 타임존(Asia/Seoul) 시간을 사용하기 위한 유틸리티입니다. +모든 datetime.now() 호출은 이 모듈의 함수로 대체해야 합니다. +""" +from datetime import datetime + +from config import TIMEZONE + + +def now() -> datetime: + """ + 서울 타임존(Asia/Seoul) 기준 현재 시간을 반환합니다. + + Returns: + datetime: 서울 타임존이 적용된 현재 시간 (aware datetime) + + Example: + >>> from app.utils.timezone import now + >>> current_time = now() # 2024-01-15 15:30:00+09:00 + """ + return datetime.now(TIMEZONE) + + +def today_str(fmt: str = "%Y-%m-%d") -> str: + """ + 서울 타임존 기준 오늘 날짜를 문자열로 반환합니다. + + Args: + fmt: 날짜 포맷 (기본값: YYYY-MM-DD) + + Returns: + str: 포맷된 날짜 문자열 + + Example: + >>> from app.utils.timezone import today_str + >>> today_str() # "2024-01-15" + >>> today_str("%Y/%m/%d") # "2024/01/15" + """ + return datetime.now(TIMEZONE).strftime(fmt) +``` + +### 2단계: 기존 코드 수정 + +모든 `datetime.now()`, `datetime.today()`, `datetime.now(timezone.utc)`를 타임존 유틸리티 함수로 교체합니다. + +#### 2.1 app/user/services/jwt.py + +```python +# Before +from datetime import datetime, timedelta + +expire = datetime.now() + timedelta(...) + +# After +from datetime import timedelta +from app.utils.timezone import now + +expire = now() + timedelta(...) +``` + +#### 2.2 app/user/services/auth.py + +```python +# Before +from datetime import datetime + +user.last_login_at = datetime.now() +if db_token.expires_at < datetime.now(): +revoked_at=datetime.now() + +# After +from app.utils.timezone import now + +user.last_login_at = now() +if db_token.expires_at < now(): +revoked_at=now() +``` + +#### 2.3 app/user/api/routers/v1/auth.py + +```python +# Before +from datetime import datetime, timezone + +user.last_login_at = datetime.now(timezone.utc) + +# After +from app.utils.timezone import now + +user.last_login_at = now() +``` + +#### 2.4 app/social/services.py + +```python +# Before +from datetime import datetime, timedelta + +buffer_time = datetime.now() + timedelta(minutes=10) +account.token_expires_at = datetime.now() + timedelta(...) +account.connected_at = datetime.now() + +# After +from datetime import timedelta +from app.utils.timezone import now + +buffer_time = now() + timedelta(minutes=10) +account.token_expires_at = now() + timedelta(...) +account.connected_at = now() +``` + +#### 2.5 app/social/worker/upload_task.py + +```python +# Before +from datetime import datetime + +upload.uploaded_at = datetime.now() + +# After +from app.utils.timezone import now + +upload.uploaded_at = now() +``` + +#### 2.6 app/utils/logger.py + +```python +# Before +from datetime import datetime + +today = datetime.today().strftime("%Y-%m-%d") + +# After +from app.utils.timezone import today_str + +today = today_str() +``` + +> **참고**: logger.py에서는 **날짜만 사용하는 것이 맞습니다.** +> 로그 파일명이 `{날짜}_app.log`, `{날짜}_error.log` 형식이므로 일별 로그 파일 관리에 적합합니다. +> 시간까지 포함하면 매 시간/분마다 새 파일이 생성되어 로그 관리가 어려워집니다. + +--- + +## 작업 체크리스트 + +| 순서 | 작업 | 파일 | 상태 | +|------|------|------|------| +| 1 | 타임존 유틸리티 생성 | `app/utils/timezone.py` | ✅ 완료 | +| 2 | JWT 서비스 수정 | `app/user/services/jwt.py` | ✅ 완료 | +| 3 | Auth 서비스 수정 | `app/user/services/auth.py` | ✅ 완료 | +| 4 | Auth 라우터 수정 | `app/user/api/routers/v1/auth.py` | ✅ 완료 | +| 5 | Social 서비스 수정 | `app/social/services.py` | ✅ 완료 | +| 6 | Upload Task 수정 | `app/social/worker/upload_task.py` | ✅ 완료 | +| 7 | Logger 유틸리티 수정 | `app/utils/logger.py` | ✅ 완료 | + +--- + +## 예상 작업 범위 + +- **신규 파일**: 1개 (`app/utils/timezone.py`) +- **수정 파일**: 6개 +- **수정 위치**: 약 15곳 + +--- + +## 참고 사항 + +### naive datetime vs aware datetime +- **naive datetime**: 타임존 정보가 없는 datetime (예: `datetime.now()`) +- **aware datetime**: 타임존 정보가 있는 datetime (예: `datetime.now(TIMEZONE)`) + +### 왜 서울 타임존을 사용하는가? +1. 데이터베이스가 Asia/Seoul로 설정됨 +2. 서비스 대상 지역이 한국 +3. Python 코드와 DB 간 시간 일관성 확보 + +### 주의사항 +- 기존 DB에 저장된 시간 데이터는 이미 서울 시간이므로 마이그레이션 불필요 +- JWT 토큰 만료 시간 비교 시 타임존 일관성 필수 +- 로그 파일명에 사용되는 날짜도 서울 기준으로 통일