Merge remote-tracking branch 'origin/main' - resolve conflict

- Resolved conflict in app/social/services.py
- Kept token auto-refresh logic with now() function for timezone support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
get_video
hbyang 2026-02-09 11:04:14 +09:00
commit 40afe9392c
18 changed files with 410 additions and 53 deletions

View File

@ -10,6 +10,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session from app.database.session import get_session
from app.sns.schemas.sns_schema import InstagramUploadRequest, InstagramUploadResponse 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 미디어 처리에 실패했습니다."): def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."):
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message) 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"]) router = APIRouter(prefix="/sns", tags=["SNS"])

View File

@ -6,10 +6,12 @@ Social Account Service
import logging import logging
import secrets import secrets
from datetime import datetime, timedelta from datetime import timedelta
from typing import Optional from typing import Optional
from sqlalchemy import select from sqlalchemy import select
from app.utils.timezone import now
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from redis.asyncio import Redis from redis.asyncio import Redis
@ -305,7 +307,7 @@ class SocialAccountService:
if account.token_expires_at is None: if account.token_expires_at is None:
should_refresh = True should_refresh = True
else: else:
buffer_time = datetime.now() + timedelta(hours=1) buffer_time = now() + timedelta(hours=1)
if account.token_expires_at <= buffer_time: if account.token_expires_at <= buffer_time:
should_refresh = True should_refresh = True
@ -512,7 +514,7 @@ class SocialAccountService:
) )
should_refresh = True should_refresh = True
else: else:
buffer_time = datetime.now() + timedelta(minutes=10) buffer_time = now() + timedelta(minutes=10)
if account.token_expires_at <= buffer_time: if account.token_expires_at <= buffer_time:
logger.info( logger.info(
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
@ -571,7 +573,7 @@ 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 = datetime.now() + timedelta( account.token_expires_at = now() + timedelta(
seconds=token_response.expires_in seconds=token_response.expires_in
) )
@ -632,7 +634,7 @@ class SocialAccountService:
# 토큰 만료 시간 계산 # 토큰 만료 시간 계산
token_expires_at = None token_expires_at = None
if token_response.expires_in: if token_response.expires_in:
token_expires_at = datetime.now() + timedelta( token_expires_at = now() + timedelta(
seconds=token_response.expires_in seconds=token_response.expires_in
) )
@ -685,7 +687,7 @@ 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 = datetime.now() + timedelta( account.token_expires_at = now() + timedelta(
seconds=token_response.expires_in seconds=token_response.expires_in
) )
if token_response.scope: if token_response.scope:
@ -701,7 +703,7 @@ class SocialAccountService:
# 재연결 시 연결 시간 업데이트 # 재연결 시 연결 시간 업데이트
if update_connected_at: if update_connected_at:
account.connected_at = datetime.now() account.connected_at = now()
await session.commit() await session.commit()
await session.refresh(account) await session.refresh(account)

View File

@ -7,11 +7,12 @@ Social Upload Background Task
import logging import logging
import os import os
import tempfile import tempfile
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import aiofiles import aiofiles
from app.utils.timezone import now
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
@ -70,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 = datetime.now() upload.uploaded_at = now()
await session.commit() await session.commit()
logger.info( logger.info(

View File

@ -6,10 +6,11 @@
import logging import logging
import random import random
from datetime import datetime, timezone
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from app.utils.timezone import now
from fastapi.responses import RedirectResponse, Response from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
@ -460,7 +461,7 @@ async def generate_test_token(
session.add(db_refresh_token) session.add(db_refresh_token)
# 마지막 로그인 시간 업데이트 # 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc) user.last_login_at = now()
await session.commit() await session.commit()
logger.info( logger.info(

View File

@ -344,7 +344,6 @@ class RefreshToken(Base):
token_hash: Mapped[str] = mapped_column( token_hash: Mapped[str] = mapped_column(
String(64), String(64),
nullable=False, nullable=False,
unique=True,
comment="리프레시 토큰 SHA-256 해시값", 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), String(100),
nullable=True, nullable=True,
comment="플랫폼 내 사용자 고유 ID", comment="플랫폼 내 사용자 고유 ID",

View File

@ -5,10 +5,11 @@
""" """
import logging import logging
from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from app.utils.timezone import now
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession 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}") logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
# 7. 마지막 로그인 시간 업데이트 # 7. 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now() user.last_login_at = now()
await session.commit() await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}" redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
@ -222,7 +223,7 @@ class AuthService:
if db_token.is_revoked: if db_token.is_revoked:
raise TokenRevokedError() raise TokenRevokedError()
if db_token.expires_at < datetime.now(): if db_token.expires_at < now():
raise TokenExpiredError() raise TokenExpiredError()
# 4. 사용자 확인 # 4. 사용자 확인
@ -482,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=datetime.now(), revoked_at=now(),
) )
) )
await session.commit() await session.commit()
@ -507,7 +508,7 @@ class AuthService:
) )
.values( .values(
is_revoked=True, is_revoked=True,
revoked_at=datetime.now(), revoked_at=now(),
) )
) )
await session.commit() await session.commit()

