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 2ee4fcd..6e7936b 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 @@ -305,7 +307,7 @@ class SocialAccountService: if account.token_expires_at is None: should_refresh = True else: - buffer_time = datetime.now() + timedelta(hours=1) + buffer_time = now() + timedelta(hours=1) if account.token_expires_at <= buffer_time: should_refresh = True @@ -512,7 +514,7 @@ class SocialAccountService: ) should_refresh = True else: - 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}" @@ -571,7 +573,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 ) @@ -632,7 +634,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 ) @@ -685,7 +687,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: @@ -701,7 +703,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 7e00a90..5dd727a 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 79796a2..9a6e22c 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 @@ -460,7 +461,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..2c734dc 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -344,7 +344,6 @@ class RefreshToken(Base): token_hash: Mapped[str] = mapped_column( String(64), nullable=False, - unique=True, comment="리프레시 토큰 SHA-256 해시값", ) @@ -522,7 +521,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/creatomate.py b/app/utils/creatomate.py index abbfd51..3e99f20 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -122,7 +122,38 @@ text_template_v_2 = { } ] } - +text_template_v_3 = { + "type": "composition", + "track": 3, + "elements": [ + { + "type": "text", + "time": 0, + "x": "0%", + "y": "80%", + "width": "100%", + "height": "15%", + "x_anchor": "0%", + "y_anchor": "0%", + "x_alignment": "50%", + "y_alignment": "50%", + "font_family": "Noto Sans", + "font_weight": "700", + "font_size_maximum": "7 vmin", + "fill_color": "#ffffff", + "animations": [ + { + "time": 0, + "duration": 1, + "easing": "quadratic-out", + "type": "text-wave", + "split": "line", + "overlap": "50%" + } + ] + } + ] +} text_template_h_1 = { "type": "composition", "track": 3, @@ -408,6 +439,7 @@ class CreatomateService: image_url_list: list[str], lyric: str, music_url: str, + address: str = None ) -> dict: """템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다. @@ -434,9 +466,8 @@ class CreatomateService: idx % len(image_url_list) ] case "text": - modifications[template_component_name] = lyric_splited[ - idx % len(lyric_splited) - ] + if "address_input" in template_component_name: + modifications[template_component_name] = address modifications["audio-music"] = music_url @@ -448,12 +479,11 @@ class CreatomateService: image_url_list: list[str], lyric: str, music_url: str, + address: str = None ) -> dict: """elements 정보와 이미지/가사/음악 리소스를 매핑합니다.""" template_component_data = self.parse_template_component_name(elements) - lyric = lyric.replace("\r", "") - lyric_splited = lyric.split("\n") modifications = {} for idx, (template_component_name, template_type) in enumerate( @@ -465,9 +495,8 @@ class CreatomateService: idx % len(image_url_list) ] case "text": - modifications[template_component_name] = lyric_splited[ - idx % len(lyric_splited) - ] + if "address_input" in template_component_name: + modifications[template_component_name] = address modifications["audio-music"] = music_url @@ -486,7 +515,8 @@ class CreatomateService: case "video": element["source"] = modification[element["name"]] case "text": - element["source"] = modification.get(element["name"], "") + #element["source"] = modification[element["name"]] + element["text"] = modification.get(element["name"], "") case "composition": for minor in element["elements"]: recursive_modify(minor) @@ -704,8 +734,8 @@ class CreatomateService: def extend_template_duration(self, template: dict, target_duration: float) -> dict: """템플릿의 duration을 target_duration으로 확장합니다.""" - target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것 - template["duration"] = target_duration + template["duration"] = target_duration + 0.5 # 늘린것보단 짧게 + target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것 total_template_duration = self.calc_scene_duration(template) extend_rate = target_duration / total_template_duration new_template = copy.deepcopy(template) @@ -727,7 +757,7 @@ class CreatomateService: return new_template - def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float) -> dict: + def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float, font_family: str = "Noto Sans") -> dict: duration = end_sec - start_sec text_scene = copy.deepcopy(text_template) text_scene["name"] = f"Caption-{lyric_index}" @@ -735,6 +765,7 @@ class CreatomateService: text_scene["time"] = start_sec text_scene["elements"][0]["name"] = f"lyric-{lyric_index}" text_scene["elements"][0]["text"] = lyric_text + text_scene["elements"][0]["font_family"] = font_family return text_scene def auto_lyric(self, auto_text_template : dict): @@ -744,7 +775,7 @@ class CreatomateService: def get_text_template(self): match self.orientation: case "vertical": - return text_template_v_2 + return text_template_v_3 case "horizontal": return text_template_h_1 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/nvMapPwScraper.py b/app/utils/nvMapPwScraper.py index cadc492..2885242 100644 --- a/app/utils/nvMapPwScraper.py +++ b/app/utils/nvMapPwScraper.py @@ -1,6 +1,11 @@ import asyncio from playwright.async_api import async_playwright from urllib import parse +import time +from app.utils.logger import get_logger + +# 로거 설정 +logger = get_logger("pwscraper") class NvMapPwScraper(): # cls vars @@ -91,25 +96,46 @@ patchedGetter.toString();''') async def get_place_id_url(self, selected): count = 0 + get_place_id_url_start = time.perf_counter() while (count <= 1): title = selected['title'].replace("", "").replace("", "") address = selected.get('roadAddress', selected['address']).replace("", "").replace("", "") encoded_query = parse.quote(f"{address} {title}") url = f"https://map.naver.com/p/search/{encoded_query}" + + wait_first_start = time.perf_counter() - await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + try: + await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + except: + await self.page.reload(wait_until="networkidle", timeout = self._max_retry/2*1000) + + wait_first_time = (time.perf_counter() - wait_first_start) * 1000 + + logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms") if "/place/" in self.page.url: return self.page.url + + logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer") + wait_forced_correct_start = time.perf_counter() + url = self.page.url.replace("?","?isCorrectAnswer=true&") - await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + try: + await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + except: + await self.page.reload(wait_until="networkidle", timeout = self._max_retry/2*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") if "/place/" in self.page.url: return self.page.url count += 1 - print("Not found url for {selected}") + logger.error("[ERROR] Not found url for {selected}") + return None # 404 diff --git a/app/utils/suno.py b/app/utils/suno.py index 87611ef..22e4af7 100644 --- a/app/utils/suno.py +++ b/app/utils/suno.py @@ -163,7 +163,8 @@ class SunoService: if data.get("code") != 200: error_msg = data.get("msg", "Unknown error") - raise SunoResponseError(f"Suno API error: {error_msg}", original_response=data) + logger.error(f"[Suno] API error: {error_msg} | response: {data}") + raise SunoResponseError("api 에러입니다.", original_response=data) response_data = data.get("data") if response_data is None: 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/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 15f32f4..b203082 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -201,6 +201,7 @@ async def generate_video( detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", ) project_id = project.id + store_address = project.detail_region_info # ===== 결과 처리: Lyric ===== lyric = lyric_result.scalar_one_or_none() @@ -211,6 +212,7 @@ async def generate_video( detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", ) lyric_id = lyric.id + lyric_language = lyric.language # ===== 결과 처리: Song ===== song = song_result.scalar_one_or_none() @@ -313,6 +315,7 @@ async def generate_video( image_url_list=image_urls, lyric=lyrics, music_url=music_url, + address=store_address ) logger.debug(f"[generate_video] Modifications created - task_id: {task_id}") @@ -333,8 +336,6 @@ async def generate_video( f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}" ) - # 이런거 추가해야하는데 AI가 자꾸 번호 달면 제가 번호를 다 밀어야 하나요? - song_timestamp_result = await session.execute( select(SongTimestamp).where( SongTimestamp.suno_audio_id == song.suno_audio_id @@ -350,6 +351,13 @@ async def generate_video( f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}" ) + match lyric_language: + case "English" : + lyric_font = "Noto Sans" + # lyric_font = "Pretendard" # 없어요 + case _ : + lyric_font = "Noto Sans" + # LYRIC AUTO 결정부 if (creatomate_settings.DEBUG_AUTO_LYRIC): auto_text_template = creatomate_service.get_auto_text_template() @@ -363,6 +371,7 @@ async def generate_video( aligned.lyric_line, aligned.start_time, aligned.end_time, + lyric_font ) final_template["source"]["elements"].append(caption) # END - LYRIC AUTO 결정부 diff --git a/config.py b/config.py index 003de5f..94c7c7c 100644 --- a/config.py +++ b/config.py @@ -1,10 +1,19 @@ +import os from pathlib import Path +from zoneinfo import ZoneInfo +from dotenv import load_dotenv from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict PROJECT_DIR = Path(__file__).resolve().parent +# .env 파일 로드 (Settings 클래스보다 먼저 TIMEZONE을 사용하기 위함) +load_dotenv(PROJECT_DIR / ".env") + +# 프로젝트 전역 타임존 설정 (기본값: 서울) +TIMEZONE = ZoneInfo(os.getenv("TIMEZONE", "Asia/Seoul")) + # 미디어 파일 저장 디렉토리 MEDIA_ROOT = PROJECT_DIR / "media" MEDIA_ROOT.mkdir(exist_ok=True) @@ -23,6 +32,10 @@ class ProjectSettings(BaseSettings): DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트") ADMIN_BASE_URL: str = Field(default="/admin") DEBUG: bool = Field(default=True) + TIMEZONE: str = Field( + default="Asia/Seoul", + description="프로젝트 전역 타임존 (예: Asia/Seoul, UTC, America/New_York)", + ) model_config = _base_config @@ -156,8 +169,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 토큰 만료 시간 비교 시 타임존 일관성 필수 +- 로그 파일명에 사용되는 날짜도 서울 기준으로 통일 diff --git a/pyproject.toml b/pyproject.toml index 4380915..751a21c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "openai>=2.13.0", "playwright>=1.57.0", "pydantic-settings>=2.12.0", + "python-dotenv>=1.0.0", "python-jose[cryptography]>=3.5.0", "python-multipart>=0.0.21", "redis>=7.1.0", diff --git a/uv.lock b/uv.lock index 2d00834..35c2838 100644 --- a/uv.lock +++ b/uv.lock @@ -657,6 +657,7 @@ dependencies = [ { name = "openai" }, { name = "playwright" }, { name = "pydantic-settings" }, + { name = "python-dotenv" }, { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "redis" }, @@ -685,6 +686,7 @@ requires-dist = [ { name = "openai", specifier = ">=2.13.0" }, { name = "playwright", specifier = ">=1.57.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "python-multipart", specifier = ">=0.0.21" }, { name = "redis", specifier = ">=7.1.0" },