first commit

get_video
Dohyun Lim 2026-02-04 16:35:08 +09:00
parent f24ff46b09
commit dd16013816
12 changed files with 310 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

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

@ -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
# 미디어 파일 저장 디렉토리
@ -157,7 +161,7 @@ class CreatomateSettings(BaseSettings):
)
DEBUG_AUTO_LYRIC: bool = Field(
default=False,
description = "Creatomate 자동 가사 생성 기능 사용 여부"
description="Creatomate 자동 가사 생성 기능 사용 여부",
)
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 토큰 만료 시간 비교 시 타임존 일관성 필수
- 로그 파일명에 사용되는 날짜도 서울 기준으로 통일