View File

@ -10,6 +10,7 @@ from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from app.utils.timezone import now
from config import jwt_settings from config import jwt_settings
@ -23,7 +24,7 @@ def create_access_token(user_uuid: str) -> str:
Returns: Returns:
JWT 액세스 토큰 문자열 JWT 액세스 토큰 문자열
""" """
expire = datetime.now() + timedelta( expire = now() + timedelta(
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
) )
to_encode = { to_encode = {
@ -48,7 +49,7 @@ def create_refresh_token(user_uuid: str) -> str:
Returns: Returns:
JWT 리프레시 토큰 문자열 JWT 리프레시 토큰 문자열
""" """
expire = datetime.now() + timedelta( expire = now() + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
) )
to_encode = { to_encode = {
@ -106,7 +107,7 @@ def get_refresh_token_expires_at() -> datetime:
Returns: Returns:
리프레시 토큰 만료 datetime (로컬 시간) 리프레시 토큰 만료 datetime (로컬 시간)
""" """
return datetime.now() + timedelta( return now() + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
) )

View File

@ -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 = { text_template_h_1 = {
"type": "composition", "type": "composition",
"track": 3, "track": 3,
@ -408,6 +439,7 @@ class CreatomateService:
image_url_list: list[str], image_url_list: list[str],
lyric: str, lyric: str,
music_url: str, music_url: str,
address: str = None
) -> dict: ) -> dict:
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다. """템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
@ -434,9 +466,8 @@ class CreatomateService:
idx % len(image_url_list) idx % len(image_url_list)
] ]
case "text": case "text":
modifications[template_component_name] = lyric_splited[ if "address_input" in template_component_name:
idx % len(lyric_splited) modifications[template_component_name] = address
]
modifications["audio-music"] = music_url modifications["audio-music"] = music_url
@ -448,12 +479,11 @@ class CreatomateService:
image_url_list: list[str], image_url_list: list[str],
lyric: str, lyric: str,
music_url: str, music_url: str,
address: str = None
) -> dict: ) -> dict:
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다.""" """elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
template_component_data = self.parse_template_component_name(elements) template_component_data = self.parse_template_component_name(elements)
lyric = lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {} modifications = {}
for idx, (template_component_name, template_type) in enumerate( for idx, (template_component_name, template_type) in enumerate(
@ -465,9 +495,8 @@ class CreatomateService:
idx % len(image_url_list) idx % len(image_url_list)
] ]
case "text": case "text":
modifications[template_component_name] = lyric_splited[ if "address_input" in template_component_name:
idx % len(lyric_splited) modifications[template_component_name] = address
]
modifications["audio-music"] = music_url modifications["audio-music"] = music_url
@ -486,7 +515,8 @@ class CreatomateService:
case "video": case "video":
element["source"] = modification[element["name"]] element["source"] = modification[element["name"]]
case "text": case "text":
element["source"] = modification.get(element["name"], "") #element["source"] = modification[element["name"]]
element["text"] = modification.get(element["name"], "")
case "composition": case "composition":
for minor in element["elements"]: for minor in element["elements"]:
recursive_modify(minor) recursive_modify(minor)
@ -704,8 +734,8 @@ class CreatomateService:
def extend_template_duration(self, template: dict, target_duration: float) -> dict: def extend_template_duration(self, template: dict, target_duration: float) -> dict:
"""템플릿의 duration을 target_duration으로 확장합니다.""" """템플릿의 duration을 target_duration으로 확장합니다."""
target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것 template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
template["duration"] = target_duration target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
total_template_duration = self.calc_scene_duration(template) total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration extend_rate = target_duration / total_template_duration
new_template = copy.deepcopy(template) new_template = copy.deepcopy(template)
@ -727,7 +757,7 @@ class CreatomateService:
return new_template 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 duration = end_sec - start_sec
text_scene = copy.deepcopy(text_template) text_scene = copy.deepcopy(text_template)
text_scene["name"] = f"Caption-{lyric_index}" text_scene["name"] = f"Caption-{lyric_index}"
@ -735,6 +765,7 @@ class CreatomateService:
text_scene["time"] = start_sec text_scene["time"] = start_sec
text_scene["elements"][0]["name"] = f"lyric-{lyric_index}" text_scene["elements"][0]["name"] = f"lyric-{lyric_index}"
text_scene["elements"][0]["text"] = lyric_text text_scene["elements"][0]["text"] = lyric_text
text_scene["elements"][0]["font_family"] = font_family
return text_scene return text_scene
def auto_lyric(self, auto_text_template : dict): def auto_lyric(self, auto_text_template : dict):
@ -744,7 +775,7 @@ class CreatomateService:
def get_text_template(self): def get_text_template(self):
match self.orientation: match self.orientation:
case "vertical": case "vertical":
return text_template_v_2 return text_template_v_3
case "horizontal": case "horizontal":
return text_template_h_1 return text_template_h_1

View File

@ -20,11 +20,11 @@ Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템.
import logging import logging
import sys import sys
from datetime import datetime
from functools import lru_cache from functools import lru_cache
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from typing import Literal from typing import Literal
from app.utils.timezone import today_str
from config import log_settings from config import log_settings
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리) # 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
@ -86,7 +86,7 @@ def _get_shared_file_handler() -> RotatingFileHandler:
global _shared_file_handler global _shared_file_handler
if _shared_file_handler is None: if _shared_file_handler is None:
today = datetime.today().strftime("%Y-%m-%d") today = today_str()
log_file = LOG_DIR / f"{today}_app.log" log_file = LOG_DIR / f"{today}_app.log"
_shared_file_handler = RotatingFileHandler( _shared_file_handler = RotatingFileHandler(
@ -116,7 +116,7 @@ def _get_shared_error_handler() -> RotatingFileHandler:
global _shared_error_handler global _shared_error_handler
if _shared_error_handler is None: if _shared_error_handler is None:
today = datetime.today().strftime("%Y-%m-%d") today = today_str()
log_file = LOG_DIR / f"{today}_error.log" log_file = LOG_DIR / f"{today}_error.log"
_shared_error_handler = RotatingFileHandler( _shared_error_handler = RotatingFileHandler(

View File

@ -1,6 +1,11 @@
import asyncio import asyncio
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
from urllib import parse from urllib import parse
import time
from app.utils.logger import get_logger
# 로거 설정
logger = get_logger("pwscraper")
class NvMapPwScraper(): class NvMapPwScraper():
# cls vars # cls vars
@ -91,25 +96,46 @@ 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()
while (count <= 1): while (count <= 1):
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}")
url = f"https://map.naver.com/p/search/{encoded_query}" url = f"https://map.naver.com/p/search/{encoded_query}"
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._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: if "/place/" in self.page.url:
return 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&") 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._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: if "/place/" in self.page.url:
return self.page.url return self.page.url
count += 1 count += 1
print("Not found url for {selected}") logger.error("[ERROR] Not found url for {selected}")
return None # 404 return None # 404

View File

@ -163,7 +163,8 @@ class SunoService:
if data.get("code") != 200: if data.get("code") != 200:
error_msg = data.get("msg", "Unknown error") 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") response_data = data.get("data")
if response_data is None: if response_data is None:

42
app/utils/timezone.py Normal file
View File

@ -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)

View File

@ -201,6 +201,7 @@ async def generate_video(
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
) )
project_id = project.id project_id = project.id
store_address = project.detail_region_info
# ===== 결과 처리: Lyric ===== # ===== 결과 처리: Lyric =====
lyric = lyric_result.scalar_one_or_none() lyric = lyric_result.scalar_one_or_none()
@ -211,6 +212,7 @@ async def generate_video(
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
) )
lyric_id = lyric.id lyric_id = lyric.id
lyric_language = lyric.language
# ===== 결과 처리: Song ===== # ===== 결과 처리: Song =====
song = song_result.scalar_one_or_none() song = song_result.scalar_one_or_none()
@ -313,6 +315,7 @@ async def generate_video(
image_url_list=image_urls, image_url_list=image_urls,
lyric=lyrics, lyric=lyrics,
music_url=music_url, music_url=music_url,
address=store_address
) )
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}") 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}" f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}"
) )
# 이런거 추가해야하는데 AI가 자꾸 번호 달면 제가 번호를 다 밀어야 하나요?
song_timestamp_result = await session.execute( song_timestamp_result = await session.execute(
select(SongTimestamp).where( select(SongTimestamp).where(
SongTimestamp.suno_audio_id == song.suno_audio_id 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}" 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 결정부 # LYRIC AUTO 결정부
if (creatomate_settings.DEBUG_AUTO_LYRIC): if (creatomate_settings.DEBUG_AUTO_LYRIC):
auto_text_template = creatomate_service.get_auto_text_template() auto_text_template = creatomate_service.get_auto_text_template()
@ -363,6 +371,7 @@ async def generate_video(
aligned.lyric_line, aligned.lyric_line,
aligned.start_time, aligned.start_time,
aligned.end_time, aligned.end_time,
lyric_font
) )
final_template["source"]["elements"].append(caption) final_template["source"]["elements"].append(caption)
# END - LYRIC AUTO 결정부 # END - LYRIC AUTO 결정부

View File

@ -1,10 +1,19 @@
import os
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
from dotenv import load_dotenv
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_DIR = Path(__file__).resolve().parent 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 = PROJECT_DIR / "media"
MEDIA_ROOT.mkdir(exist_ok=True) MEDIA_ROOT.mkdir(exist_ok=True)
@ -23,6 +32,10 @@ class ProjectSettings(BaseSettings):
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트") DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
ADMIN_BASE_URL: str = Field(default="/admin") ADMIN_BASE_URL: str = Field(default="/admin")
DEBUG: bool = Field(default=True) DEBUG: bool = Field(default=True)
TIMEZONE: str = Field(
default="Asia/Seoul",
description="프로젝트 전역 타임존 (예: Asia/Seoul, UTC, America/New_York)",
)
model_config = _base_config model_config = _base_config
@ -156,8 +169,8 @@ class CreatomateSettings(BaseSettings):
description="가로형 템플릿 기본 duration (초)", description="가로형 템플릿 기본 duration (초)",
) )
DEBUG_AUTO_LYRIC: bool = Field( DEBUG_AUTO_LYRIC: bool = Field(
default = False, default=False,
description = "Creatomate 자동 가사 생성 기능 사용 여부" description="Creatomate 자동 가사 생성 기능 사용 여부",
) )
model_config = _base_config model_config = _base_config

226
docs/plan/timezone_plan.md Normal file
View File

@ -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 토큰 만료 시간 비교 시 타임존 일관성 필수
- 로그 파일명에 사용되는 날짜도 서울 기준으로 통일

View File

@ -15,6 +15,7 @@ dependencies = [
"openai>=2.13.0", "openai>=2.13.0",
"playwright>=1.57.0", "playwright>=1.57.0",
"pydantic-settings>=2.12.0", "pydantic-settings>=2.12.0",
"python-dotenv>=1.0.0",
"python-jose[cryptography]>=3.5.0", "python-jose[cryptography]>=3.5.0",
"python-multipart>=0.0.21", "python-multipart>=0.0.21",
"redis>=7.1.0", "redis>=7.1.0",

View File

@ -657,6 +657,7 @@ dependencies = [
{ name = "openai" }, { name = "openai" },
{ name = "playwright" }, { name = "playwright" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] }, { name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "redis" }, { name = "redis" },
@ -685,6 +686,7 @@ requires-dist = [
{ name = "openai", specifier = ">=2.13.0" }, { name = "openai", specifier = ">=2.13.0" },
{ name = "playwright", specifier = ">=1.57.0" }, { name = "playwright", specifier = ">=1.57.0" },
{ name = "pydantic-settings", specifier = ">=2.12.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-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.21" }, { name = "python-multipart", specifier = ">=0.0.21" },
{ name = "redis", specifier = ">=7.1.0" }, { name = "redis", specifier = ">=7.1.0" },