Compare commits

..

7 Commits

Author SHA1 Message Date
jaehwang 2f384fb72a merge main 2026-01-16 07:43:19 +00:00
Dohyun Lim a9d0a3ee7f added auth 2026-01-15 17:33:57 +09:00
Dohyun Lim a3d3c75463 added .cluade 2026-01-15 16:16:05 +09:00
Dohyun Lim f5130b73d7 added userproject table 2026-01-15 13:55:43 +09:00
Dohyun Lim d7120bb0ba finish user model definition 2026-01-15 13:09:29 +09:00
Dohyun Lim bf7b53c8e8 add logger 2026-01-14 17:46:45 +09:00
Dohyun Lim 1acd8846ab fix lyric 2026-01-13 17:27:30 +09:00
75 changed files with 4501 additions and 652 deletions

BIN
.DS_Store vendored

Binary file not shown.

32
.claude/agents/design.md Normal file
View File

@ -0,0 +1,32 @@
# 설계 에이전트 (Design Agent)
Python과 FastAPI 전문 설계자로서, 비동기 프로그래밍, 디자인 패턴, 데이터베이스에 대한 전문적인 지식을 보유하고 있습니다.
## 역할
- 사용자의 요구사항을 분석하고 설계 문서를 작성합니다
- 기존 프로젝트 패턴과 일관성 있는 아키텍처를 설계합니다
- API 엔드포인트, 데이터 모델, 서비스 레이어, 스키마를 설계합니다
## 수행 절차
### 1단계: 요구사항 분석
- 사용자의 요구사항을 명확히 파악합니다
- 기능적 요구사항과 비기능적 요구사항을 분리합니다
### 2단계: 관련 코드 검토
- 프로젝트의 기존 구조와 패턴을 분석합니다
- `app/` 디렉토리의 모듈 구조를 확인합니다
### 3단계: 설계 수행
다음 원칙을 준수하여 설계합니다:
- **레이어드 아키텍처**: Router → Service → Repository 패턴
- **비동기 우선**: 모든 I/O 작업은 async/await 사용
- **의존성 주입**: FastAPI의 Depends 활용
### 4단계: 설계 검수
- 기존 프로젝트 패턴과 일관성 확인
- N+1 쿼리 문제 검토
- SOLID 원칙 준수 여부 확인
## 출력
설계 문서를 화면에 출력합니다.

54
.claude/agents/develop.md Normal file
View File

@ -0,0 +1,54 @@
# 개발 에이전트 (Development Agent)
Python과 FastAPI 전문 개발자로서, 비동기 프로그래밍과 디자인 패턴에 대한 전문적인 지식을 보유하고 있습니다.
## 역할
- 설계 문서를 바탕으로 코드를 구현합니다
- 프로젝트 컨벤션을 준수하여 개발합니다
- 비동기 처리 패턴과 예외 처리를 적용합니다
## 코딩 표준
### Docstring
```python
async def create_user(self, user_data: UserCreate) -> User:
"""
새로운 사용자를 생성합니다.
Args:
user_data: 사용자 생성 데이터
Returns:
생성된 User 객체
"""
pass
```
### 로깅
```python
from app.core.logging import get_logger
logger = get_logger(__name__)
logger.debug(f"[1/3] 작업 시작: id={id}")
```
### 비동기 병렬 처리
```python
import asyncio
user, orders, stats = await asyncio.gather(
user_task, orders_task, stats_task
)
```
## 구현 순서
1. 모델 (models.py)
2. 스키마 (schemas/)
3. 서비스 (services/)
4. 라우터 (api/routers/)
5. 의존성 (dependencies.py)
## 검수 항목
- import 문이 올바른가?
- 타입 힌트가 정확한가?
- 비동기 함수에 await가 누락되지 않았는가?
- 순환 참조가 발생하지 않는가?

45
.claude/agents/review.md Normal file
View File

@ -0,0 +1,45 @@
# 코드리뷰 에이전트 (Code Review Agent)
Python과 FastAPI 전문 개발자로서, 수정된 파일들을 엔드포인트부터 흐름을 추적하여 문제점을 분석하고 개선사항을 리포트합니다.
**중요**: 이 에이전트는 파일을 수정하거나 생성하지 않습니다. 오직 분석 결과를 화면에 출력합니다.
## 역할
- 변경된 코드의 전체 흐름을 추적합니다
- 보안, 성능, 코드 품질을 검사합니다
- 개선사항을 도출하여 리포트합니다
## 흐름 추적
```
Request → Router → Dependency → Service → Repository → Database
Response ← Router ← Service ← Repository ←
```
## 검사 항목
### 보안 검사
- SQL Injection 취약점
- XSS 취약점
- 인증/인가 누락
- 민감 정보 노출
### 성능 검사
- N+1 쿼리 문제
- 불필요한 DB 호출
- 비동기 처리 누락
- 캐싱 가능 여부
### 코드 품질 검사
- 타입 힌트 정확성
- 예외 처리 적절성
- 로깅 충분성
- SOLID 원칙 준수
## 심각도 정의
- 🔴 Critical: 보안 취약점, 데이터 손실 가능성
- 🟡 Warning: 성능 저하, 유지보수성 저하
- 🟢 Info: 코드 스타일, 베스트 프랙티스 권장
## 출력
코드 리뷰 리포트를 화면에 출력합니다.

102
.claude/commands/design.md Normal file
View File

@ -0,0 +1,102 @@
# 설계 에이전트 (Design Agent)
## 역할
Python과 FastAPI 전문 설계자로서, 비동기 프로그래밍, 디자인 패턴, 데이터베이스에 대한 전문적인 지식을 보유하고 있습니다.
## 입력
사용자 요구사항: $ARGUMENTS
## 수행 절차
### 1단계: 요구사항 분석
- 사용자의 요구사항을 명확히 파악합니다
- 기능적 요구사항과 비기능적 요구사항을 분리합니다
- 모호한 부분이 있다면 명확히 정의합니다
### 2단계: 관련 코드 검토 및 학습
- 프로젝트의 기존 구조와 패턴을 분석합니다
- 관련된 기존 코드들을 검토합니다:
- `app/` 디렉토리의 모듈 구조
- `app/core/` 핵심 유틸리티
- `app/database/` DB 설정
- `app/dependencies/` 의존성 주입 패턴
- 관련 도메인 모듈 (home, lyric, song, video, auth 등)
- 기존 서비스 레이어 패턴을 확인합니다
### 3단계: 설계 수행
다음 원칙을 준수하여 설계합니다:
#### 아키텍처 원칙
- **레이어드 아키텍처**: Router → Service → Repository 패턴
- **비동기 우선**: 모든 I/O 작업은 async/await 사용
- **의존성 주입**: FastAPI의 Depends 활용
- **단일 책임 원칙**: 각 컴포넌트는 하나의 책임만 가짐
#### 설계 산출물
1. **API 엔드포인트 설계**
- HTTP 메서드, 경로, 요청/응답 스키마
2. **데이터 모델 설계**
- SQLAlchemy 모델 정의
- 테이블 관계 설계
3. **서비스 레이어 설계**
- 비즈니스 로직 구조
- 트랜잭션 경계
4. **스키마 설계**
- Pydantic v2 모델
- 요청/응답 DTO
5. **파일 구조**
- 생성/수정될 파일 목록
- 각 파일의 역할
### 4단계: 설계 검수 (필수)
설계 완료 후 다음 항목을 점검합니다:
#### 검수 체크리스트
- [ ] 기존 프로젝트 패턴과 일관성이 있는가?
- [ ] 비동기 처리가 적절히 설계되었는가?
- [ ] N+1 쿼리 문제가 발생하지 않는가?
- [ ] 트랜잭션 경계가 명확한가?
- [ ] 예외 처리 전략이 포함되어 있는가?
- [ ] 확장성을 고려했는가?
- [ ] 개발자가 쉽게 이해할 수 있는 직관적인 구조인가?
- [ ] SOLID 원칙을 준수하는가?
## 출력 형식
```
## 📋 설계 문서
### 1. 요구사항 요약
[요구사항 정리]
### 2. 설계 개요
[전체적인 설계 방향]
### 3. API 설계
[엔드포인트 상세]
### 4. 데이터 모델
[모델 설계]
### 5. 서비스 레이어
[비즈니스 로직 구조]
### 6. 스키마
[Pydantic 모델]
### 7. 파일 구조
[생성/수정 파일 목록]
### 8. 구현 순서
[개발 에이전트가 따라야 할 순서]
### 9. 설계 검수 결과
[체크리스트 결과 및 개선사항]
```
## 다음 단계
설계가 완료되면 `/develop` 명령으로 개발 에이전트를 호출하여 구현을 진행합니다.

158
.claude/commands/develop.md Normal file
View File

@ -0,0 +1,158 @@
# 개발 에이전트 (Development Agent)
## 역할
Python과 FastAPI 전문 개발자로서, 비동기 프로그래밍과 디자인 패턴에 대한 전문적인 지식을 보유하고 있습니다.
## 입력
설계 문서 또는 작업 지시: $ARGUMENTS
## 수행 절차
### 1단계: 작업 분석
- 설계 에이전트의 설계 문서를 확인합니다
- 구현해야 할 항목들을 파악합니다
- 구현 순서를 결정합니다
### 2단계: 개발 수행
다음 원칙을 준수하여 개발합니다:
#### 코딩 표준
```python
# 모든 함수/클래스에 docstring 작성
async def create_user(self, user_data: UserCreate) -> User:
"""
새로운 사용자를 생성합니다.
Args:
user_data: 사용자 생성 데이터
Returns:
생성된 User 객체
Raises:
DuplicateEmailError: 이메일이 이미 존재하는 경우
"""
pass
```
#### 주석 규칙
- 복잡한 비즈니스 로직에는 단계별 주석 추가
- 왜(Why) 그렇게 했는지 설명하는 주석 우선
- 자명한 코드에는 불필요한 주석 지양
#### 디버그 로깅 규칙
```python
from app.core.logging import get_logger
logger = get_logger(__name__)
async def process_order(self, order_id: int) -> Order:
"""주문 처리"""
logger.debug(f"[1/3] 주문 처리 시작: order_id={order_id}")
# 주문 조회
order = await self.get_order(order_id)
logger.debug(f"[2/3] 주문 조회 완료: status={order.status}")
# 처리 로직
result = await self._process(order)
logger.debug(f"[3/3] 주문 처리 완료: result={result}")
return result
```
#### 비동기 처리 패턴
```python
# 병렬 처리가 가능한 경우
import asyncio
async def get_dashboard_data(self, user_id: int):
"""대시보드 데이터 조회 - 병렬 처리"""
user_task = self.get_user(user_id)
orders_task = self.get_user_orders(user_id)
stats_task = self.get_user_stats(user_id)
user, orders, stats = await asyncio.gather(
user_task, orders_task, stats_task
)
return DashboardData(user=user, orders=orders, stats=stats)
```
#### 예외 처리 패턴
```python
from app.core.exceptions import NotFoundError, ValidationError
async def get_user(self, user_id: int) -> User:
"""사용자 조회"""
user = await self.repository.get(user_id)
if not user:
raise NotFoundError(f"사용자를 찾을 수 없습니다: {user_id}")
return user
```
### 3단계: 코드 구현
파일별로 순차적으로 구현합니다:
1. **모델 (models.py)**
- SQLAlchemy 모델 정의
- 관계 설정
2. **스키마 (schemas/)**
- Pydantic 요청/응답 모델
3. **서비스 (services/)**
- 비즈니스 로직 구현
- 트랜잭션 관리
4. **라우터 (api/routers/)**
- 엔드포인트 정의
- 의존성 주입
5. **의존성 (dependencies.py)**
- 서비스 주입 함수
### 4단계: 코드 검수 (필수)
모든 작업 완료 후 다음을 수행합니다:
#### 검수 항목
- [ ] 모든 파일이 정상적으로 생성/수정되었는가?
- [ ] import 문이 올바른가?
- [ ] 타입 힌트가 정확한가?
- [ ] 비동기 함수에 await가 누락되지 않았는가?
- [ ] 관련 함수들과의 호출 관계가 정상인가?
- [ ] 순환 참조가 발생하지 않는가?
- [ ] 기존 코드와의 호환성이 유지되는가?
#### 의존성 확인
```
수정된 파일 → 이 파일을 import하는 파일들 확인 → 문제 없는지 검증
```
## 출력 형식
```
## 🛠️ 개발 완료 보고서
### 1. 구현 요약
[구현된 기능 요약]
### 2. 생성/수정된 파일
| 파일 | 작업 | 설명 |
|------|------|------|
| app/xxx/models.py | 생성 | ... |
### 3. 주요 코드 설명
[핵심 로직 설명]
### 4. 디버그 포인트
[로깅이 추가된 주요 지점]
### 5. 코드 검수 결과
[검수 결과 및 확인 사항]
### 6. 주의사항
[사용 시 주의할 점]
```
## 다음 단계
개발이 완료되면 `/review` 명령으로 코드리뷰 에이전트를 호출하여 최종 검수를 진행합니다.

125
.claude/commands/review.md Normal file
View File

@ -0,0 +1,125 @@
# 코드리뷰 에이전트 (Code Review Agent)
## 역할
Python과 FastAPI 전문 개발자로서, 수정된 파일들을 엔드포인트부터 흐름을 추적하여 문제점을 분석하고 개선사항을 리포트합니다.
**중요**: 이 에이전트는 파일을 수정하거나 생성하지 않습니다. 오직 분석 결과를 화면에 출력합니다.
## 입력
리뷰 대상 파일 또는 기능: $ARGUMENTS
## 수행 절차
### 1단계: 변경 파일 식별
- 리뷰 대상 파일들을 확인합니다
- `git diff` 또는 명시된 파일 목록을 기준으로 합니다
### 2단계: 엔드포인트 흐름 추적
변경된 코드가 호출되는 전체 흐름을 추적합니다:
```
Request → Router → Dependency → Service → Repository → Database
Response ← Router ← Service ← Repository ←
```
각 단계에서 확인할 사항:
- **Router**: 엔드포인트 정의, 요청/응답 스키마, 상태 코드
- **Dependency**: 인증, 권한, DB 세션 주입
- **Service**: 비즈니스 로직, 트랜잭션 경계
- **Repository/Model**: 쿼리 효율성, 관계 로딩
### 3단계: 코드 품질 검사
#### 3.1 보안 검사
- [ ] SQL Injection 취약점
- [ ] XSS 취약점
- [ ] 인증/인가 누락
- [ ] 민감 정보 노출
- [ ] Rate Limiting 적용 여부
#### 3.2 성능 검사
- [ ] N+1 쿼리 문제
- [ ] 불필요한 DB 호출
- [ ] 비동기 처리 누락 (sync in async)
- [ ] 메모리 누수 가능성
- [ ] 캐싱 가능 여부
#### 3.3 코드 품질 검사
- [ ] 타입 힌트 정확성
- [ ] 예외 처리 적절성
- [ ] 로깅 충분성
- [ ] 코드 중복
- [ ] SOLID 원칙 준수
#### 3.4 FastAPI 베스트 프랙티스
- [ ] Pydantic 모델 활용
- [ ] 의존성 주입 패턴
- [ ] 응답 모델 정의
- [ ] OpenAPI 문서화
- [ ] 비동기 컨텍스트 관리
#### 3.5 SQLAlchemy 베스트 프랙티스
- [ ] 세션 관리
- [ ] Eager/Lazy 로딩 전략
- [ ] 트랜잭션 관리
- [ ] 관계 정의
### 4단계: 개선사항 도출
발견된 문제점에 대해 구체적인 개선 방안을 제시합니다.
## 출력 형식
```
## 📝 코드 리뷰 리포트
### 1. 리뷰 대상
| 파일 | 변경 유형 |
|------|----------|
| app/xxx/... | 생성/수정 |
### 2. 흐름 분석
[엔드포인트별 흐름 다이어그램]
### 3. 검사 결과
#### 🔴 Critical (즉시 수정 필요)
| 파일:라인 | 문제 | 설명 | 개선 방안 |
|-----------|------|------|----------|
#### 🟡 Warning (권장 수정)
| 파일:라인 | 문제 | 설명 | 개선 방안 |
|-----------|------|------|----------|
#### 🟢 Info (참고 사항)
| 파일:라인 | 내용 |
|-----------|------|
### 4. 성능 분석
[잠재적 성능 이슈 및 최적화 제안]
### 5. 보안 분석
[보안 관련 검토 결과]
### 6. 전체 평가
- 코드 품질: ⭐⭐⭐⭐☆
- 보안: ⭐⭐⭐⭐⭐
- 성능: ⭐⭐⭐☆☆
- 가독성: ⭐⭐⭐⭐☆
### 7. 요약
[전체 리뷰 요약 및 주요 권고사항]
```
## 심각도 정의
| 심각도 | 설명 |
|--------|------|
| 🔴 Critical | 보안 취약점, 데이터 손실 가능성, 서비스 장애 유발 |
| 🟡 Warning | 성능 저하, 유지보수성 저하, 잠재적 버그 |
| 🟢 Info | 코드 스타일, 개선 제안, 베스트 프랙티스 권장 |
## 참고 사항
- 이 에이전트는 **읽기 전용**입니다
- 파일을 직접 수정하지 않습니다
- 발견된 문제는 개발 에이전트(`/develop`)를 통해 수정합니다

8
.gitignore vendored
View File

@ -8,8 +8,8 @@ __pycache__/
.env .env
# Claude AI related files # Claude AI related files
.claude/
.claudeignore .claudeignore
# .claude/ 폴더는 커밋 대상 (에이전트 설정 포함)
# VSCode settings # VSCode settings
.vscode/ .vscode/
@ -30,3 +30,9 @@ media/
*.ipynb_checkpoint* *.ipynb_checkpoint*
# Static files
static/
# Log files
*.log
logs/

53
CLAUDE.md Normal file
View File

@ -0,0 +1,53 @@
# CLAUDE.md - O2O Castad Backend 프로젝트 가이드
## 프로젝트 개요
Python FastAPI 기반의 O2O Castad 백엔드 서비스
## 기술 스택
- **언어**: Python 3.13
- **프레임워크**: FastAPI
- **ORM**: SQLAlchemy (비동기)
- **데이터베이스**: PostgreSQL, Redis
- **패키지 관리**: uv
## 프로젝트 구조
```
app/
├── core/ # 핵심 유틸리티 (logging, exceptions)
├── database/ # DB 세션 및 Redis 설정
├── dependencies/ # FastAPI 의존성 주입
├── home/ # 홈 모듈 (크롤링, 이미지 업로드)
├── user/ # 사용자 모듈 (카카오 로그인, JWT 인증)
├── lyric/ # 가사 모듈
├── song/ # 노래 모듈
├── video/ # 비디오 모듈
└── utils/ # 공통 유틸리티
```
## 개발 컨벤션
- 모든 DB 작업은 비동기(async/await) 사용
- 서비스 레이어 패턴 적용 (routers → services → models)
- Pydantic v2 스키마 사용
- 타입 힌트 필수
## 에이전트 워크플로우
모든 개발 요청은 다음 3단계 에이전트 파이프라인을 통해 처리됩니다:
### 1단계: 설계 에이전트 (`/design`)
### 2단계: 개발 에이전트 (`/develop`)
### 3단계: 코드리뷰 에이전트 (`/review`)
각 에이전트는 `.claude/commands/` 폴더의 슬래시 커맨드로 호출할 수 있습니다.
## 주요 명령어
```bash
# 개발 서버 실행
uv run uvicorn main:app --reload
# 테스트 실행
uv run pytest
# 린트
uv run ruff check .
```

0
app/auth/__init__.py Normal file
View File

0
app/auth/api/__init__.py Normal file
View File

View File

View File

View File

@ -0,0 +1,125 @@
"""
카카오 로그인 API 라우터
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user_optional
from app.auth.models import User
from app.auth.schemas import AuthStatusResponse, TokenResponse, UserResponse
from app.auth.services.jwt import create_access_token
from app.auth.services.kakao import kakao_client
from app.database.session import get_session
router = APIRouter(tags=["Auth"])
@router.get("/auth/kakao/login")
async def kakao_login():
"""
카카오 로그인 페이지로 리다이렉트
프론트엔드에서 URL을 호출하면 카카오 로그인 페이지로 이동합니다.
"""
auth_url = kakao_client.get_authorization_url()
return RedirectResponse(url=auth_url)
@router.get("/kakao/callback", response_model=TokenResponse)
async def kakao_callback(
code: str,
session: AsyncSession = Depends(get_session),
):
"""
카카오 로그인 콜백
카카오 로그인 성공 인가 코드를 받아 JWT 토큰을 발급합니다.
"""
# 1. 인가 코드로 액세스 토큰 획득
token_data = await kakao_client.get_access_token(code)
if "error" in token_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"카카오 토큰 발급 실패: {token_data.get('error_description', token_data.get('error'))}",
)
access_token = token_data.get("access_token")
if not access_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="액세스 토큰을 받지 못했습니다",
)
# 2. 액세스 토큰으로 사용자 정보 조회
user_info = await kakao_client.get_user_info(access_token)
if "id" not in user_info:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="사용자 정보를 가져오지 못했습니다",
)
kakao_id = str(user_info["id"])
kakao_account = user_info.get("kakao_account", {})
profile = kakao_account.get("profile", {})
nickname = profile.get("nickname")
email = kakao_account.get("email")
profile_image = profile.get("profile_image_url")
# 3. 기존 회원 확인 또는 신규 가입
result = await session.execute(select(User).where(User.kakao_id == kakao_id))
user = result.scalar_one_or_none()
if user is None:
# 신규 가입
user = User(
kakao_id=kakao_id,
nickname=nickname,
email=email,
profile_image=profile_image,
)
session.add(user)
await session.commit()
await session.refresh(user)
else:
# 기존 회원 - 마지막 로그인 시간 및 정보 업데이트
user.nickname = nickname
user.email = email
user.profile_image = profile_image
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(user)
# 4. JWT 토큰 발급
jwt_token = create_access_token({"sub": str(user.id)})
return TokenResponse(
access_token=jwt_token,
user=UserResponse.model_validate(user),
)
@router.get("/auth/me", response_model=AuthStatusResponse)
async def get_auth_status(
current_user: User | None = Depends(get_current_user_optional),
):
"""
현재 인증 상태 확인
프론트엔드에서 로그인 상태를 확인할 사용합니다.
토큰이 유효하면 사용자 정보를, 아니면 is_authenticated=False를 반환합니다.
"""
if current_user is None:
return AuthStatusResponse(is_authenticated=False)
return AuthStatusResponse(
is_authenticated=True,
user=UserResponse.model_validate(current_user),
)

71
app/auth/dependencies.py Normal file
View File

@ -0,0 +1,71 @@
"""
Auth 모듈 의존성 주입
"""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.models import User
from app.auth.services.jwt import decode_access_token
from app.database.session import get_session
security = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: AsyncSession = Depends(get_session),
) -> User:
"""현재 로그인한 사용자 반환 (필수 인증)"""
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증이 필요합니다",
)
payload = decode_access_token(credentials.credentials)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰입니다",
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰입니다",
)
result = await session.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="사용자를 찾을 수 없습니다",
)
return user
async def get_current_user_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: AsyncSession = Depends(get_session),
) -> User | None:
"""현재 로그인한 사용자 반환 (선택적 인증)"""
if credentials is None:
return None
payload = decode_access_token(credentials.credentials)
if payload is None:
return None
user_id = payload.get("sub")
if user_id is None:
return None
result = await session.execute(select(User).where(User.id == int(user_id)))
return result.scalar_one_or_none()

88
app/auth/models.py Normal file
View File

@ -0,0 +1,88 @@
"""
Auth 모듈 SQLAlchemy 모델 정의
카카오 로그인 사용자 정보를 저장합니다.
"""
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database.session import Base
class User(Base):
"""
사용자 테이블 (카카오 로그인)
Attributes:
id: 고유 식별자 (자동 증가)
kakao_id: 카카오 고유 ID
nickname: 카카오 닉네임
email: 카카오 이메일 (선택)
profile_image: 프로필 이미지 URL
created_at: 가입 일시
last_login_at: 마지막 로그인 일시
"""
__tablename__ = "user"
__table_args__ = (
Index("idx_user_kakao_id", "kakao_id"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
kakao_id: Mapped[str] = mapped_column(
String(50),
nullable=False,
unique=True,
comment="카카오 고유 ID",
)
nickname: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
comment="카카오 닉네임",
)
email: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
comment="카카오 이메일",
)
profile_image: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
comment="프로필 이미지 URL",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="가입 일시",
)
last_login_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
comment="마지막 로그인 일시",
)
def __repr__(self) -> str:
return f"<User(id={self.id}, kakao_id='{self.kakao_id}', nickname='{self.nickname}')>"

36
app/auth/schemas.py Normal file
View File

@ -0,0 +1,36 @@
"""
Auth 모듈 Pydantic 스키마
"""
from datetime import datetime
from pydantic import BaseModel
class UserResponse(BaseModel):
"""사용자 정보 응답"""
id: int
kakao_id: str
nickname: str | None
email: str | None
profile_image: str | None
created_at: datetime
last_login_at: datetime
model_config = {"from_attributes": True}
class TokenResponse(BaseModel):
"""토큰 응답"""
access_token: str
token_type: str = "bearer"
user: UserResponse
class AuthStatusResponse(BaseModel):
"""인증 상태 응답 (프론트엔드용)"""
is_authenticated: bool
user: UserResponse | None = None

View File

34
app/auth/services/jwt.py Normal file
View File

@ -0,0 +1,34 @@
"""
JWT 토큰 유틸리티
"""
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from config import security_settings
def create_access_token(data: dict) -> str:
"""JWT 액세스 토큰 생성"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=security_settings.JWT_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(
to_encode,
security_settings.JWT_SECRET,
algorithm=security_settings.JWT_ALGORITHM,
)
def decode_access_token(token: str) -> dict | None:
"""JWT 액세스 토큰 디코딩"""
try:
payload = jwt.decode(
token,
security_settings.JWT_SECRET,
algorithms=[security_settings.JWT_ALGORITHM],
)
return payload
except JWTError:
return None

View File

@ -0,0 +1,55 @@
"""
카카오 OAuth API 클라이언트
"""
import aiohttp
from config import kakao_settings
class KakaoOAuthClient:
"""카카오 OAuth API 클라이언트"""
AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
TOKEN_URL = "https://kauth.kakao.com/oauth/token"
USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
def __init__(self):
self.client_id = kakao_settings.KAKAO_CLIENT_ID
self.client_secret = kakao_settings.KAKAO_CLIENT_SECRET
self.redirect_uri = kakao_settings.KAKAO_REDIRECT_URI
def get_authorization_url(self) -> str:
"""카카오 로그인 페이지 URL 반환"""
return (
f"{self.AUTH_URL}"
f"?client_id={self.client_id}"
f"&redirect_uri={self.redirect_uri}"
f"&response_type=code"
)
async def get_access_token(self, code: str) -> dict:
"""인가 코드로 액세스 토큰 획득"""
async with aiohttp.ClientSession() as session:
data = {
"grant_type": "authorization_code",
"client_id": self.client_id,
"client_secret": self.client_secret,
"redirect_uri": self.redirect_uri,
"code": code,
}
print(f"[kakao] Token request - client_id: {self.client_id}, redirect_uri: {self.redirect_uri}")
async with session.post(self.TOKEN_URL, data=data) as response:
result = await response.json()
print(f"[kakao] Token response: {result}")
return result
async def get_user_info(self, access_token: str) -> dict:
"""액세스 토큰으로 사용자 정보 조회"""
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {access_token}"}
async with session.get(self.USER_INFO_URL, headers=headers) as response:
return await response.json()
kakao_client = KakaoOAuthClient()

View File

@ -4,12 +4,16 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from app.utils.logger import get_logger
logger = get_logger("core")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""FastAPI 애플리케이션 생명주기 관리""" """FastAPI 애플리케이션 생명주기 관리"""
# Startup - 애플리케이션 시작 시 # Startup - 애플리케이션 시작 시
print("Starting up...") logger.info("Starting up...")
try: try:
from config import prj_settings from config import prj_settings
@ -19,20 +23,20 @@ async def lifespan(app: FastAPI):
from app.database.session import create_db_tables from app.database.session import create_db_tables
await create_db_tables() await create_db_tables()
print("Database tables created (DEBUG mode)") logger.info("Database tables created (DEBUG mode)")
except asyncio.TimeoutError: except asyncio.TimeoutError:
print("Database initialization timed out") logger.error("Database initialization timed out")
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass # 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
raise raise
except Exception as e: except Exception as e:
print(f"Database initialization failed: {e}") logger.error(f"Database initialization failed: {e}")
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass # 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
raise raise
yield # 애플리케이션 실행 중 yield # 애플리케이션 실행 중
# Shutdown - 애플리케이션 종료 시 # Shutdown - 애플리케이션 종료 시
print("Shutting down...") logger.info("Shutting down...")
# 공유 HTTP 클라이언트 종료 # 공유 HTTP 클라이언트 종료
from app.utils.creatomate import close_shared_client from app.utils.creatomate import close_shared_client

View File

@ -1,4 +1,3 @@
import logging
import traceback import traceback
from functools import wraps from functools import wraps
from typing import Any, Callable, TypeVar from typing import Any, Callable, TypeVar
@ -7,8 +6,10 @@ from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.utils.logger import get_logger
# 로거 설정 # 로거 설정
logger = logging.getLogger(__name__) logger = get_logger("core")
T = TypeVar("T") T = TypeVar("T")
@ -159,16 +160,14 @@ def handle_db_exceptions(
raise raise
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[DB Error] {func.__name__}: {e}") logger.error(f"[DB Error] {func.__name__}: {e}")
logger.error(traceback.format_exc()) logger.debug(traceback.format_exc())
print(f"[DB Error] {func.__name__}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=error_message, detail=error_message,
) )
except Exception as e: except Exception as e:
logger.error(f"[Unexpected Error] {func.__name__}: {e}") logger.error(f"[Unexpected Error] {func.__name__}: {e}")
logger.error(traceback.format_exc()) logger.debug(traceback.format_exc())
print(f"[Unexpected Error] {func.__name__}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.", detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.",
@ -205,8 +204,7 @@ def handle_external_service_exceptions(
except Exception as e: except Exception as e:
msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다." msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다."
logger.error(f"[{service_name} Error] {func.__name__}: {e}") logger.error(f"[{service_name} Error] {func.__name__}: {e}")
logger.error(traceback.format_exc()) logger.debug(traceback.format_exc())
print(f"[{service_name} Error] {func.__name__}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_502_BAD_GATEWAY,
detail=msg, detail=msg,
@ -240,16 +238,14 @@ def handle_api_exceptions(
raise raise
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[API DB Error] {func.__name__}: {e}") logger.error(f"[API DB Error] {func.__name__}: {e}")
logger.error(traceback.format_exc()) logger.debug(traceback.format_exc())
print(f"[API DB Error] {func.__name__}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.", detail="데이터베이스 연결에 문제가 발생했습니다.",
) )
except Exception as e: except Exception as e:
logger.error(f"[API Error] {func.__name__}: {e}") logger.error(f"[API Error] {func.__name__}: {e}")
logger.error(traceback.format_exc()) logger.debug(traceback.format_exc())
print(f"[API Error] {func.__name__}: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_message, detail=error_message,
@ -263,16 +259,7 @@ def handle_api_exceptions(
def _get_handler(status: int, detail: str): def _get_handler(status: int, detail: str):
# Define # Define
def handler(request: Request, exception: Exception) -> Response: def handler(request: Request, exception: Exception) -> Response:
# DEBUG PRINT STATEMENT 👇 logger.debug(f"Handled Exception: {exception.__class__.__name__}")
from rich import print, panel
print(
panel.Panel(
exception.__class__.__name__,
title="Handled Exception",
border_style="red",
),
)
# DEBUG PRINT STATEMENT 👆
# Raise HTTPException with given status and detail # Raise HTTPException with given status and detail
# can return JSONResponse as well # can return JSONResponse as well

View File

@ -9,8 +9,11 @@ from sqlalchemy.ext.asyncio import (
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스 from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
from app.utils.logger import get_logger
from config import db_settings from config import db_settings
logger = get_logger("database")
# Base 클래스 정의 # Base 클래스 정의
class Base(DeclarativeBase): class Base(DeclarativeBase):
@ -61,7 +64,7 @@ async def create_db_tables() -> None:
async with engine.begin() as conn: async with engine.begin() as conn:
# from app.database.models import Shipment, Seller # noqa: F401 # from app.database.models import Shipment, Seller # noqa: F401
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
print("MySQL tables created successfully") logger.info("MySQL tables created successfully")
# 세션 제너레이터 (FastAPI Depends에 사용) # 세션 제너레이터 (FastAPI Depends에 사용)
@ -80,13 +83,13 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback) # FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
except Exception as e: except Exception as e:
await session.rollback() # 명시적 롤백 (선택적) await session.rollback() # 명시적 롤백 (선택적)
print(f"Session rollback due to: {e}") # 로깅 logger.error(f"Session rollback due to: {e}")
raise raise
finally: finally:
# 명시적 세션 종료 (Connection Pool에 반환) # 명시적 세션 종료 (Connection Pool에 반환)
# context manager가 자동 처리하지만, 명시적으로 유지 # context manager가 자동 처리하지만, 명시적으로 유지
await session.close() await session.close()
print("session closed successfully") logger.debug("session closed successfully")
# 또는 session.aclose() - Python 3.10+ # 또는 session.aclose() - Python 3.10+
@ -94,4 +97,4 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
async def dispose_engine() -> None: async def dispose_engine() -> None:
"""애플리케이션 종료 시 모든 연결 해제""" """애플리케이션 종료 시 모든 연결 해제"""
await engine.dispose() await engine.dispose()
print("Database engine disposed") logger.info("Database engine disposed")

View File

@ -4,8 +4,11 @@ from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from app.utils.logger import get_logger
from config import db_settings from config import db_settings
logger = get_logger("database")
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
@ -69,12 +72,14 @@ async def create_db_tables():
import asyncio import asyncio
# 모델 import (테이블 메타데이터 등록용) # 모델 import (테이블 메타데이터 등록용)
from app.home.models import Image, Project # noqa: F401 # 주의: User를 먼저 import해야 UserProject가 User를 참조할 수 있음
from app.user.models import User # noqa: F401
from app.home.models import Image, Project, UserProject # noqa: F401
from app.lyric.models import Lyric # noqa: F401 from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song # noqa: F401 from app.song.models import Song # noqa: F401
from app.video.models import Video # noqa: F401 from app.video.models import Video # noqa: F401
print("Creating database tables...") logger.info("Creating database tables...")
async with asyncio.timeout(10): async with asyncio.timeout(10):
async with engine.begin() as connection: async with engine.begin() as connection:
@ -87,7 +92,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
pool = engine.pool pool = engine.pool
# 커넥션 풀 상태 로깅 (디버깅용) # 커넥션 풀 상태 로깅 (디버깅용)
print( logger.debug(
f"[get_session] ACQUIRE - pool_size: {pool.size()}, " f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
f"in: {pool.checkedin()}, out: {pool.checkedout()}, " f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
f"overflow: {pool.overflow()}" f"overflow: {pool.overflow()}"
@ -95,7 +100,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
acquire_time = time.perf_counter() acquire_time = time.perf_counter()
print( logger.debug(
f"[get_session] Session acquired in " f"[get_session] Session acquired in "
f"{(acquire_time - start_time)*1000:.1f}ms" f"{(acquire_time - start_time)*1000:.1f}ms"
) )
@ -103,14 +108,14 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
yield session yield session
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
print( logger.error(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, " f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
) )
raise e raise e
finally: finally:
total_time = time.perf_counter() - start_time total_time = time.perf_counter() - start_time
print( logger.debug(
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, " f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
f"pool_out: {pool.checkedout()}" f"pool_out: {pool.checkedout()}"
) )
@ -121,7 +126,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
start_time = time.perf_counter() start_time = time.perf_counter()
pool = background_engine.pool pool = background_engine.pool
print( logger.debug(
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, " f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
f"in: {pool.checkedin()}, out: {pool.checkedout()}, " f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
f"overflow: {pool.overflow()}" f"overflow: {pool.overflow()}"
@ -129,7 +134,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
async with BackgroundSessionLocal() as session: async with BackgroundSessionLocal() as session:
acquire_time = time.perf_counter() acquire_time = time.perf_counter()
print( logger.debug(
f"[get_background_session] Session acquired in " f"[get_background_session] Session acquired in "
f"{(acquire_time - start_time)*1000:.1f}ms" f"{(acquire_time - start_time)*1000:.1f}ms"
) )
@ -137,7 +142,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
yield session yield session
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
print( logger.error(
f"[get_background_session] ROLLBACK - " f"[get_background_session] ROLLBACK - "
f"error: {type(e).__name__}: {e}, " f"error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms" f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
@ -145,7 +150,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
raise e raise e
finally: finally:
total_time = time.perf_counter() - start_time total_time = time.perf_counter() - start_time
print( logger.debug(
f"[get_background_session] RELEASE - " f"[get_background_session] RELEASE - "
f"duration: {total_time*1000:.1f}ms, " f"duration: {total_time*1000:.1f}ms, "
f"pool_out: {pool.checkedout()}" f"pool_out: {pool.checkedout()}"
@ -154,8 +159,8 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
# 앱 종료 시 엔진 리소스 정리 함수 # 앱 종료 시 엔진 리소스 정리 함수
async def dispose_engine() -> None: async def dispose_engine() -> None:
print("[dispose_engine] Disposing database engines...") logger.info("[dispose_engine] Disposing database engines...")
await engine.dispose() await engine.dispose()
print("[dispose_engine] Main engine disposed") logger.info("[dispose_engine] Main engine disposed")
await background_engine.dispose() await background_engine.dispose()
print("[dispose_engine] Background engine disposed - ALL DONE") logger.info("[dispose_engine] Background engine disposed - ALL DONE")

View File

@ -1,15 +1,3 @@
"""API 1 Version Router Module.""" """
Home API v1 라우터 모듈
# from fastapi import APIRouter, Depends """
# API 버전 1 라우터를 정의합니다.
# router = APIRouter(
# prefix="/api/v1",
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
# )
# router = APIRouter(
# prefix="/api/v1",
# dependencies=[Depends(check_use_api), Depends(set_current_connect)],
# )
# router.include_router(auth.router, tags=[Tags.AUTH])
# router.include_router(board.router, prefix="/boards", tags=[Tags.BOARD])

View File

@ -1,6 +1,5 @@
import json import json
import logging import time
import traceback
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -26,13 +25,13 @@ from app.home.schemas.home_schema import (
from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.chatgpt_prompt import ChatgptService from app.utils.chatgpt_prompt import ChatgptService
from app.utils.common import generate_task_id 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.nvMapScraper import NvMapScraper, GraphQLException
from app.utils.prompts.prompts import marketing_prompt from app.utils.prompts.prompts import marketing_prompt
from config import MEDIA_ROOT
# 로거 설정 # 로거 설정
logger = logging.getLogger(__name__) logger = get_logger("home")
MEDIA_ROOT = Path("media")
# 전국 시 이름 목록 (roadAddress에서 region 추출용) # 전국 시 이름 목록 (roadAddress에서 region 추출용)
# fmt: off # fmt: off
@ -63,7 +62,7 @@ KOREAN_CITIES = [
] ]
# fmt: on # fmt: on
router = APIRouter() router = APIRouter(tags=["Home"])
def _extract_region_from_address(road_address: str | None) -> str: def _extract_region_from_address(road_address: str | None) -> str:
@ -107,16 +106,13 @@ def _extract_region_from_address(road_address: str | None) -> str:
) )
async def crawling(request_body: CrawlingRequest): async def crawling(request_body: CrawlingRequest):
"""네이버 지도 장소 크롤링""" """네이버 지도 장소 크롤링"""
import time
request_start = time.perf_counter() request_start = time.perf_counter()
logger.info(f"[crawling] START - url: {request_body.url[:80]}...") logger.info("[crawling] ========== START ==========")
print(f"[crawling] ========== START ==========") logger.info(f"[crawling] URL: {request_body.url[:80]}...")
print(f"[crawling] URL: {request_body.url[:80]}...")
# ========== Step 1: 네이버 지도 크롤링 ========== # ========== Step 1: 네이버 지도 크롤링 ==========
step1_start = time.perf_counter() step1_start = time.perf_counter()
print(f"[crawling] Step 1: 네이버 지도 크롤링 시작...") logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...")
try: try:
scraper = NvMapScraper(request_body.url) scraper = NvMapScraper(request_body.url)
@ -124,7 +120,6 @@ async def crawling(request_body: CrawlingRequest):
except GraphQLException as e: except GraphQLException as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error(f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)") logger.error(f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)")
print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"네이버 지도 크롤링에 실패했습니다: {e}", detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
@ -132,8 +127,7 @@ async def crawling(request_body: CrawlingRequest):
except Exception as e: except Exception as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error(f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)") logger.error(f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)")
print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)") logger.exception("[crawling] Step 1 상세 오류:")
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_502_BAD_GATEWAY,
detail="네이버 지도 크롤링 중 오류가 발생했습니다.", detail="네이버 지도 크롤링 중 오류가 발생했습니다.",
@ -142,11 +136,10 @@ async def crawling(request_body: CrawlingRequest):
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0 image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
logger.info(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)") logger.info(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
print(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
# ========== Step 2: 정보 가공 ========== # ========== Step 2: 정보 가공 ==========
step2_start = time.perf_counter() step2_start = time.perf_counter()
print(f"[crawling] Step 2: 정보 가공 시작...") logger.info("[crawling] Step 2: 정보 가공 시작...")
processed_info = None processed_info = None
marketing_analysis = None marketing_analysis = None
@ -164,18 +157,17 @@ async def crawling(request_body: CrawlingRequest):
step2_elapsed = (time.perf_counter() - step2_start) * 1000 step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)") logger.info(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)")
print(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)")
# ========== Step 3: ChatGPT 마케팅 분석 ========== # ========== Step 3: ChatGPT 마케팅 분석 ==========
step3_start = time.perf_counter() step3_start = time.perf_counter()
print(f"[crawling] Step 3: ChatGPT 마케팅 분석 시작...") logger.info("[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
try: try:
# Step 3-1: ChatGPT 서비스 초기화 # Step 3-1: ChatGPT 서비스 초기화
step3_1_start = time.perf_counter() step3_1_start = time.perf_counter()
chatgpt_service = ChatgptService() chatgpt_service = ChatgptService()
step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000 step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000
print(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)") logger.debug(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)")
# Step 3-2: 프롬프트 생성 # Step 3-2: 프롬프트 생성
# step3_2_start = time.perf_counter() # step3_2_start = time.perf_counter()
@ -187,19 +179,18 @@ async def crawling(request_body: CrawlingRequest):
# prompt = chatgpt_service.build_market_analysis_prompt() # prompt = chatgpt_service.build_market_analysis_prompt()
# prompt1 = marketing_prompt.build_prompt(input_marketing_data) # prompt1 = marketing_prompt.build_prompt(input_marketing_data)
# step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000 # step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000
# print(f"[crawling] Step 3-2: 프롬프트 생성 완료 - ({step3_2_elapsed:.1f}ms)")
# Step 3-3: GPT API 호출 # Step 3-3: GPT API 호출
step3_3_start = time.perf_counter() step3_3_start = time.perf_counter()
structured_report = await chatgpt_service.generate_structured_output(marketing_prompt, input_marketing_data) structured_report = await chatgpt_service.generate_structured_output(marketing_prompt, input_marketing_data)
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000 step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)") logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)")
print(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)") logger.debug(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)")
# Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달) # Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달)
step3_4_start = time.perf_counter() step3_4_start = time.perf_counter()
print(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}") logger.debug(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}")
# 요약 Deprecated / 20250115 / Selling points를 첫 prompt에서 추출 중 # 요약 Deprecated / 20250115 / Selling points를 첫 prompt에서 추출 중
# parsed = await chatgpt_service.parse_marketing_analysis( # parsed = await chatgpt_service.parse_marketing_analysis(
@ -218,36 +209,32 @@ async def crawling(request_body: CrawlingRequest):
# print(sp['keywords']) # print(sp['keywords'])
# print(sp['description']) # print(sp['description'])
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000 step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
print(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)") logger.debug(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)")
step3_elapsed = (time.perf_counter() - step3_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.info(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)") logger.info(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
print(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
except Exception as e: except Exception as e:
step3_elapsed = (time.perf_counter() - step3_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.error(f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)") logger.error(f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)")
print(f"[crawling] Step 3 FAILED - {e} ({step3_elapsed:.1f}ms)") logger.exception("[crawling] Step 3 상세 오류:")
traceback.print_exc()
# GPT 실패 시에도 크롤링 결과는 반환 # GPT 실패 시에도 크롤링 결과는 반환
marketing_analysis = None marketing_analysis = None
else: else:
step2_elapsed = (time.perf_counter() - step2_start) * 1000 step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.warning(f"[crawling] Step 2 - base_info 없음 ({step2_elapsed:.1f}ms)") logger.warning(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)")
print(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)")
# ========== 완료 ========== # ========== 완료 ==========
total_elapsed = (time.perf_counter() - request_start) * 1000 total_elapsed = (time.perf_counter() - request_start) * 1000
logger.info(f"[crawling] COMPLETE - 총 소요시간: {total_elapsed:.1f}ms") logger.info("[crawling] ========== COMPLETE ==========")
print(f"[crawling] ========== COMPLETE ==========") logger.info(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
print(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms") logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
print(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
if scraper.base_info: if scraper.base_info:
print(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms") logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
if 'step3_elapsed' in locals(): if 'step3_elapsed' in locals():
print(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms") logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
if 'step3_3_elapsed' in locals(): if 'step3_3_elapsed' in locals():
print(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms") logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
return { return {
"image_list": scraper.image_link_list, "image_list": scraper.image_link_list,
@ -629,12 +616,11 @@ async def upload_images_blob(
- Stage 2: Azure Blob 업로드 (세션 없음) - Stage 2: Azure Blob 업로드 (세션 없음)
- Stage 3: DB 저장 ( 세션으로 빠르게 처리) - Stage 3: DB 저장 ( 세션으로 빠르게 처리)
""" """
import time
request_start = time.perf_counter() request_start = time.perf_counter()
# task_id 생성 # task_id 생성
task_id = await generate_task_id() task_id = await generate_task_id()
print(f"[upload_images_blob] START - task_id: {task_id}") logger.info(f"[upload_images_blob] START - task_id: {task_id}")
# ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ========== # ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ==========
has_images_json = images_json is not None and images_json.strip() != "" has_images_json = images_json is not None and images_json.strip() != ""
@ -688,7 +674,7 @@ async def upload_images_blob(
) )
stage1_time = time.perf_counter() stage1_time = time.perf_counter()
print(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, " logger.info(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
f"files: {len(valid_files_data)}, " f"files: {len(valid_files_data)}, "
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms") f"elapsed: {(stage1_time - request_start)*1000:.1f}ms")
@ -709,7 +695,7 @@ async def upload_images_blob(
) )
filename = f"{name_without_ext}_{img_order:03d}{ext}" filename = f"{name_without_ext}_{img_order:03d}{ext}"
print(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: " logger.debug(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: "
f"{filename} ({len(file_content)} bytes)") f"{filename} ({len(file_content)} bytes)")
# Azure Blob Storage에 직접 업로드 # Azure Blob Storage에 직접 업로드
@ -719,18 +705,18 @@ async def upload_images_blob(
blob_url = uploader.public_url blob_url = uploader.public_url
blob_upload_results.append((original_name, blob_url)) blob_upload_results.append((original_name, blob_url))
img_order += 1 img_order += 1
print(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS") logger.debug(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS")
else: else:
skipped_files.append(filename) skipped_files.append(filename)
print(f"[upload_images_blob] File {idx+1}/{total_files} FAILED") logger.warning(f"[upload_images_blob] File {idx+1}/{total_files} FAILED")
stage2_time = time.perf_counter() stage2_time = time.perf_counter()
print(f"[upload_images_blob] Stage 2 done - blob uploads: " logger.info(f"[upload_images_blob] Stage 2 done - blob uploads: "
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, " f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms") f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms")
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ========== # ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
print("[upload_images_blob] Stage 3 starting - DB save...") logger.info("[upload_images_blob] Stage 3 starting - DB save...")
result_images: list[ImageUploadResultItem] = [] result_images: list[ImageUploadResultItem] = []
img_order = 0 img_order = 0
@ -786,13 +772,13 @@ async def upload_images_blob(
await session.commit() await session.commit()
stage3_time = time.perf_counter() stage3_time = time.perf_counter()
print(f"[upload_images_blob] Stage 3 done - " logger.info(f"[upload_images_blob] Stage 3 done - "
f"saved: {len(result_images)}, " f"saved: {len(result_images)}, "
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms") f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}") logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}")
traceback.print_exc() logger.exception("[upload_images_blob] DB 상세 오류:")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.", detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
@ -800,7 +786,7 @@ async def upload_images_blob(
except Exception as e: except Exception as e:
logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - " logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - "
f"task_id: {task_id}, error: {type(e).__name__}: {e}") f"task_id: {task_id}, error: {type(e).__name__}: {e}")
traceback.print_exc() logger.exception("[upload_images_blob] Stage 3 상세 오류:")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="이미지 업로드 중 오류가 발생했습니다.", detail="이미지 업로드 중 오류가 발생했습니다.",
@ -810,7 +796,7 @@ async def upload_images_blob(
image_urls = [img.img_url for img in result_images] image_urls = [img.img_url for img in result_images]
total_time = time.perf_counter() - request_start total_time = time.perf_counter() - request_start
print(f"[upload_images_blob] SUCCESS - task_id: {task_id}, " logger.info(f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
f"total: {saved_count}, total_time: {total_time*1000:.1f}ms") f"total: {saved_count}, total_time: {total_time*1000:.1f}ms")
return ImageUploadResponse( return ImageUploadResponse(

View File

@ -4,12 +4,13 @@ Home 모듈 SQLAlchemy 모델 정의
모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다. 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
- Project: 프로젝트(사용자 입력 이력) 관리 - Project: 프로젝트(사용자 입력 이력) 관리
- Image: 업로드된 이미지 URL 관리 - Image: 업로드된 이미지 URL 관리
- UserProject: User와 Project M:N 관계 중계 테이블
""" """
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List, Optional from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import DateTime, Index, Integer, String, Text, func from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base from app.database.session import Base
@ -17,8 +18,125 @@ from app.database.session import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song from app.song.models import Song
from app.user.models import User
from app.video.models import Video from app.video.models import Video
# =============================================================================
# User-Project M:N 관계 중계 테이블
# =============================================================================
#
# 설계 의도:
# - User와 Project는 다대다(M:N) 관계입니다.
# - 한 사용자는 여러 프로젝트에 참여할 수 있습니다.
# - 한 프로젝트에는 여러 사용자가 참여할 수 있습니다.
#
# 중계 테이블 역할:
# - UserProject 테이블이 두 테이블 간의 관계를 연결합니다.
# - 각 레코드는 특정 사용자와 특정 프로젝트의 연결을 나타냅니다.
# - 추가 속성(role, joined_at)으로 관계의 메타데이터를 저장합니다.
#
# 외래키 설정:
# - user_id: User 테이블의 id를 참조 (ON DELETE CASCADE)
# - project_id: Project 테이블의 id를 참조 (ON DELETE CASCADE)
# - CASCADE 설정으로 부모 레코드 삭제 시 중계 레코드도 자동 삭제됩니다.
#
# 관계 방향:
# - User.projects → UserProject → Project (사용자가 참여한 프로젝트 목록)
# - Project.users → UserProject → User (프로젝트에 참여한 사용자 목록)
# =============================================================================
class UserProject(Base):
"""
User-Project M:N 관계 중계 테이블
사용자와 프로젝트 간의 다대다 관계를 관리합니다.
사용자는 여러 프로젝트에 참여할 있고,
프로젝트에는 여러 사용자가 참여할 있습니다.
Attributes:
id: 고유 식별자 (자동 증가)
user_id: 사용자 외래키 (User.id 참조)
project_id: 프로젝트 외래키 (Project.id 참조)
role: 프로젝트 사용자 역할 (owner: 소유자, member: 멤버, viewer: 조회자)
joined_at: 프로젝트 참여 일시
외래키 제약조건:
- user_id user.id (ON DELETE CASCADE)
- project_id project.id (ON DELETE CASCADE)
유니크 제약조건:
- (user_id, project_id) 조합은 유일해야 (중복 참여 방지)
"""
__tablename__ = "user_project"
__table_args__ = (
Index("idx_user_project_user_id", "user_id"),
Index("idx_user_project_project_id", "project_id"),
Index("idx_user_project_user_project", "user_id", "project_id", unique=True),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
# 외래키: User 테이블 참조
# - BigInteger 사용 (User.id가 BigInteger이므로 타입 일치 필요)
# - ondelete="CASCADE": User 삭제 시 연결된 UserProject 레코드도 삭제
user_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
comment="사용자 외래키 (User.id 참조)",
)
# 외래키: Project 테이블 참조
# - Integer 사용 (Project.id가 Integer이므로 타입 일치 필요)
# - ondelete="CASCADE": Project 삭제 시 연결된 UserProject 레코드도 삭제
project_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("project.id", ondelete="CASCADE"),
nullable=False,
comment="프로젝트 외래키 (Project.id 참조)",
)
# ==========================================================================
# Relationships (관계 설정)
# ==========================================================================
# back_populates: 양방향 관계 설정 (User.user_projects, Project.user_projects)
# lazy="selectin": N+1 문제 방지를 위한 즉시 로딩
# ==========================================================================
user: Mapped["User"] = relationship(
"User",
back_populates="user_projects",
lazy="selectin",
)
project: Mapped["Project"] = relationship(
"Project",
back_populates="user_projects",
lazy="selectin",
)
def __repr__(self) -> str:
return (
f"<UserProject("
f"id={self.id}, "
f"user_id={self.user_id}, "
f"project_id={self.project_id}, "
f"role='{self.role}'"
f")>"
)
class Project(Base): class Project(Base):
""" """
@ -39,6 +157,8 @@ class Project(Base):
lyrics: 생성된 가사 목록 lyrics: 생성된 가사 목록
songs: 생성된 노래 목록 songs: 생성된 노래 목록
videos: 최종 영상 결과 목록 videos: 최종 영상 결과 목록
user_projects: User와의 M:N 관계 (중계 테이블 통한 연결)
users: 프로젝트에 참여한 사용자 목록 (Association Proxy)
""" """
__tablename__ = "project" __tablename__ = "project"
@ -123,6 +243,20 @@ class Project(Base):
lazy="selectin", lazy="selectin",
) )
# ==========================================================================
# User M:N 관계 (중계 테이블 UserProject 통한 연결)
# ==========================================================================
# back_populates: UserProject.project와 양방향 연결
# cascade: Project 삭제 시 UserProject 레코드도 삭제 (User는 유지)
# lazy="selectin": N+1 문제 방지
# ==========================================================================
user_projects: Mapped[List["UserProject"]] = relationship(
"UserProject",
back_populates="project",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str: def __repr__(self) -> str:
def truncate(value: str | None, max_len: int = 10) -> str: def truncate(value: str | None, max_len: int = 10) -> str:
if value is None: if value is None:

View File

@ -2,6 +2,10 @@ import pytest
from sqlalchemy import text from sqlalchemy import text
from app.database.session import AsyncSessionLocal, engine from app.database.session import AsyncSessionLocal, engine
from app.utils.logger import get_logger
# 로거 설정
logger = get_logger("test_db")
@pytest.mark.asyncio @pytest.mark.asyncio
@ -27,4 +31,4 @@ async def test_database_version():
result = await session.execute(text("SELECT VERSION()")) result = await session.execute(text("SELECT VERSION()"))
version = result.scalar() version = result.scalar()
assert version is not None assert version is not None
print(f"MySQL Version: {version}") logger.info(f"MySQL Version: {version}")

View File

@ -11,8 +11,6 @@ from fastapi import UploadFile
from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.upload_blob_as_request import AzureBlobUploader
MEDIA_ROOT = Path("media")
async def save_upload_file(file: UploadFile, save_path: Path) -> None: async def save_upload_file(file: UploadFile, save_path: Path) -> None:
"""업로드 파일을 지정된 경로에 저장""" """업로드 파일을 지정된 경로에 저장"""

View File

@ -0,0 +1,3 @@
"""
Lyric API v1 라우터 모듈
"""

View File

@ -41,10 +41,13 @@ from app.lyric.schemas.lyric import (
) )
from app.lyric.worker.lyric_task import generate_lyric_background from app.lyric.worker.lyric_task import generate_lyric_background
from app.utils.chatgpt_prompt import ChatgptService from app.utils.chatgpt_prompt import ChatgptService
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse, get_paginated from app.utils.pagination import PaginatedResponse, get_paginated
from app.utils.prompts.prompts import lyric_prompt from app.utils.prompts.prompts import lyric_prompt
import traceback as tb import traceback as tb
# 로거 설정
logger = get_logger("lyric")
router = APIRouter(prefix="/lyric", tags=["lyric"]) router = APIRouter(prefix="/lyric", tags=["lyric"])
@ -77,7 +80,7 @@ async def get_lyric_status_by_task_id(
if status_info.status == "completed": if status_info.status == "completed":
# 완료 처리 # 완료 처리
""" """
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}") logger.info(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
result = await session.execute( result = await session.execute(
select(Lyric) select(Lyric)
.where(Lyric.task_id == task_id) .where(Lyric.task_id == task_id)
@ -87,7 +90,7 @@ async def get_lyric_status_by_task_id(
lyric = result.scalar_one_or_none() lyric = result.scalar_one_or_none()
if not lyric: if not lyric:
print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}") logger.warning(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
@ -99,7 +102,7 @@ async def get_lyric_status_by_task_id(
"failed": "가사 생성에 실패했습니다.", "failed": "가사 생성에 실패했습니다.",
} }
print( logger.info(
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}" f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
) )
return LyricStatusResponse( return LyricStatusResponse(
@ -130,7 +133,7 @@ async def get_lyric_by_task_id(
lyric = await get_lyric_by_task_id(session, task_id) lyric = await get_lyric_by_task_id(session, task_id)
""" """
print(f"[get_lyric_by_task_id] START - task_id: {task_id}") logger.info(f"[get_lyric_by_task_id] START - task_id: {task_id}")
result = await session.execute( result = await session.execute(
select(Lyric) select(Lyric)
.where(Lyric.task_id == task_id) .where(Lyric.task_id == task_id)
@ -140,19 +143,18 @@ async def get_lyric_by_task_id(
lyric = result.scalar_one_or_none() lyric = result.scalar_one_or_none()
if not lyric: if not lyric:
print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}") logger.warning(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
) )
print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}") logger.info(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
return LyricDetailResponse( return LyricDetailResponse(
id=lyric.id, id=lyric.id,
task_id=lyric.task_id, task_id=lyric.task_id,
project_id=lyric.project_id, project_id=lyric.project_id,
status=lyric.status, status=lyric.status,
lyric_prompt=lyric.lyric_prompt,
lyric_result=lyric.lyric_result, lyric_result=lyric.lyric_result,
created_at=lyric.created_at, created_at=lyric.created_at,
) )
@ -229,8 +231,8 @@ async def generate_lyric(
task_id = request_body.task_id task_id = request_body.task_id
print(f"[generate_lyric] ========== START ==========") logger.info(f"[generate_lyric] ========== START ==========")
print( logger.info(
f"[generate_lyric] task_id: {task_id}, " f"[generate_lyric] task_id: {task_id}, "
f"customer_name: {request_body.customer_name}, " f"customer_name: {request_body.customer_name}, "
f"region: {request_body.region}" f"region: {request_body.region}"
@ -239,7 +241,7 @@ async def generate_lyric(
try: try:
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ========== # ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
step1_start = time.perf_counter() step1_start = time.perf_counter()
print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...") logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
# service = ChatgptService( # service = ChatgptService(
# customer_name=request_body.customer_name, # customer_name=request_body.customer_name,
@ -279,11 +281,11 @@ async def generate_lyric(
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data) estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
#print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)") #logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
# ========== Step 2: Project 테이블에 데이터 저장 ========== # ========== Step 2: Project 테이블에 데이터 저장 ==========
step2_start = time.perf_counter() step2_start = time.perf_counter()
print(f"[generate_lyric] Step 2: Project 저장...") logger.debug(f"[generate_lyric] Step 2: Project 저장...")
project = Project( project = Project(
store_name=request_body.customer_name, store_name=request_body.customer_name,
@ -297,11 +299,11 @@ async def generate_lyric(
await session.refresh(project) await session.refresh(project)
step2_elapsed = (time.perf_counter() - step2_start) * 1000 step2_elapsed = (time.perf_counter() - step2_start) * 1000
print(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)") logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
# ========== Step 3: Lyric 테이블에 데이터 저장 ========== # ========== Step 3: Lyric 테이블에 데이터 저장 ==========
step3_start = time.perf_counter() step3_start = time.perf_counter()
print(f"[generate_lyric] Step 3: Lyric 저장 (processing)...") logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data) estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
lyric = Lyric( lyric = Lyric(
@ -317,11 +319,11 @@ async def generate_lyric(
await session.refresh(lyric) await session.refresh(lyric)
step3_elapsed = (time.perf_counter() - step3_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
print(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)") logger.debug(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)")
# ========== Step 4: 백그라운드 태스크 스케줄링 ========== # ========== Step 4: 백그라운드 태스크 스케줄링 ==========
step4_start = time.perf_counter() step4_start = time.perf_counter()
print(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...") logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
background_tasks.add_task( background_tasks.add_task(
generate_lyric_background, generate_lyric_background,
@ -331,17 +333,17 @@ async def generate_lyric(
) )
step4_elapsed = (time.perf_counter() - step4_start) * 1000 step4_elapsed = (time.perf_counter() - step4_start) * 1000
print(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)") logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
# ========== 완료 ========== # ========== 완료 ==========
total_elapsed = (time.perf_counter() - request_start) * 1000 total_elapsed = (time.perf_counter() - request_start) * 1000
print(f"[generate_lyric] ========== COMPLETE ==========") logger.info(f"[generate_lyric] ========== COMPLETE ==========")
print(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms") logger.info(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms") logger.debug(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms") logger.debug(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms") logger.debug(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms") logger.debug(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
print(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)") logger.debug(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
# 5. 즉시 응답 반환 # 5. 즉시 응답 반환
return GenerateLyricResponse( return GenerateLyricResponse(
@ -354,7 +356,7 @@ async def generate_lyric(
except Exception as e: except Exception as e:
elapsed = (time.perf_counter() - request_start) * 1000 elapsed = (time.perf_counter() - request_start) * 1000
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") logger.error(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
await session.rollback() await session.rollback()
return GenerateLyricResponse( return GenerateLyricResponse(
success=False, success=False,

View File

@ -1,8 +0,0 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]

View File

@ -140,7 +140,6 @@ class LyricDetailResponse(BaseModel):
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431", "task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
"project_id": 1, "project_id": 1,
"status": "completed", "status": "completed",
"lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...",
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요", "lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
"created_at": "2024-01-15T12:00:00", "created_at": "2024-01-15T12:00:00",
} }
@ -151,7 +150,6 @@ class LyricDetailResponse(BaseModel):
task_id: str = Field(..., description="작업 고유 식별자") task_id: str = Field(..., description="작업 고유 식별자")
project_id: int = Field(..., description="프로젝트 ID") project_id: int = Field(..., description="프로젝트 ID")
status: str = Field(..., description="처리 상태") status: str = Field(..., description="처리 상태")
lyric_prompt: str = Field(..., description="가사 생성 프롬프트")
lyric_result: Optional[str] = Field(None, description="생성된 가사") lyric_result: Optional[str] = Field(None, description="생성된 가사")
created_at: Optional[datetime] = Field(None, description="생성 일시") created_at: Optional[datetime] = Field(None, description="생성 일시")

View File

@ -4,7 +4,6 @@ Lyric Background Tasks
가사 생성 관련 백그라운드 태스크를 정의합니다. 가사 생성 관련 백그라운드 태스크를 정의합니다.
""" """
import logging
import traceback import traceback
from sqlalchemy import select from sqlalchemy import select
@ -14,9 +13,10 @@ from app.database.session import BackgroundSessionLocal
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.utils.chatgpt_prompt import ChatgptService from app.utils.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import Prompt from app.utils.prompts.prompts import Prompt
from app.utils.logger import get_logger
# 로거 설정 # 로거 설정
logger = logging.getLogger(__name__) logger = get_logger("lyric")
async def _update_lyric_status( async def _update_lyric_status(
@ -50,20 +50,16 @@ async def _update_lyric_status(
lyric.lyric_result = result lyric.lyric_result = result
await session.commit() await session.commit()
logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}") logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
print(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
return True return True
else: else:
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}") logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
print(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
return False return False
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}") logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}") logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False return False
@ -83,15 +79,15 @@ async def generate_lyric_background(
task_start = time.perf_counter() task_start = time.perf_counter()
logger.info(f"[generate_lyric_background] START - task_id: {task_id}") logger.info(f"[generate_lyric_background] START - task_id: {task_id}")
print(f"[generate_lyric_background] ========== START ==========") logger.debug(f"[generate_lyric_background] ========== START ==========")
print(f"[generate_lyric_background] task_id: {task_id}") logger.debug(f"[generate_lyric_background] task_id: {task_id}")
print(f"[generate_lyric_background] language: {lyric_input_data['language']}") logger.debug(f"[generate_lyric_background] language: {lyric_input_data['language']}")
#print(f"[generate_lyric_background] prompt length: {len(prompt)}자") #logger.debug(f"[generate_lyric_background] prompt length: {len(prompt)}자")
try: try:
# ========== Step 1: ChatGPT 서비스 초기화 ========== # ========== Step 1: ChatGPT 서비스 초기화 ==========
step1_start = time.perf_counter() step1_start = time.perf_counter()
print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...") logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
# service = ChatgptService( # service = ChatgptService(
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값 # customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
@ -103,48 +99,43 @@ async def generate_lyric_background(
chatgpt = ChatgptService() chatgpt = ChatgptService()
step1_elapsed = (time.perf_counter() - step1_start) * 1000 step1_elapsed = (time.perf_counter() - step1_start) * 1000
print(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)") logger.debug(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)")
# ========== Step 2: ChatGPT API 호출 (가사 생성) ========== # ========== Step 2: ChatGPT API 호출 (가사 생성) ==========
step2_start = time.perf_counter() step2_start = time.perf_counter()
logger.info(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작 - task_id: {task_id}") logger.info(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작 - task_id: {task_id}")
print(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...") logger.debug(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...")
#result = await service.generate(prompt=prompt) #result = await service.generate(prompt=prompt)
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data) result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
result = result_response['lyric'] result = result_response['lyric']
step2_elapsed = (time.perf_counter() - step2_start) * 1000 step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)") logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
print(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
# ========== Step 3: DB 상태 업데이트 ========== # ========== Step 3: DB 상태 업데이트 ==========
step3_start = time.perf_counter() step3_start = time.perf_counter()
print(f"[generate_lyric_background] Step 3: DB 상태 업데이트...") logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
await _update_lyric_status(task_id, "completed", result) await _update_lyric_status(task_id, "completed", result)
step3_elapsed = (time.perf_counter() - step3_start) * 1000 step3_elapsed = (time.perf_counter() - step3_start) * 1000
print(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)") logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
# ========== 완료 ========== # ========== 완료 ==========
total_elapsed = (time.perf_counter() - task_start) * 1000 total_elapsed = (time.perf_counter() - task_start) * 1000
logger.info(f"[generate_lyric_background] SUCCESS - task_id: {task_id}, 총 소요시간: {total_elapsed:.1f}ms") logger.info(f"[generate_lyric_background] SUCCESS - task_id: {task_id}, 총 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric_background] ========== SUCCESS ==========") logger.debug(f"[generate_lyric_background] ========== SUCCESS ==========")
print(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms") logger.debug(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms") logger.debug(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms") logger.debug(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms") logger.debug(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms")
except SQLAlchemyError as e: except SQLAlchemyError as e:
elapsed = (time.perf_counter() - task_start) * 1000 elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
print(f"[generate_lyric_background] DB ERROR - {e} ({elapsed:.1f}ms)")
traceback.print_exc()
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}") await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
except Exception as e: except Exception as e:
elapsed = (time.perf_counter() - task_start) * 1000 elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)") logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
print(f"[generate_lyric_background] EXCEPTION - {e} ({elapsed:.1f}ms)")
traceback.print_exc()
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}") await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")

View File

@ -0,0 +1,3 @@
"""
Song API v1 라우터 모듈
"""

View File

@ -32,11 +32,13 @@ from app.song.schemas.song_schema import (
PollingSongResponse, PollingSongResponse,
SongListItem, SongListItem,
) )
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.utils.suno import SunoService from app.utils.suno import SunoService
logger = get_logger("song")
router = APIRouter(prefix="/song", tags=["song"]) router = APIRouter(prefix="/song", tags=["Song"])
@router.post( @router.post(
@ -99,7 +101,7 @@ async def generate_song(
from app.database.session import AsyncSessionLocal from app.database.session import AsyncSessionLocal
request_start = time.perf_counter() request_start = time.perf_counter()
print( logger.info(
f"[generate_song] START - task_id: {task_id}, " f"[generate_song] START - task_id: {task_id}, "
f"genre: {request_body.genre}, language: {request_body.language}" f"genre: {request_body.genre}, language: {request_body.language}"
) )
@ -124,7 +126,7 @@ async def generate_song(
project = project_result.scalar_one_or_none() project = project_result.scalar_one_or_none()
if not project: if not project:
print(f"[generate_song] Project NOT FOUND - task_id: {task_id}") logger.warning(f"[generate_song] Project NOT FOUND - task_id: {task_id}")
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
@ -141,7 +143,7 @@ async def generate_song(
lyric = lyric_result.scalar_one_or_none() lyric = lyric_result.scalar_one_or_none()
if not lyric: if not lyric:
print(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}") logger.warning(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}")
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
@ -149,7 +151,7 @@ async def generate_song(
lyric_id = lyric.id lyric_id = lyric.id
query_time = time.perf_counter() query_time = time.perf_counter()
print( logger.info(
f"[generate_song] Queries completed - task_id: {task_id}, " f"[generate_song] Queries completed - task_id: {task_id}, "
f"project_id: {project_id}, lyric_id: {lyric_id}, " f"project_id: {project_id}, lyric_id: {lyric_id}, "
f"elapsed: {(query_time - request_start)*1000:.1f}ms" f"elapsed: {(query_time - request_start)*1000:.1f}ms"
@ -174,7 +176,7 @@ async def generate_song(
song_id = song.id song_id = song.id
stage1_time = time.perf_counter() stage1_time = time.perf_counter()
print( logger.info(
f"[generate_song] Stage 1 DONE - Song saved - " f"[generate_song] Stage 1 DONE - Song saved - "
f"task_id: {task_id}, song_id: {song_id}, " f"task_id: {task_id}, song_id: {song_id}, "
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms" f"elapsed: {(stage1_time - request_start)*1000:.1f}ms"
@ -184,7 +186,7 @@ async def generate_song(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
print( logger.error(
f"[generate_song] Stage 1 EXCEPTION - " f"[generate_song] Stage 1 EXCEPTION - "
f"task_id: {task_id}, error: {type(e).__name__}: {e}" f"task_id: {task_id}, error: {type(e).__name__}: {e}"
) )
@ -203,7 +205,7 @@ async def generate_song(
suno_task_id: str | None = None suno_task_id: str | None = None
try: try:
print(f"[generate_song] Stage 2 START - Suno API - task_id: {task_id}") logger.info(f"[generate_song] Stage 2 START - Suno API - task_id: {task_id}")
suno_service = SunoService() suno_service = SunoService()
suno_task_id = await suno_service.generate( suno_task_id = await suno_service.generate(
prompt=request_body.lyrics, prompt=request_body.lyrics,
@ -211,14 +213,14 @@ async def generate_song(
) )
stage2_time = time.perf_counter() stage2_time = time.perf_counter()
print( logger.info(
f"[generate_song] Stage 2 DONE - task_id: {task_id}, " f"[generate_song] Stage 2 DONE - task_id: {task_id}, "
f"suno_task_id: {suno_task_id}, " f"suno_task_id: {suno_task_id}, "
f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms" f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
) )
except Exception as e: except Exception as e:
print( logger.error(
f"[generate_song] Stage 2 EXCEPTION - Suno API failed - " f"[generate_song] Stage 2 EXCEPTION - Suno API failed - "
f"task_id: {task_id}, error: {type(e).__name__}: {e}" f"task_id: {task_id}, error: {type(e).__name__}: {e}"
) )
@ -244,7 +246,7 @@ async def generate_song(
# 3단계: suno_task_id 업데이트 (새 세션으로 빠르게 처리) # 3단계: suno_task_id 업데이트 (새 세션으로 빠르게 처리)
# ========================================================================== # ==========================================================================
stage3_start = time.perf_counter() stage3_start = time.perf_counter()
print(f"[generate_song] Stage 3 START - DB update - task_id: {task_id}") logger.info(f"[generate_song] Stage 3 START - DB update - task_id: {task_id}")
try: try:
async with AsyncSessionLocal() as update_session: async with AsyncSessionLocal() as update_session:
@ -258,11 +260,11 @@ async def generate_song(
stage3_time = time.perf_counter() stage3_time = time.perf_counter()
total_time = stage3_time - request_start total_time = stage3_time - request_start
print( logger.info(
f"[generate_song] Stage 3 DONE - task_id: {task_id}, " f"[generate_song] Stage 3 DONE - task_id: {task_id}, "
f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms" f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
) )
print( logger.info(
f"[generate_song] SUCCESS - task_id: {task_id}, " f"[generate_song] SUCCESS - task_id: {task_id}, "
f"suno_task_id: {suno_task_id}, " f"suno_task_id: {suno_task_id}, "
f"total_time: {total_time*1000:.1f}ms" f"total_time: {total_time*1000:.1f}ms"
@ -277,7 +279,7 @@ async def generate_song(
) )
except Exception as e: except Exception as e:
print( logger.error(
f"[generate_song] Stage 3 EXCEPTION - " f"[generate_song] Stage 3 EXCEPTION - "
f"task_id: {task_id}, error: {type(e).__name__}: {e}" f"task_id: {task_id}, error: {type(e).__name__}: {e}"
) )
@ -341,12 +343,12 @@ async def get_song_status(
Azure Blob Storage에 업로드한 Song 테이블의 status를 completed로, Azure Blob Storage에 업로드한 Song 테이블의 status를 completed로,
song_result_url을 Blob URL로 업데이트합니다. song_result_url을 Blob URL로 업데이트합니다.
""" """
print(f"[get_song_status] START - suno_task_id: {suno_task_id}") logger.info(f"[get_song_status] START - suno_task_id: {suno_task_id}")
try: try:
suno_service = SunoService() suno_service = SunoService()
result = await suno_service.get_task_status(suno_task_id) result = await suno_service.get_task_status(suno_task_id)
parsed_response = suno_service.parse_status_response(result) parsed_response = suno_service.parse_status_response(result)
print(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}") logger.info(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}")
# SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장 # SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장
if parsed_response.status == "SUCCESS" and parsed_response.clips: if parsed_response.status == "SUCCESS" and parsed_response.clips:
@ -354,7 +356,7 @@ async def get_song_status(
first_clip = parsed_response.clips[0] first_clip = parsed_response.clips[0]
audio_url = first_clip.audio_url audio_url = first_clip.audio_url
clip_duration = first_clip.duration clip_duration = first_clip.duration
print(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}") logger.debug(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}")
if audio_url: if audio_url:
# suno_task_id로 Song 조회 # suno_task_id로 Song 조회
@ -373,17 +375,17 @@ async def get_song_status(
if clip_duration is not None: if clip_duration is not None:
song.duration = clip_duration song.duration = clip_duration
await session.commit() await session.commit()
print(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}") logger.info(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}")
elif song and song.status == "completed": elif song and song.status == "completed":
print(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}") logger.info(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}")
print(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}") logger.info(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}")
return parsed_response return parsed_response
except Exception as e: except Exception as e:
import traceback import traceback
print(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") logger.error(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
return PollingSongResponse( return PollingSongResponse(
success=False, success=False,
status="error", status="error",
@ -438,7 +440,7 @@ async def download_song(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> DownloadSongResponse: ) -> DownloadSongResponse:
"""task_id로 Song 상태를 polling하고 completed 시 Project 정보와 노래 URL을 반환합니다.""" """task_id로 Song 상태를 polling하고 completed 시 Project 정보와 노래 URL을 반환합니다."""
print(f"[download_song] START - task_id: {task_id}") logger.info(f"[download_song] START - task_id: {task_id}")
try: try:
# task_id로 Song 조회 (여러 개 있을 경우 가장 최근 것 선택) # task_id로 Song 조회 (여러 개 있을 경우 가장 최근 것 선택)
song_result = await session.execute( song_result = await session.execute(
@ -450,7 +452,7 @@ async def download_song(
song = song_result.scalar_one_or_none() song = song_result.scalar_one_or_none()
if not song: if not song:
print(f"[download_song] Song NOT FOUND - task_id: {task_id}") logger.warning(f"[download_song] Song NOT FOUND - task_id: {task_id}")
return DownloadSongResponse( return DownloadSongResponse(
success=False, success=False,
status="not_found", status="not_found",
@ -458,11 +460,11 @@ async def download_song(
error_message="Song not found", error_message="Song not found",
) )
print(f"[download_song] Song found - task_id: {task_id}, status: {song.status}") logger.info(f"[download_song] Song found - task_id: {task_id}, status: {song.status}")
# processing 상태인 경우 # processing 상태인 경우
if song.status == "processing": if song.status == "processing":
print(f"[download_song] PROCESSING - task_id: {task_id}") logger.info(f"[download_song] PROCESSING - task_id: {task_id}")
return DownloadSongResponse( return DownloadSongResponse(
success=True, success=True,
status="processing", status="processing",
@ -472,7 +474,7 @@ async def download_song(
# failed 상태인 경우 # failed 상태인 경우
if song.status == "failed": if song.status == "failed":
print(f"[download_song] FAILED - task_id: {task_id}") logger.warning(f"[download_song] FAILED - task_id: {task_id}")
return DownloadSongResponse( return DownloadSongResponse(
success=False, success=False,
status="failed", status="failed",
@ -487,7 +489,7 @@ async def download_song(
) )
project = project_result.scalar_one_or_none() project = project_result.scalar_one_or_none()
print(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}") logger.info(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}")
return DownloadSongResponse( return DownloadSongResponse(
success=True, success=True,
status="completed", status="completed",
@ -502,7 +504,7 @@ async def download_song(
) )
except Exception as e: except Exception as e:
print(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}") logger.error(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}")
return DownloadSongResponse( return DownloadSongResponse(
success=False, success=False,
status="error", status="error",
@ -550,7 +552,7 @@ async def get_songs(
pagination: PaginationParams = Depends(get_pagination_params), pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[SongListItem]: ) -> PaginatedResponse[SongListItem]:
"""완료된 노래 목록을 페이지네이션하여 반환합니다.""" """완료된 노래 목록을 페이지네이션하여 반환합니다."""
print(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}") logger.info(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}")
try: try:
offset = (pagination.page - 1) * pagination.page_size offset = (pagination.page - 1) * pagination.page_size
@ -622,14 +624,14 @@ async def get_songs(
page_size=pagination.page_size, page_size=pagination.page_size,
) )
print( logger.info(
f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, " f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, "
f"page_size: {pagination.page_size}, items_count: {len(items)}" f"page_size: {pagination.page_size}, items_count: {len(items)}"
) )
return response return response
except Exception as e: except Exception as e:
print(f"[get_songs] EXCEPTION - error: {e}") logger.error(f"[get_songs] EXCEPTION - error: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"노래 목록 조회에 실패했습니다: {str(e)}", detail=f"노래 목록 조회에 실패했습니다: {str(e)}",

View File

@ -1,8 +0,0 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]

View File

@ -6,6 +6,7 @@ from fastapi.exceptions import HTTPException
from sqlalchemy import Connection, text from sqlalchemy import Connection, text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from app.utils.logger import get_logger
from app.lyrics.schemas.lyrics_schema import ( from app.lyrics.schemas.lyrics_schema import (
AttributeData, AttributeData,
PromptTemplateData, PromptTemplateData,
@ -15,6 +16,8 @@ from app.lyrics.schemas.lyrics_schema import (
) )
from app.utils.chatgpt_prompt import chatgpt_api from app.utils.chatgpt_prompt import chatgpt_api
logger = get_logger("song")
async def get_store_info(conn: Connection) -> List[StoreData]: async def get_store_info(conn: Connection) -> List[StoreData]:
try: try:
@ -38,13 +41,13 @@ async def get_store_info(conn: Connection) -> List[StoreData]:
result.close() result.close()
return all_store_info return all_store_info
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemyError in get_store_info: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_store_info: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -69,13 +72,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
result.close() result.close()
return all_attribute return all_attribute
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemyError in get_attribute: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_attribute: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -100,13 +103,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
result.close() result.close()
return all_attribute return all_attribute
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemyError in get_attribute (duplicate): {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_attribute (duplicate): {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -132,13 +135,13 @@ async def get_sample_song(conn: Connection) -> List[SongSampleData]:
result.close() result.close()
return all_sample_song return all_sample_song
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemyError in get_sample_song: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_sample_song: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -162,13 +165,13 @@ async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]:
result.close() result.close()
return all_prompt_template return all_prompt_template
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemyError in get_prompt_template: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_prompt_template: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -192,13 +195,13 @@ async def get_song_result(conn: Connection) -> List[PromptTemplateData]:
result.close() result.close()
return all_prompt_template return all_prompt_template
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemyError in get_song_result (prompt_template): {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_song_result (prompt_template): {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -210,11 +213,11 @@ async def make_song_result(request: Request, conn: Connection):
# 1. Form 데이터 파싱 # 1. Form 데이터 파싱
form_data = await SongFormData.from_form(request) form_data = await SongFormData.from_form(request)
print(f"\n{'=' * 60}") logger.info(f"{'=' * 60}")
print(f"Store ID: {form_data.store_id}") logger.info(f"Store ID: {form_data.store_id}")
print(f"Lyrics IDs: {form_data.lyrics_ids}") logger.info(f"Lyrics IDs: {form_data.lyrics_ids}")
print(f"Prompt IDs: {form_data.prompts}") logger.info(f"Prompt IDs: {form_data.prompts}")
print(f"{'=' * 60}\n") logger.info(f"{'=' * 60}")
# 2. Store 정보 조회 # 2. Store 정보 조회
store_query = """ store_query = """
@ -243,7 +246,7 @@ async def make_song_result(request: Request, conn: Connection):
) )
store_info = all_store_info[0] store_info = all_store_info[0]
print(f"Store: {store_info.store_name}") logger.info(f"Store: {store_info.store_name}")
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
@ -251,7 +254,7 @@ async def make_song_result(request: Request, conn: Connection):
combined_sample_song = None combined_sample_song = None
if form_data.lyrics_ids: if form_data.lyrics_ids:
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}") logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
lyrics_query = """ lyrics_query = """
SELECT sample_song FROM song_sample SELECT sample_song FROM song_sample
@ -270,11 +273,11 @@ async def make_song_result(request: Request, conn: Connection):
combined_sample_song = "\n\n".join( combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
) )
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else: else:
print("샘플 가사가 비어있습니다") logger.info("샘플 가사가 비어있습니다")
else: else:
print("선택된 lyrics가 없습니다") logger.info("선택된 lyrics가 없습니다")
# 5. 템플릿 가져오기 # 5. 템플릿 가져오기
if not form_data.prompts: if not form_data.prompts:
@ -283,7 +286,7 @@ async def make_song_result(request: Request, conn: Connection):
detail="프롬프트 ID가 필요합니다", detail="프롬프트 ID가 필요합니다",
) )
print("템플릿 가져오기") logger.info("템플릿 가져오기")
prompts_query = """ prompts_query = """
SELECT * FROM prompt_template WHERE id=:id; SELECT * FROM prompt_template WHERE id=:id;
@ -310,7 +313,7 @@ async def make_song_result(request: Request, conn: Connection):
) )
prompt = prompts_info[0] prompt = prompts_info[0]
print(f"Prompt Template: {prompt.prompt}") logger.debug(f"Prompt Template: {prompt.prompt}")
# ✅ 6. 프롬프트 조합 # ✅ 6. 프롬프트 조합
updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format(
@ -329,7 +332,7 @@ async def make_song_result(request: Request, conn: Connection):
{combined_sample_song} {combined_sample_song}
""" """
print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") logger.debug(f"[업데이트된 프롬프트]\n{updated_prompt}")
# 7. 모델에게 요청 # 7. 모델에게 요청
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
@ -348,13 +351,12 @@ async def make_song_result(request: Request, conn: Connection):
전체 글자 (공백 포함): {total_chars_with_space} 전체 글자 (공백 포함): {total_chars_with_space}
전체 글자 (공백 제외): {total_chars_without_space}\r\n\r\n{generated_lyrics}""" 전체 글자 (공백 제외): {total_chars_without_space}\r\n\r\n{generated_lyrics}"""
print("=" * 40) logger.debug("=" * 40)
print("[translate:form_data.attributes_str:] ", form_data.attributes_str) logger.debug(f"[translate:form_data.attributes_str:] {form_data.attributes_str}")
print("[translate:total_chars_with_space:] ", total_chars_with_space) logger.debug(f"[translate:total_chars_with_space:] {total_chars_with_space}")
print("[translate:total_chars_without_space:] ", total_chars_without_space) logger.debug(f"[translate:total_chars_without_space:] {total_chars_without_space}")
print("[translate:final_lyrics:]") logger.debug(f"[translate:final_lyrics:]\n{final_lyrics}")
print(final_lyrics) logger.debug("=" * 40)
print("=" * 40)
# 8. DB 저장 # 8. DB 저장
insert_query = """ insert_query = """
@ -396,9 +398,9 @@ async def make_song_result(request: Request, conn: Connection):
await conn.execute(text(insert_query), insert_params) await conn.execute(text(insert_query), insert_params)
await conn.commit() await conn.commit()
print("결과 저장 완료") logger.info("make_song_result 결과 저장 완료")
print("\n전체 결과 조회 중...") logger.info("make_song_result 전체 결과 조회 중...")
# 9. 생성 결과 가져오기 (created_at 역순) # 9. 생성 결과 가져오기 (created_at 역순)
select_query = """ select_query = """
@ -430,26 +432,20 @@ async def make_song_result(request: Request, conn: Connection):
for row in all_results.fetchall() for row in all_results.fetchall()
] ]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n") logger.info(f"make_song_result 전체 {len(results_list)}개의 결과 조회 완료")
return results_list return results_list
except HTTPException: except HTTPException:
raise raise
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(f"Database Error: {e}") logger.error(f"make_song_result Database Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.", detail="데이터베이스 연결에 문제가 발생했습니다.",
) )
except Exception as e: except Exception as e:
print(f"Unexpected Error: {e}") logger.error(f"make_song_result Unexpected Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.", detail="서비스 처리 중 오류가 발생했습니다.",
@ -490,25 +486,19 @@ async def get_song_result(conn: Connection): # 반환 타입 수정
for row in all_results.fetchall() for row in all_results.fetchall()
] ]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n") logger.info(f"get_song_result 전체 {len(results_list)}개의 결과 조회 완료")
return results_list return results_list
except HTTPException: # HTTPException은 그대로 raise except HTTPException: # HTTPException은 그대로 raise
raise raise
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(f"Database Error: {e}") logger.error(f"get_song_result Database Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.", detail="데이터베이스 연결에 문제가 발생했습니다.",
) )
except Exception as e: except Exception as e:
print(f"Unexpected Error: {e}") logger.error(f"get_song_result Unexpected Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.", detail="서비스 처리 중 오류가 발생했습니다.",
@ -520,9 +510,9 @@ async def make_automation(request: Request, conn: Connection):
# 1. Form 데이터 파싱 # 1. Form 데이터 파싱
form_data = await SongFormData.from_form(request) form_data = await SongFormData.from_form(request)
print(f"\n{'=' * 60}") logger.info(f"{'=' * 60}")
print(f"Store ID: {form_data.store_id}") logger.info(f"make_automation Store ID: {form_data.store_id}")
print(f"{'=' * 60}\n") logger.info(f"{'=' * 60}")
# 2. Store 정보 조회 # 2. Store 정보 조회
store_query = """ store_query = """
@ -551,7 +541,7 @@ async def make_automation(request: Request, conn: Connection):
) )
store_info = all_store_info[0] store_info = all_store_info[0]
print(f"Store: {store_info.store_name}") logger.info(f"make_automation Store: {store_info.store_name}")
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
attribute_query = """ attribute_query = """
@ -596,13 +586,13 @@ async def make_automation(request: Request, conn: Connection):
# 최종 문자열 생성 # 최종 문자열 생성
formatted_attributes = "\n".join(formatted_pairs) formatted_attributes = "\n".join(formatted_pairs)
print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") logger.debug(f"[포맷팅된 문자열 속성 정보]\n{formatted_attributes}")
else: else:
print("속성 데이터가 없습니다") logger.info("속성 데이터가 없습니다")
formatted_attributes = "" formatted_attributes = ""
# 4. 템플릿 가져오기 # 4. 템플릿 가져오기
print("템플릿 가져오기 (ID=1)") logger.info("템플릿 가져오기 (ID=1)")
prompts_query = """ prompts_query = """
SELECT * FROM prompt_template WHERE id=1; SELECT * FROM prompt_template WHERE id=1;
@ -624,7 +614,7 @@ async def make_automation(request: Request, conn: Connection):
prompt=row[2], prompt=row[2],
) )
print(f"Prompt Template: {prompt.prompt}") logger.debug(f"Prompt Template: {prompt.prompt}")
# 5. 템플릿 조합 # 5. 템플릿 조합
@ -635,17 +625,17 @@ async def make_automation(request: Request, conn: Connection):
description=store_info.store_info or "", description=store_info.store_info or "",
) )
print("\n" + "=" * 80) logger.debug("=" * 80)
print("업데이트된 프롬프트") logger.debug("업데이트된 프롬프트")
print("=" * 80) logger.debug("=" * 80)
print(updated_prompt) logger.debug(updated_prompt)
print("=" * 80 + "\n") logger.debug("=" * 80)
# 4. Sample Song 조회 및 결합 # 4. Sample Song 조회 및 결합
combined_sample_song = None combined_sample_song = None
if form_data.lyrics_ids: if form_data.lyrics_ids:
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}") logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
lyrics_query = """ lyrics_query = """
SELECT sample_song FROM song_sample SELECT sample_song FROM song_sample
@ -664,14 +654,14 @@ async def make_automation(request: Request, conn: Connection):
combined_sample_song = "\n\n".join( combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
) )
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else: else:
print("샘플 가사가 비어있습니다") logger.info("샘플 가사가 비어있습니다")
else: else:
print("선택된 lyrics가 없습니다") logger.info("선택된 lyrics가 없습니다")
# 1. song_sample 테이블의 모든 ID 조회 # 1. song_sample 테이블의 모든 ID 조회
print("\n[샘플 가사 랜덤 선택]") logger.info("[샘플 가사 랜덤 선택]")
all_ids_query = """ all_ids_query = """
SELECT id FROM song_sample; SELECT id FROM song_sample;
@ -679,7 +669,7 @@ async def make_automation(request: Request, conn: Connection):
ids_result = await conn.execute(text(all_ids_query)) ids_result = await conn.execute(text(all_ids_query))
all_ids = [row.id for row in ids_result.fetchall()] all_ids = [row.id for row in ids_result.fetchall()]
print(f"전체 샘플 가사 개수: {len(all_ids)}") logger.info(f"전체 샘플 가사 개수: {len(all_ids)}")
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
combined_sample_song = None combined_sample_song = None
@ -689,7 +679,7 @@ async def make_automation(request: Request, conn: Connection):
sample_count = min(3, len(all_ids)) sample_count = min(3, len(all_ids))
selected_ids = random.sample(all_ids, sample_count) selected_ids = random.sample(all_ids, sample_count)
print(f"랜덤 선택된 ID: {selected_ids}") logger.debug(f"랜덤 선택된 ID: {selected_ids}")
# 3. 선택된 ID로 샘플 가사 조회 # 3. 선택된 ID로 샘플 가사 조회
lyrics_query = """ lyrics_query = """
@ -710,11 +700,11 @@ async def make_automation(request: Request, conn: Connection):
combined_sample_song = "\n\n".join( combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
) )
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else: else:
print("샘플 가사가 비어있습니다") logger.info("샘플 가사가 비어있습니다")
else: else:
print("song_sample 테이블에 데이터가 없습니다") logger.info("song_sample 테이블에 데이터가 없습니다")
# 5. 프롬프트에 샘플 가사 추가 # 5. 프롬프트에 샘플 가사 추가
if combined_sample_song: if combined_sample_song:
@ -726,11 +716,11 @@ async def make_automation(request: Request, conn: Connection):
{combined_sample_song} {combined_sample_song}
""" """
print("샘플 가사 정보가 프롬프트에 추가되었습니다") logger.info("샘플 가사 정보가 프롬프트에 추가되었습니다")
else: else:
print("샘플 가사가 없어 기본 프롬프트만 사용합니다") logger.info("샘플 가사가 없어 기본 프롬프트만 사용합니다")
print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") logger.info(f"[최종 프롬프트 길이: {len(updated_prompt)} 자]")
# 7. 모델에게 요청 # 7. 모델에게 요청
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
@ -763,10 +753,9 @@ async def make_automation(request: Request, conn: Connection):
:sample_song, :result_song, NOW() :sample_song, :result_song, NOW()
); );
""" """
print("\n[insert_params 선택된 속성 확인]") logger.debug("[insert_params 선택된 속성 확인]")
print(f"Categories: {selected_categories}") logger.debug(f"Categories: {selected_categories}")
print(f"Values: {selected_values}") logger.debug(f"Values: {selected_values}")
print()
# attr_category, attr_value # attr_category, attr_value
insert_params = { insert_params = {
@ -792,9 +781,9 @@ async def make_automation(request: Request, conn: Connection):
await conn.execute(text(insert_query), insert_params) await conn.execute(text(insert_query), insert_params)
await conn.commit() await conn.commit()
print("결과 저장 완료") logger.info("make_automation 결과 저장 완료")
print("\n전체 결과 조회 중...") logger.info("make_automation 전체 결과 조회 중...")
# 9. 생성 결과 가져오기 (created_at 역순) # 9. 생성 결과 가져오기 (created_at 역순)
select_query = """ select_query = """
@ -826,26 +815,20 @@ async def make_automation(request: Request, conn: Connection):
for row in all_results.fetchall() for row in all_results.fetchall()
] ]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n") logger.info(f"make_automation 전체 {len(results_list)}개의 결과 조회 완료")
return results_list return results_list
except HTTPException: except HTTPException:
raise raise
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(f"Database Error: {e}") logger.error(f"make_automation Database Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.", detail="데이터베이스 연결에 문제가 발생했습니다.",
) )
except Exception as e: except Exception as e:
print(f"Unexpected Error: {e}") logger.error(f"make_automation Unexpected Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.", detail="서비스 처리 중 오류가 발생했습니다.",

View File

@ -4,7 +4,6 @@ Song Background Tasks
노래 생성 관련 백그라운드 태스크를 정의합니다. 노래 생성 관련 백그라운드 태스크를 정의합니다.
""" """
import logging
import traceback import traceback
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
@ -17,11 +16,12 @@ from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.song.models import Song from app.song.models import Song
from app.utils.common import generate_task_id from app.utils.common import generate_task_id
from app.utils.logger import get_logger
from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.upload_blob_as_request import AzureBlobUploader
from config import prj_settings from config import prj_settings
# 로거 설정 # 로거 설정
logger = logging.getLogger(__name__) logger = get_logger("song")
# HTTP 요청 설정 # HTTP 요청 설정
REQUEST_TIMEOUT = 120.0 # 초 REQUEST_TIMEOUT = 120.0 # 초
@ -73,20 +73,16 @@ async def _update_song_status(
song.duration = duration song.duration = duration
await session.commit() await session.commit()
logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}") logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}")
print(f"[Song] Status updated - task_id: {task_id}, status: {status}")
return True return True
else: else:
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}") logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
print(f"[Song] NOT FOUND in DB - task_id: {task_id}")
return False return False
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}") logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}") logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False return False
@ -104,14 +100,12 @@ async def _download_audio(url: str, task_id: str) -> bytes:
httpx.HTTPError: 다운로드 실패 httpx.HTTPError: 다운로드 실패
""" """
logger.info(f"[Download] Downloading - task_id: {task_id}") logger.info(f"[Download] Downloading - task_id: {task_id}")
print(f"[Download] Downloading - task_id: {task_id}")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=REQUEST_TIMEOUT) response = await client.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status() response.raise_for_status()
logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
print(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
return response.content return response.content
@ -128,7 +122,6 @@ async def download_and_save_song(
store_name: 저장할 파일명에 사용할 업체명 store_name: 저장할 파일명에 사용할 업체명
""" """
logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}") logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
try: try:
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3 # 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
@ -146,11 +139,9 @@ async def download_and_save_song(
media_dir.mkdir(parents=True, exist_ok=True) media_dir.mkdir(parents=True, exist_ok=True)
file_path = media_dir / file_name file_path = media_dir / file_name
logger.info(f"[download_and_save_song] Directory created - path: {file_path}") logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
print(f"[download_and_save_song] Directory created - path: {file_path}")
# 오디오 파일 다운로드 # 오디오 파일 다운로드
logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}") logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
content = await _download_audio(audio_url, task_id) content = await _download_audio(audio_url, task_id)
@ -158,36 +149,27 @@ async def download_and_save_song(
await f.write(content) await f.write(content)
logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}") logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
# 프론트엔드에서 접근 가능한 URL 생성 # 프론트엔드에서 접근 가능한 URL 생성
relative_path = f"/media/song/{today}/{unique_id}/{file_name}" relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
base_url = f"http://{prj_settings.PROJECT_DOMAIN}" base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
file_url = f"{base_url}{relative_path}" file_url = f"{base_url}{relative_path}"
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}") logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
# Song 테이블 업데이트 # Song 테이블 업데이트
await _update_song_status(task_id, "completed", file_url) await _update_song_status(task_id, "completed", file_url)
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}") logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed") await _update_song_status(task_id, "failed")
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}") logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
print(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed") await _update_song_status(task_id, "failed")
except Exception as e: except Exception as e:
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}") logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed") await _update_song_status(task_id, "failed")
@ -204,7 +186,6 @@ async def download_and_upload_song_to_blob(
store_name: 저장할 파일명에 사용할 업체명 store_name: 저장할 파일명에 사용할 업체명
""" """
logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}") logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None temp_file_path: Path | None = None
try: try:
@ -220,11 +201,9 @@ async def download_and_upload_song_to_blob(
temp_dir.mkdir(parents=True, exist_ok=True) temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}") logger.info(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드 # 오디오 파일 다운로드
logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}") logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
content = await _download_audio(audio_url, task_id) content = await _download_audio(audio_url, task_id)
@ -232,7 +211,6 @@ async def download_and_upload_song_to_blob(
await f.write(content) await f.write(content)
logger.info(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") logger.info(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드 # Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id) uploader = AzureBlobUploader(task_id=task_id)
@ -244,29 +222,21 @@ async def download_and_upload_song_to_blob(
# SAS 토큰이 제외된 public_url 사용 # SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url blob_url = uploader.public_url
logger.info(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") logger.info(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Song 테이블 업데이트 # Song 테이블 업데이트
await _update_song_status(task_id, "completed", blob_url) await _update_song_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}") logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed") await _update_song_status(task_id, "failed")
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}") logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed") await _update_song_status(task_id, "failed")
except Exception as e: except Exception as e:
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_song_status(task_id, "failed") await _update_song_status(task_id, "failed")
finally: finally:
@ -275,10 +245,8 @@ async def download_and_upload_song_to_blob(
try: try:
temp_file_path.unlink() temp_file_path.unlink()
logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}") logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e: except Exception as e:
logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}") logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도 # 임시 디렉토리 삭제 시도
temp_dir = Path("media") / "temp" / task_id temp_dir = Path("media") / "temp" / task_id
@ -304,7 +272,6 @@ async def download_and_upload_song_by_suno_task_id(
duration: 노래 재생 시간 () duration: 노래 재생 시간 ()
""" """
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}") logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
temp_file_path: Path | None = None temp_file_path: Path | None = None
task_id: str | None = None task_id: str | None = None
@ -321,12 +288,10 @@ async def download_and_upload_song_by_suno_task_id(
if not song: if not song:
logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}") logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
return return
task_id = song.task_id task_id = song.task_id
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}") logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
# 파일명에 사용할 수 없는 문자 제거 # 파일명에 사용할 수 없는 문자 제거
safe_store_name = "".join( safe_store_name = "".join(
@ -340,11 +305,9 @@ async def download_and_upload_song_by_suno_task_id(
temp_dir.mkdir(parents=True, exist_ok=True) temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}") logger.info(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드 # 오디오 파일 다운로드
logger.info(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}") logger.info(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
content = await _download_audio(audio_url, task_id) content = await _download_audio(audio_url, task_id)
@ -352,7 +315,6 @@ async def download_and_upload_song_by_suno_task_id(
await f.write(content) await f.write(content)
logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}") logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드 # Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id) uploader = AzureBlobUploader(task_id=task_id)
@ -364,7 +326,6 @@ async def download_and_upload_song_by_suno_task_id(
# SAS 토큰이 제외된 public_url 사용 # SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url blob_url = uploader.public_url
logger.info(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}") logger.info(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
# Song 테이블 업데이트 # Song 테이블 업데이트
await _update_song_status( await _update_song_status(
@ -375,26 +336,19 @@ async def download_and_upload_song_by_suno_task_id(
duration=duration, duration=duration,
) )
logger.info(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}") logger.info(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}") logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
if task_id: if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id) await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}") logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
if task_id: if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id) await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
except Exception as e: except Exception as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}") logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
if task_id: if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id) await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
@ -404,10 +358,8 @@ async def download_and_upload_song_by_suno_task_id(
try: try:
temp_file_path.unlink() temp_file_path.unlink()
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}") logger.info(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
except Exception as e: except Exception as e:
logger.warning(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}") logger.warning(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도 # 임시 디렉토리 삭제 시도
if task_id: if task_id:

0
app/user/__init__.py Normal file
View File

0
app/user/api/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,3 @@
"""
User API v1 라우터 모듈
"""

View File

@ -0,0 +1,166 @@
"""
인증 API 라우터
카카오 로그인, 토큰 갱신, 로그아웃, 정보 조회 엔드포인트를 제공합니다.
"""
from typing import Optional
from fastapi import APIRouter, Depends, Header, Request, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.user.dependencies import get_current_user
from app.user.models import User
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoCallbackRequest,
KakaoLoginResponse,
LoginResponse,
RefreshTokenRequest,
UserResponse,
)
from app.user.services import auth_service, kakao_client
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.get(
"/kakao/login",
response_model=KakaoLoginResponse,
summary="카카오 로그인 URL 요청",
description="카카오 OAuth 인증 페이지 URL을 반환합니다.",
)
async def kakao_login() -> KakaoLoginResponse:
"""
카카오 로그인 URL 반환
프론트엔드에서 URL로 사용자를 리다이렉트하면
카카오 로그인 페이지가 표시됩니다.
"""
auth_url = kakao_client.get_authorization_url()
return KakaoLoginResponse(auth_url=auth_url)
@router.post(
"/kakao/callback",
response_model=LoginResponse,
summary="카카오 로그인 콜백",
description="카카오 인가 코드를 받아 로그인/가입을 처리하고 JWT 토큰을 발급합니다.",
)
async def kakao_callback(
request: Request,
body: KakaoCallbackRequest,
session: AsyncSession = Depends(get_session),
user_agent: Optional[str] = Header(None, alias="User-Agent"),
) -> LoginResponse:
"""
카카오 로그인 콜백
카카오 로그인 성공 발급받은 인가 코드로
JWT 토큰을 발급합니다.
신규 사용자인 경우 자동으로 회원가입이 처리됩니다.
"""
# 클라이언트 IP 추출
ip_address = request.client.host if request.client else None
# X-Forwarded-For 헤더 확인 (프록시/로드밸런서 뒤에 있는 경우)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
ip_address = forwarded_for.split(",")[0].strip()
return await auth_service.kakao_login(
code=body.code,
session=session,
user_agent=user_agent,
ip_address=ip_address,
)
@router.post(
"/refresh",
response_model=AccessTokenResponse,
summary="토큰 갱신",
description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.",
)
async def refresh_token(
body: RefreshTokenRequest,
session: AsyncSession = Depends(get_session),
) -> AccessTokenResponse:
"""
액세스 토큰 갱신
유효한 리프레시 토큰을 제출하면 액세스 토큰을 발급합니다.
리프레시 토큰은 변경되지 않습니다.
"""
return await auth_service.refresh_tokens(
refresh_token=body.refresh_token,
session=session,
)
@router.post(
"/logout",
status_code=status.HTTP_204_NO_CONTENT,
summary="로그아웃",
description="현재 세션의 리프레시 토큰을 폐기합니다.",
)
async def logout(
body: RefreshTokenRequest,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> Response:
"""
로그아웃
현재 사용 중인 리프레시 토큰을 폐기합니다.
해당 토큰으로는 이상 액세스 토큰을 갱신할 없습니다.
"""
await auth_service.logout(
user_id=current_user.id,
refresh_token=body.refresh_token,
session=session,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/logout/all",
status_code=status.HTTP_204_NO_CONTENT,
summary="모든 기기에서 로그아웃",
description="사용자의 모든 리프레시 토큰을 폐기합니다.",
)
async def logout_all(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> Response:
"""
모든 기기에서 로그아웃
사용자의 모든 리프레시 토큰을 폐기합니다.
모든 기기에서 재로그인이 필요합니다.
"""
await auth_service.logout_all(
user_id=current_user.id,
session=session,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get(
"/me",
response_model=UserResponse,
summary="내 정보 조회",
description="현재 로그인한 사용자의 정보를 반환합니다.",
)
async def get_me(
current_user: User = Depends(get_current_user),
) -> UserResponse:
"""
정보 조회
현재 로그인한 사용자의 상세 정보를 반환합니다.
"""
return UserResponse.model_validate(current_user)

View File

View File

@ -0,0 +1,15 @@
"""
User 의존성 주입 모듈
"""
from app.user.dependencies.auth import (
get_current_user,
get_current_user_optional,
get_current_admin,
)
__all__ = [
"get_current_user",
"get_current_user_optional",
"get_current_admin",
]

View File

@ -0,0 +1,145 @@
"""
인증 의존성 주입
FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다.
"""
from typing import Optional
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.user.exceptions import (
AdminRequiredError,
InvalidTokenError,
MissingTokenError,
TokenExpiredError,
UserInactiveError,
UserNotFoundError,
)
from app.user.models import User
from app.user.services.jwt import decode_token
security = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: AsyncSession = Depends(get_session),
) -> User:
"""
현재 로그인한 사용자 반환 (필수 인증)
Args:
credentials: HTTP Bearer 토큰
session: DB 세션
Returns:
User: 현재 로그인한 사용자
Raises:
MissingTokenError: 토큰이 없는 경우
InvalidTokenError: 토큰이 유효하지 않은 경우
TokenExpiredError: 토큰이 만료된 경우
UserNotFoundError: 사용자를 찾을 없는 경우
UserInactiveError: 비활성화된 계정인 경우
"""
if credentials is None:
raise MissingTokenError()
payload = decode_token(credentials.credentials)
if payload is None:
raise InvalidTokenError()
# 토큰 타입 확인
if payload.get("type") != "access":
raise InvalidTokenError("액세스 토큰이 아닙니다.")
user_id = payload.get("sub")
if user_id is None:
raise InvalidTokenError()
# 사용자 조회
result = await session.execute(
select(User).where(
User.id == int(user_id),
User.is_deleted == False, # noqa: E712
)
)
user = result.scalar_one_or_none()
if user is None:
raise UserNotFoundError()
if not user.is_active:
raise UserInactiveError()
return user
async def get_current_user_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: AsyncSession = Depends(get_session),
) -> Optional[User]:
"""
현재 로그인한 사용자 반환 (선택적 인증)
토큰이 없거나 유효하지 않으면 None 반환
Args:
credentials: HTTP Bearer 토큰
session: DB 세션
Returns:
User | None: 로그인한 사용자 또는 None
"""
if credentials is None:
return None
payload = decode_token(credentials.credentials)
if payload is None:
return None
if payload.get("type") != "access":
return None
user_id = payload.get("sub")
if user_id is None:
return None
result = await session.execute(
select(User).where(
User.id == int(user_id),
User.is_deleted == False, # noqa: E712
)
)
user = result.scalar_one_or_none()
if user is None or not user.is_active:
return None
return user
async def get_current_admin(
current_user: User = Depends(get_current_user),
) -> User:
"""
현재 로그인한 관리자 반환
Args:
current_user: 현재 로그인한 사용자
Returns:
User: 관리자 권한이 있는 사용자
Raises:
AdminRequiredError: 관리자 권한이 없는 경우
"""
if not current_user.is_admin and current_user.role != "admin":
raise AdminRequiredError()
return current_user

0
app/user/dependency.py Normal file
View File

141
app/user/exceptions.py Normal file
View File

@ -0,0 +1,141 @@
"""
User 모듈 커스텀 예외 정의
인증 사용자 관련 에러를 처리하기 위한 예외 클래스들입니다.
"""
from fastapi import HTTPException, status
class AuthException(HTTPException):
"""인증 관련 기본 예외"""
def __init__(
self,
status_code: int,
code: str,
message: str,
):
super().__init__(
status_code=status_code,
detail={"code": code, "message": message},
)
# =============================================================================
# 카카오 OAuth 관련 예외
# =============================================================================
class InvalidAuthCodeError(AuthException):
"""유효하지 않은 인가 코드"""
def __init__(self, message: str = "유효하지 않은 인가 코드입니다."):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
code="INVALID_CODE",
message=message,
)
class KakaoAuthFailedError(AuthException):
"""카카오 인증 실패"""
def __init__(self, message: str = "카카오 인증에 실패했습니다."):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
code="KAKAO_AUTH_FAILED",
message=message,
)
class KakaoAPIError(AuthException):
"""카카오 API 호출 오류"""
def __init__(self, message: str = "카카오 API 호출 중 오류가 발생했습니다."):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="KAKAO_API_ERROR",
message=message,
)
# =============================================================================
# JWT 토큰 관련 예외
# =============================================================================
class TokenExpiredError(AuthException):
"""토큰 만료"""
def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
code="TOKEN_EXPIRED",
message=message,
)
class InvalidTokenError(AuthException):
"""유효하지 않은 토큰"""
def __init__(self, message: str = "유효하지 않은 토큰입니다."):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
code="INVALID_TOKEN",
message=message,
)
class TokenRevokedError(AuthException):
"""취소된 토큰"""
def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
code="TOKEN_REVOKED",
message=message,
)
class MissingTokenError(AuthException):
"""토큰 누락"""
def __init__(self, message: str = "인증 토큰이 필요합니다."):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
code="MISSING_TOKEN",
message=message,
)
# =============================================================================
# 사용자 관련 예외
# =============================================================================
class UserNotFoundError(AuthException):
"""사용자 없음"""
def __init__(self, message: str = "사용자를 찾을 수 없습니다."):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
code="USER_NOT_FOUND",
message=message,
)
class UserInactiveError(AuthException):
"""비활성화된 계정"""
def __init__(self, message: str = "비활성화된 계정입니다. 관리자에게 문의하세요."):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
code="USER_INACTIVE",
message=message,
)
class AdminRequiredError(AuthException):
"""관리자 권한 필요"""
def __init__(self, message: str = "관리자 권한이 필요합니다."):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
code="ADMIN_REQUIRED",
message=message,
)

375
app/user/models.py Normal file
View File

@ -0,0 +1,375 @@
"""
User 모듈 SQLAlchemy 모델 정의
카카오 소셜 로그인 기반 사용자 관리 모델입니다.
"""
from datetime import date, datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.home.models import UserProject
class User(Base):
"""
사용자 테이블 (카카오 소셜 로그인)
카카오 로그인을 통해 인증된 사용자 정보를 저장합니다.
Attributes:
id: 고유 식별자 (자동 증가)
kakao_id: 카카오 고유 ID (필수, 유니크)
email: 이메일 주소 (선택, 카카오에서 제공 )
nickname: 카카오 닉네임 (선택)
profile_image_url: 카카오 프로필 이미지 URL (선택)
thumbnail_image_url: 카카오 썸네일 이미지 URL (선택)
phone: 전화번호 (선택)
name: 실명 (선택)
birth_date: 생년월일 (선택)
gender: 성별 (선택)
is_active: 계정 활성화 상태 (기본 True)
is_admin: 관리자 여부 (기본 False)
role: 사용자 권한 (user, manager, admin )
is_deleted: 소프트 삭제 여부 (기본 False)
deleted_at: 삭제 일시
last_login_at: 마지막 로그인 일시
created_at: 계정 생성 일시
updated_at: 계정 정보 수정 일시
권한 체계:
- user: 일반 사용자 (기본값)
- manager: 매니저 (일부 관리 권한)
- admin: 관리자 (is_admin=True와 동일)
소프트 삭제:
- is_deleted=True로 설정 삭제된 것으로 처리
- 실제 데이터는 DB에 유지됨
Relationships:
user_projects: Project와의 M:N 관계 (중계 테이블 통한 연결)
projects: 사용자가 참여한 프로젝트 목록 (Association Proxy)
카카오 API 응답 필드 매핑:
- kakao_id: id (카카오 회원번호)
- email: kakao_account.email
- nickname: kakao_account.profile.nickname 또는 properties.nickname
- profile_image_url: kakao_account.profile.profile_image_url
- thumbnail_image_url: kakao_account.profile.thumbnail_image_url
- birth_date: kakao_account.birthday 또는 kakao_account.birthyear
- gender: kakao_account.gender (male/female)
"""
__tablename__ = "user"
__table_args__ = (
Index("idx_user_kakao_id", "kakao_id", unique=True),
Index("idx_user_email", "email"),
Index("idx_user_phone", "phone"),
Index("idx_user_is_active", "is_active"),
Index("idx_user_is_deleted", "is_deleted"),
Index("idx_user_role", "role"),
Index("idx_user_created_at", "created_at"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
# ==========================================================================
# 기본 식별자
# ==========================================================================
id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
# ==========================================================================
# 카카오 소셜 로그인 필수 정보
# ==========================================================================
kakao_id: Mapped[int] = mapped_column(
BigInteger,
nullable=False,
unique=True,
comment="카카오 고유 ID (회원번호)",
)
# ==========================================================================
# 카카오에서 제공하는 사용자 정보 (선택적)
# ==========================================================================
email: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="이메일 주소 (카카오 계정 이메일, 동의 시 제공)",
)
nickname: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="카카오 닉네임",
)
profile_image_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="카카오 프로필 이미지 URL",
)
thumbnail_image_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="카카오 썸네일 이미지 URL",
)
# ==========================================================================
# 추가 사용자 정보
# ==========================================================================
phone: Mapped[Optional[str]] = mapped_column(
String(20),
nullable=True,
comment="전화번호 (본인인증, 알림용)",
)
name: Mapped[Optional[str]] = mapped_column(
String(50),
nullable=True,
comment="실명 (법적 실명, 결제/계약 시 사용)",
)
birth_date: Mapped[Optional[date]] = mapped_column(
Date,
nullable=True,
comment="생년월일 (카카오 제공 또는 직접 입력)",
)
gender: Mapped[Optional[str]] = mapped_column(
String(10),
nullable=True,
comment="성별 (male: 남성, female: 여성)",
)
# ==========================================================================
# 계정 상태 관리
# ==========================================================================
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="계정 활성화 상태 (비활성화 시 로그인 차단)",
)
is_admin: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="관리자 권한 여부",
)
role: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="user",
comment="사용자 권한 (user: 일반, manager: 매니저, admin: 관리자)",
)
# ==========================================================================
# 소프트 삭제
# ==========================================================================
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="소프트 삭제 여부 (True: 삭제됨)",
)
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="삭제 일시",
)
# ==========================================================================
# 시간 정보
# ==========================================================================
last_login_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="마지막 로그인 일시",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="계정 생성 일시",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
comment="계정 정보 수정 일시",
)
# ==========================================================================
# Project M:N 관계 (중계 테이블 UserProject 통한 연결)
# ==========================================================================
# back_populates: UserProject.user와 양방향 연결
# cascade: User 삭제 시 UserProject 레코드도 삭제 (Project는 유지)
# lazy="selectin": N+1 문제 방지
# ==========================================================================
user_projects: Mapped[List["UserProject"]] = relationship(
"UserProject",
back_populates="user",
cascade="all, delete-orphan",
lazy="selectin",
)
# ==========================================================================
# RefreshToken 1:N 관계
# ==========================================================================
# 한 사용자는 여러 리프레시 토큰을 가질 수 있음 (다중 기기 로그인)
# ==========================================================================
refresh_tokens: Mapped[List["RefreshToken"]] = relationship(
"RefreshToken",
back_populates="user",
cascade="all, delete-orphan",
lazy="selectin",
)
def __repr__(self) -> str:
return (
f"<User("
f"id={self.id}, "
f"kakao_id={self.kakao_id}, "
f"nickname='{self.nickname}', "
f"role='{self.role}', "
f"is_active={self.is_active}, "
f"is_deleted={self.is_deleted}"
f")>"
)
class RefreshToken(Base):
"""
리프레시 토큰 테이블
JWT 리프레시 토큰을 DB에 저장하여 관리합니다.
토큰 폐기(로그아웃), 다중 기기 로그인 관리 등에 활용됩니다.
Attributes:
id: 고유 식별자 (자동 증가)
user_id: 사용자 외래키 (User.id 참조)
token_hash: 리프레시 토큰의 SHA-256 해시값 (원본 저장 X)
expires_at: 토큰 만료 일시
is_revoked: 토큰 폐기 여부 (로그아웃 True)
created_at: 토큰 생성 일시
revoked_at: 토큰 폐기 일시
user_agent: 접속 기기 정보 (브라우저, OS )
ip_address: 접속 IP 주소
보안 고려사항:
- 토큰 원본은 저장하지 않고 해시값만 저장
- 토큰 검증 해시값 비교로 유효성 확인
- 로그아웃 is_revoked=True로 설정하여 재사용 방지
"""
__tablename__ = "refresh_token"
__table_args__ = (
Index("idx_refresh_token_user_id", "user_id"),
Index("idx_refresh_token_token_hash", "token_hash", unique=True),
Index("idx_refresh_token_expires_at", "expires_at"),
Index("idx_refresh_token_is_revoked", "is_revoked"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
user_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
comment="사용자 외래키 (User.id 참조)",
)
token_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,
unique=True,
comment="리프레시 토큰 SHA-256 해시값",
)
expires_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
comment="토큰 만료 일시",
)
is_revoked: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="토큰 폐기 여부 (True: 폐기됨)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="토큰 생성 일시",
)
revoked_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="토큰 폐기 일시",
)
user_agent: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True,
comment="접속 기기 정보 (브라우저, OS 등)",
)
ip_address: Mapped[Optional[str]] = mapped_column(
String(45),
nullable=True,
comment="접속 IP 주소 (IPv4/IPv6)",
)
# ==========================================================================
# User 관계
# ==========================================================================
user: Mapped["User"] = relationship(
"User",
back_populates="refresh_tokens",
)
def __repr__(self) -> str:
return (
f"<RefreshToken("
f"id={self.id}, "
f"user_id={self.user_id}, "
f"is_revoked={self.is_revoked}, "
f"expires_at={self.expires_at}"
f")>"
)

View File

@ -0,0 +1,25 @@
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoCallbackRequest,
KakaoLoginResponse,
KakaoTokenResponse,
KakaoUserInfo,
LoginResponse,
RefreshTokenRequest,
TokenResponse,
UserBriefResponse,
UserResponse,
)
__all__ = [
"AccessTokenResponse",
"KakaoCallbackRequest",
"KakaoLoginResponse",
"KakaoTokenResponse",
"KakaoUserInfo",
"LoginResponse",
"RefreshTokenRequest",
"TokenResponse",
"UserBriefResponse",
"UserResponse",
]

View File

@ -0,0 +1,220 @@
"""
User 모듈 Pydantic 스키마 정의
API 요청/응답 검증을 위한 스키마들입니다.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# =============================================================================
# 카카오 OAuth 스키마
# =============================================================================
class KakaoLoginResponse(BaseModel):
"""카카오 로그인 URL 응답"""
auth_url: str = Field(..., description="카카오 인증 페이지 URL")
model_config = {
"json_schema_extra": {
"example": {
"auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8000/api/v1/user/auth/kakao/callback&response_type=code"
}
}
}
class KakaoCallbackRequest(BaseModel):
"""카카오 콜백 요청 (인가 코드)"""
code: str = Field(..., min_length=1, description="카카오 인가 코드")
model_config = {
"json_schema_extra": {
"example": {
"code": "AUTHORIZATION_CODE_FROM_KAKAO"
}
}
}
# =============================================================================
# JWT 토큰 스키마
# =============================================================================
class TokenResponse(BaseModel):
"""토큰 발급 응답"""
access_token: str = Field(..., description="액세스 토큰")
refresh_token: str = Field(..., description="리프레시 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
model_config = {
"json_schema_extra": {
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.xxx",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy",
"token_type": "Bearer",
"expires_in": 3600
}
}
}
class AccessTokenResponse(BaseModel):
"""액세스 토큰 갱신 응답"""
access_token: str = Field(..., description="액세스 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
model_config = {
"json_schema_extra": {
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.new_token",
"token_type": "Bearer",
"expires_in": 3600
}
}
}
class RefreshTokenRequest(BaseModel):
"""토큰 갱신 요청"""
refresh_token: str = Field(..., min_length=1, description="리프레시 토큰")
model_config = {
"json_schema_extra": {
"example": {
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy"
}
}
}
# =============================================================================
# 사용자 정보 스키마
# =============================================================================
class UserResponse(BaseModel):
"""사용자 정보 응답"""
id: int = Field(..., description="사용자 ID")
kakao_id: int = Field(..., description="카카오 회원번호")
email: Optional[str] = Field(None, description="이메일")
nickname: Optional[str] = Field(None, description="닉네임")
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
thumbnail_image_url: Optional[str] = Field(None, description="썸네일 이미지 URL")
is_active: bool = Field(..., description="계정 활성화 상태")
is_admin: bool = Field(..., description="관리자 여부")
last_login_at: Optional[datetime] = Field(None, description="마지막 로그인 일시")
created_at: datetime = Field(..., description="가입 일시")
model_config = {
"from_attributes": True,
"json_schema_extra": {
"example": {
"id": 1,
"kakao_id": 1234567890,
"email": "user@kakao.com",
"nickname": "홍길동",
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
"thumbnail_image_url": "https://k.kakaocdn.net/dn/.../thumb.jpg",
"is_active": True,
"is_admin": False,
"last_login_at": "2026-01-15T10:30:00",
"created_at": "2026-01-01T09:00:00"
}
}
}
class UserBriefResponse(BaseModel):
"""사용자 간략 정보 (토큰 응답에 포함)"""
id: int = Field(..., description="사용자 ID")
nickname: Optional[str] = Field(None, description="닉네임")
email: Optional[str] = Field(None, description="이메일")
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
is_new_user: bool = Field(..., description="신규 가입 여부")
model_config = {
"from_attributes": True,
"json_schema_extra": {
"example": {
"id": 1,
"nickname": "홍길동",
"email": "user@kakao.com",
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
"is_new_user": False
}
}
}
class LoginResponse(BaseModel):
"""로그인 응답 (토큰 + 사용자 정보)"""
access_token: str = Field(..., description="액세스 토큰")
refresh_token: str = Field(..., description="리프레시 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
user: UserBriefResponse = Field(..., description="사용자 정보")
model_config = {
"json_schema_extra": {
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.xxx",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3MDU4MzM2MDB9.yyy",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"id": 1,
"nickname": "홍길동",
"email": "user@kakao.com",
"profile_image_url": "https://k.kakaocdn.net/dn/.../profile.jpg",
"is_new_user": False
}
}
}
}
# =============================================================================
# 내부 사용 스키마 (카카오 API 응답 파싱)
# =============================================================================
class KakaoTokenResponse(BaseModel):
"""카카오 토큰 응답 (내부 사용)"""
access_token: str
token_type: str
refresh_token: Optional[str] = None
expires_in: int
scope: Optional[str] = None
refresh_token_expires_in: Optional[int] = None
class KakaoProfile(BaseModel):
"""카카오 프로필 정보 (내부 사용)"""
nickname: Optional[str] = None
profile_image_url: Optional[str] = None
thumbnail_image_url: Optional[str] = None
is_default_image: Optional[bool] = None
class KakaoAccount(BaseModel):
"""카카오 계정 정보 (내부 사용)"""
email: Optional[str] = None
profile: Optional[KakaoProfile] = None
class KakaoUserInfo(BaseModel):
"""카카오 사용자 정보 (내부 사용)"""
id: int
kakao_account: Optional[KakaoAccount] = None

View File

@ -0,0 +1,29 @@
"""
User 서비스 모듈
인증 사용자 관련 비즈니스 로직을 제공합니다.
"""
from app.user.services.auth import AuthService, auth_service
from app.user.services.jwt import (
create_access_token,
create_refresh_token,
decode_token,
get_access_token_expire_seconds,
get_refresh_token_expires_at,
get_token_hash,
)
from app.user.services.kakao import KakaoOAuthClient, kakao_client
__all__ = [
"AuthService",
"auth_service",
"KakaoOAuthClient",
"kakao_client",
"create_access_token",
"create_refresh_token",
"decode_token",
"get_token_hash",
"get_refresh_token_expires_at",
"get_access_token_expire_seconds",
]

377
app/user/services/auth.py Normal file
View File

@ -0,0 +1,377 @@
"""
인증 서비스
카카오 로그인, JWT 토큰 관리, 사용자 인증 관련 비즈니스 로직을 처리합니다.
"""
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.user.exceptions import (
InvalidTokenError,
TokenExpiredError,
TokenRevokedError,
UserInactiveError,
UserNotFoundError,
)
from app.user.models import RefreshToken, User
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoUserInfo,
LoginResponse,
UserBriefResponse,
)
from app.user.services.jwt import (
create_access_token,
create_refresh_token,
decode_token,
get_access_token_expire_seconds,
get_refresh_token_expires_at,
get_token_hash,
)
from app.user.services.kakao import kakao_client
class AuthService:
"""
인증 서비스
카카오 로그인, JWT 토큰 발급/갱신/폐기, 사용자 관리 기능을 제공합니다.
"""
async def kakao_login(
self,
code: str,
session: AsyncSession,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> LoginResponse:
"""
카카오 로그인 처리
1. 카카오 인가 코드로 액세스 토큰 획득
2. 카카오 사용자 정보 조회
3. 사용자 조회 또는 생성
4. JWT 토큰 발급 리프레시 토큰 저장
Args:
code: 카카오 인가 코드
session: DB 세션
user_agent: 클라이언트 User-Agent
ip_address: 클라이언트 IP 주소
Returns:
LoginResponse: 토큰 사용자 정보
"""
# 1. 카카오 토큰 획득
kakao_token = await kakao_client.get_access_token(code)
# 2. 카카오 사용자 정보 조회
kakao_user_info = await kakao_client.get_user_info(kakao_token.access_token)
# 3. 사용자 조회 또는 생성
user, is_new_user = await self._get_or_create_user(kakao_user_info, session)
# 4. 비활성화 계정 체크
if not user.is_active:
raise UserInactiveError()
# 5. JWT 토큰 생성
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
# 6. 리프레시 토큰 DB 저장
await self._save_refresh_token(
user_id=user.id,
token=refresh_token,
session=session,
user_agent=user_agent,
ip_address=ip_address,
)
# 7. 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc)
await session.commit()
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
token_type="Bearer",
expires_in=get_access_token_expire_seconds(),
user=UserBriefResponse(
id=user.id,
nickname=user.nickname,
email=user.email,
profile_image_url=user.profile_image_url,
is_new_user=is_new_user,
),
)
async def refresh_tokens(
self,
refresh_token: str,
session: AsyncSession,
) -> AccessTokenResponse:
"""
리프레시 토큰으로 액세스 토큰 갱신
Args:
refresh_token: 리프레시 토큰
session: DB 세션
Returns:
AccessTokenResponse: 액세스 토큰
Raises:
InvalidTokenError: 토큰이 유효하지 않은 경우
TokenExpiredError: 토큰이 만료된 경우
TokenRevokedError: 토큰이 폐기된 경우
"""
# 1. 토큰 디코딩 및 검증
payload = decode_token(refresh_token)
if payload is None:
raise InvalidTokenError()
if payload.get("type") != "refresh":
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
# 2. DB에서 리프레시 토큰 조회
token_hash = get_token_hash(refresh_token)
db_token = await self._get_refresh_token_by_hash(token_hash, session)
if db_token is None:
raise InvalidTokenError()
# 3. 토큰 상태 확인
if db_token.is_revoked:
raise TokenRevokedError()
if db_token.expires_at < datetime.now(timezone.utc):
raise TokenExpiredError()
# 4. 사용자 확인
user_id = int(payload.get("sub"))
user = await self._get_user_by_id(user_id, session)
if user is None:
raise UserNotFoundError()
if not user.is_active:
raise UserInactiveError()
# 5. 새 액세스 토큰 발급
new_access_token = create_access_token(user.id)
return AccessTokenResponse(
access_token=new_access_token,
token_type="Bearer",
expires_in=get_access_token_expire_seconds(),
)
async def logout(
self,
user_id: int,
refresh_token: str,
session: AsyncSession,
) -> None:
"""
로그아웃 (리프레시 토큰 폐기)
Args:
user_id: 사용자 ID
refresh_token: 폐기할 리프레시 토큰
session: DB 세션
"""
token_hash = get_token_hash(refresh_token)
await self._revoke_refresh_token_by_hash(token_hash, session)
async def logout_all(
self,
user_id: int,
session: AsyncSession,
) -> None:
"""
모든 기기에서 로그아웃 (사용자의 모든 리프레시 토큰 폐기)
Args:
user_id: 사용자 ID
session: DB 세션
"""
await self._revoke_all_user_tokens(user_id, session)
async def _get_or_create_user(
self,
kakao_user_info: KakaoUserInfo,
session: AsyncSession,
) -> tuple[User, bool]:
"""
사용자 조회 또는 생성
Args:
kakao_user_info: 카카오 사용자 정보
session: DB 세션
Returns:
(User, is_new_user) 튜플
"""
kakao_id = kakao_user_info.id
kakao_account = kakao_user_info.kakao_account
profile = kakao_account.profile if kakao_account else None
# 기존 사용자 조회
result = await session.execute(
select(User).where(User.kakao_id == kakao_id)
)
user = result.scalar_one_or_none()
if user is not None:
# 기존 사용자: 프로필 정보 업데이트
if profile:
user.nickname = profile.nickname
user.profile_image_url = profile.profile_image_url
user.thumbnail_image_url = profile.thumbnail_image_url
if kakao_account and kakao_account.email:
user.email = kakao_account.email
await session.flush()
return user, False
# 신규 사용자 생성
new_user = User(
kakao_id=kakao_id,
email=kakao_account.email if kakao_account else None,
nickname=profile.nickname if profile else None,
profile_image_url=profile.profile_image_url if profile else None,
thumbnail_image_url=profile.thumbnail_image_url if profile else None,
)
session.add(new_user)
await session.flush()
await session.refresh(new_user)
return new_user, True
async def _save_refresh_token(
self,
user_id: int,
token: str,
session: AsyncSession,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> RefreshToken:
"""
리프레시 토큰 DB 저장
Args:
user_id: 사용자 ID
token: 리프레시 토큰
session: DB 세션
user_agent: User-Agent
ip_address: IP 주소
Returns:
저장된 RefreshToken 객체
"""
token_hash = get_token_hash(token)
expires_at = get_refresh_token_expires_at()
refresh_token = RefreshToken(
user_id=user_id,
token_hash=token_hash,
expires_at=expires_at,
user_agent=user_agent,
ip_address=ip_address,
)
session.add(refresh_token)
await session.flush()
return refresh_token
async def _get_refresh_token_by_hash(
self,
token_hash: str,
session: AsyncSession,
) -> Optional[RefreshToken]:
"""
해시값으로 리프레시 토큰 조회
Args:
token_hash: 토큰 해시값
session: DB 세션
Returns:
RefreshToken 객체 또는 None
"""
result = await session.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
return result.scalar_one_or_none()
async def _get_user_by_id(
self,
user_id: int,
session: AsyncSession,
) -> Optional[User]:
"""
ID로 사용자 조회
Args:
user_id: 사용자 ID
session: DB 세션
Returns:
User 객체 또는 None
"""
result = await session.execute(
select(User).where(User.id == user_id, User.is_deleted == False) # noqa: E712
)
return result.scalar_one_or_none()
async def _revoke_refresh_token_by_hash(
self,
token_hash: str,
session: AsyncSession,
) -> None:
"""
해시값으로 리프레시 토큰 폐기
Args:
token_hash: 토큰 해시값
session: DB 세션
"""
await session.execute(
update(RefreshToken)
.where(RefreshToken.token_hash == token_hash)
.values(
is_revoked=True,
revoked_at=datetime.now(timezone.utc),
)
)
await session.commit()
async def _revoke_all_user_tokens(
self,
user_id: int,
session: AsyncSession,
) -> None:
"""
사용자의 모든 리프레시 토큰 폐기
Args:
user_id: 사용자 ID
session: DB 세션
"""
await session.execute(
update(RefreshToken)
.where(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked == False, # noqa: E712
)
.values(
is_revoked=True,
revoked_at=datetime.now(timezone.utc),
)
)
await session.commit()
auth_service = AuthService()

121
app/user/services/jwt.py Normal file
View File

@ -0,0 +1,121 @@
"""
JWT 토큰 유틸리티
Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니다.
"""
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from config import jwt_settings
def create_access_token(user_id: int) -> str:
"""
JWT 액세스 토큰 생성
Args:
user_id: 사용자 ID
Returns:
JWT 액세스 토큰 문자열
"""
expire = datetime.now(timezone.utc) + timedelta(
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {
"sub": str(user_id),
"exp": expire,
"type": "access",
}
return jwt.encode(
to_encode,
jwt_settings.JWT_SECRET,
algorithm=jwt_settings.JWT_ALGORITHM,
)
def create_refresh_token(user_id: int) -> str:
"""
JWT 리프레시 토큰 생성
Args:
user_id: 사용자 ID
Returns:
JWT 리프레시 토큰 문자열
"""
expire = datetime.now(timezone.utc) + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode = {
"sub": str(user_id),
"exp": expire,
"type": "refresh",
}
return jwt.encode(
to_encode,
jwt_settings.JWT_SECRET,
algorithm=jwt_settings.JWT_ALGORITHM,
)
def decode_token(token: str) -> Optional[dict]:
"""
JWT 토큰 디코딩
Args:
token: JWT 토큰 문자열
Returns:
디코딩된 페이로드 딕셔너리, 실패 None
"""
try:
payload = jwt.decode(
token,
jwt_settings.JWT_SECRET,
algorithms=[jwt_settings.JWT_ALGORITHM],
)
return payload
except JWTError:
return None
def get_token_hash(token: str) -> str:
"""
토큰의 SHA-256 해시값 생성
리프레시 토큰을 DB에 저장할 원본 대신 해시값을 저장합니다.
Args:
token: 해시할 토큰 문자열
Returns:
토큰의 SHA-256 해시값 (64 hex 문자열)
"""
return hashlib.sha256(token.encode()).hexdigest()
def get_refresh_token_expires_at() -> datetime:
"""
리프레시 토큰 만료 시간 계산
Returns:
리프레시 토큰 만료 datetime (UTC)
"""
return datetime.now(timezone.utc) + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
def get_access_token_expire_seconds() -> int:
"""
액세스 토큰 만료 시간() 반환
Returns:
액세스 토큰 만료 시간 ()
"""
return jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60

123
app/user/services/kakao.py Normal file
View File

@ -0,0 +1,123 @@
"""
카카오 OAuth API 클라이언트
카카오 로그인 인증 흐름을 처리하는 클라이언트입니다.
"""
import aiohttp
from config import kakao_settings
from app.user.exceptions import KakaoAPIError, KakaoAuthFailedError
from app.user.schemas.user_schema import KakaoTokenResponse, KakaoUserInfo
class KakaoOAuthClient:
"""
카카오 OAuth API 클라이언트
카카오 로그인 인증 흐름:
1. get_authorization_url() 카카오 로그인 페이지 URL 획득
2. 사용자가 카카오에서 로그인 인가 코드(code) 발급
3. get_access_token()으로 인가 코드를 액세스 토큰으로 교환
4. get_user_info() 사용자 정보 조회
"""
AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
TOKEN_URL = "https://kauth.kakao.com/oauth/token"
USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
def __init__(self) -> None:
self.client_id = kakao_settings.KAKAO_CLIENT_ID
self.client_secret = kakao_settings.KAKAO_CLIENT_SECRET
self.redirect_uri = kakao_settings.KAKAO_REDIRECT_URI
def get_authorization_url(self) -> str:
"""
카카오 로그인 페이지 URL 반환
Returns:
카카오 OAuth 인증 페이지 URL
"""
return (
f"{self.AUTH_URL}"
f"?client_id={self.client_id}"
f"&redirect_uri={self.redirect_uri}"
f"&response_type=code"
)
async def get_access_token(self, code: str) -> KakaoTokenResponse:
"""
인가 코드로 액세스 토큰 획득
Args:
code: 카카오 로그인 발급받은 인가 코드
Returns:
KakaoTokenResponse: 카카오 토큰 정보
Raises:
KakaoAuthFailedError: 토큰 발급 실패
KakaoAPIError: API 호출 오류
"""
try:
async with aiohttp.ClientSession() as session:
data = {
"grant_type": "authorization_code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"code": code,
}
if self.client_secret:
data["client_secret"] = self.client_secret
async with session.post(self.TOKEN_URL, data=data) as response:
result = await response.json()
if "error" in result:
error_desc = result.get(
"error_description", result.get("error", "알 수 없는 오류")
)
raise KakaoAuthFailedError(f"카카오 토큰 발급 실패: {error_desc}")
return KakaoTokenResponse(**result)
except KakaoAuthFailedError:
raise
except Exception as e:
raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}")
async def get_user_info(self, access_token: str) -> KakaoUserInfo:
"""
액세스 토큰으로 사용자 정보 조회
Args:
access_token: 카카오 액세스 토큰
Returns:
KakaoUserInfo: 카카오 사용자 정보
Raises:
KakaoAuthFailedError: 사용자 정보 조회 실패
KakaoAPIError: API 호출 오류
"""
try:
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {access_token}"}
async with session.get(self.USER_INFO_URL, headers=headers) as response:
result = await response.json()
if "id" not in result:
raise KakaoAuthFailedError("카카오 사용자 정보를 가져올 수 없습니다.")
return KakaoUserInfo(**result)
except KakaoAuthFailedError:
raise
except Exception as e:
raise KakaoAPIError(f"카카오 API 호출 중 오류 발생: {str(e)}")
kakao_client = KakaoOAuthClient()

View File

View File

View File

View File

View File

@ -1,14 +1,14 @@
import json import json
import logging
import re import re
from openai import AsyncOpenAI from openai import AsyncOpenAI
from app.utils.logger import get_logger
from config import apikey_settings from config import apikey_settings
from app.utils.prompts.prompts import Prompt from app.utils.prompts.prompts import Prompt
# 로거 설정 # 로거 설정
logger = logging.getLogger(__name__) logger = get_logger("chatgpt")
# fmt: on # fmt: on
@ -44,5 +44,4 @@ class ChatgptService:
# GPT API 호출 # GPT API 호출
response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model) response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
return response return response

View File

@ -30,16 +30,16 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
""" """
import copy import copy
import logging
import time import time
from typing import Literal from typing import Literal
import httpx import httpx
from app.utils.logger import get_logger
from config import apikey_settings, creatomate_settings from config import apikey_settings, creatomate_settings
# 로거 설정 # 로거 설정
logger = logging.getLogger(__name__) logger = get_logger("creatomate")
# Orientation 타입 정의 # Orientation 타입 정의
@ -76,14 +76,14 @@ async def close_shared_client() -> None:
if _shared_client is not None and not _shared_client.is_closed: if _shared_client is not None and not _shared_client.is_closed:
await _shared_client.aclose() await _shared_client.aclose()
_shared_client = None _shared_client = None
print("[CreatomateService] Shared HTTP client closed") logger.info("[CreatomateService] Shared HTTP client closed")
def clear_template_cache() -> None: def clear_template_cache() -> None:
"""템플릿 캐시를 전체 삭제합니다.""" """템플릿 캐시를 전체 삭제합니다."""
global _template_cache global _template_cache
_template_cache.clear() _template_cache.clear()
print("[CreatomateService] Template cache cleared") logger.info("[CreatomateService] Template cache cleared")
def _is_cache_valid(cached_at: float) -> bool: def _is_cache_valid(cached_at: float) -> bool:
@ -164,7 +164,6 @@ class CreatomateService:
httpx.HTTPError: 요청 실패 httpx.HTTPError: 요청 실패
""" """
logger.info(f"[Creatomate] {method} {url}") logger.info(f"[Creatomate] {method} {url}")
print(f"[Creatomate] {method} {url}")
client = await get_shared_client() client = await get_shared_client()
@ -180,7 +179,6 @@ class CreatomateService:
raise ValueError(f"Unsupported HTTP method: {method}") raise ValueError(f"Unsupported HTTP method: {method}")
logger.info(f"[Creatomate] Response - Status: {response.status_code}") logger.info(f"[Creatomate] Response - Status: {response.status_code}")
print(f"[Creatomate] Response - Status: {response.status_code}")
return response return response
async def get_all_templates_data(self) -> dict: async def get_all_templates_data(self) -> dict:
@ -210,12 +208,12 @@ class CreatomateService:
if use_cache and template_id in _template_cache: if use_cache and template_id in _template_cache:
cached = _template_cache[template_id] cached = _template_cache[template_id]
if _is_cache_valid(cached["cached_at"]): if _is_cache_valid(cached["cached_at"]):
print(f"[CreatomateService] Cache HIT - {template_id}") logger.debug(f"[CreatomateService] Cache HIT - {template_id}")
return copy.deepcopy(cached["data"]) return copy.deepcopy(cached["data"])
else: else:
# 만료된 캐시 삭제 # 만료된 캐시 삭제
del _template_cache[template_id] del _template_cache[template_id]
print(f"[CreatomateService] Cache EXPIRED - {template_id}") logger.debug(f"[CreatomateService] Cache EXPIRED - {template_id}")
# API 호출 # API 호출
url = f"{self.BASE_URL}/v1/templates/{template_id}" url = f"{self.BASE_URL}/v1/templates/{template_id}"
@ -228,7 +226,7 @@ class CreatomateService:
"data": data, "data": data,
"cached_at": time.time(), "cached_at": time.time(),
} }
print(f"[CreatomateService] Cache MISS - {template_id} (cached)") logger.debug(f"[CreatomateService] Cache MISS - {template_id} (cached)")
return copy.deepcopy(data) return copy.deepcopy(data)
@ -444,12 +442,13 @@ class CreatomateService:
if animation["transition"]: if animation["transition"]:
total_template_duration -= animation["duration"] total_template_duration -= animation["duration"]
except Exception as e: except Exception as e:
print(f"[calc_scene_duration] Error processing element: {elem}, {e}") logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}")
return total_template_duration return total_template_duration
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 template["duration"] = target_duration
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
@ -466,7 +465,7 @@ class CreatomateService:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
animation["duration"] = animation["duration"] * extend_rate animation["duration"] = animation["duration"] * extend_rate
except Exception as e: except Exception as e:
print( logger.error(
f"[extend_template_duration] Error processing element: {elem}, {e}" f"[extend_template_duration] Error processing element: {elem}, {e}"
) )

337
app/utils/logger.py Normal file
View File

@ -0,0 +1,337 @@
"""
FastAPI용 로깅 모듈
Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템.
사용 예시:
from app.utils.logger import get_logger
logger = get_logger("song")
logger.info("노래 생성 완료")
logger.error("오류 발생", exc_info=True)
로그 레벨:
1. DEBUG: 디버깅 목적
2. INFO: 일반 정보
3. WARNING: 경고 정보 (작은 문제)
4. ERROR: 오류 정보 ( 문제)
5. CRITICAL: 아주 심각한 문제
"""
import logging
import sys
from datetime import datetime
from functools import lru_cache
from logging.handlers import RotatingFileHandler
from typing import Literal
from config import log_settings
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
LOG_DIR = log_settings.get_log_dir()
# 로그 레벨 타입
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
class LoggerConfig:
"""로거 설정 클래스 (config.py의 LogSettings 참조)"""
# 출력 대상 설정 (LogSettings에서 가져옴)
CONSOLE_ENABLED: bool = log_settings.LOG_CONSOLE_ENABLED
FILE_ENABLED: bool = log_settings.LOG_FILE_ENABLED
# 기본 설정 (LogSettings에서 가져옴)
DEFAULT_LEVEL: str = log_settings.LOG_LEVEL
CONSOLE_LEVEL: str = log_settings.LOG_CONSOLE_LEVEL
FILE_LEVEL: str = log_settings.LOG_FILE_LEVEL
MAX_BYTES: int = log_settings.LOG_MAX_SIZE_MB * 1024 * 1024
BACKUP_COUNT: int = log_settings.LOG_BACKUP_COUNT
ENCODING: str = "utf-8"
# 포맷 설정 (LogSettings에서 가져옴)
CONSOLE_FORMAT: str = log_settings.LOG_CONSOLE_FORMAT
FILE_FORMAT: str = log_settings.LOG_FILE_FORMAT
DATE_FORMAT: str = log_settings.LOG_DATE_FORMAT
def _create_console_handler() -> logging.StreamHandler:
"""콘솔 핸들러 생성"""
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(getattr(logging, LoggerConfig.CONSOLE_LEVEL))
formatter = logging.Formatter(
fmt=LoggerConfig.CONSOLE_FORMAT,
datefmt=LoggerConfig.DATE_FORMAT,
style="{",
)
handler.setFormatter(formatter)
return handler
# =============================================================================
# 공유 파일 핸들러 (싱글톤)
# 모든 로거가 동일한 파일 핸들러를 공유하여 하나의 로그 파일에 기록
# =============================================================================
_shared_file_handler: RotatingFileHandler | None = None
_shared_error_handler: RotatingFileHandler | None = None
def _get_shared_file_handler() -> RotatingFileHandler:
"""
공유 파일 핸들러 반환 (싱글톤)
모든 로거가 하나의 기본 로그 파일(app.log) 기록합니다.
파일명: logs/{날짜}_app.log
"""
global _shared_file_handler
if _shared_file_handler is None:
today = datetime.today().strftime("%Y-%m-%d")
log_file = LOG_DIR / f"{today}_app.log"
_shared_file_handler = RotatingFileHandler(
filename=log_file,
maxBytes=LoggerConfig.MAX_BYTES,
backupCount=LoggerConfig.BACKUP_COUNT,
encoding=LoggerConfig.ENCODING,
)
_shared_file_handler.setLevel(getattr(logging, LoggerConfig.FILE_LEVEL))
formatter = logging.Formatter(
fmt=LoggerConfig.FILE_FORMAT,
datefmt=LoggerConfig.DATE_FORMAT,
style="{",
)
_shared_file_handler.setFormatter(formatter)
return _shared_file_handler
def _get_shared_error_handler() -> RotatingFileHandler:
"""
공유 에러 파일 핸들러 반환 (싱글톤)
모든 로거의 ERROR 이상 로그가 하나의 에러 로그 파일(error.log) 기록됩니다.
파일명: logs/{날짜}_error.log
"""
global _shared_error_handler
if _shared_error_handler is None:
today = datetime.today().strftime("%Y-%m-%d")
log_file = LOG_DIR / f"{today}_error.log"
_shared_error_handler = RotatingFileHandler(
filename=log_file,
maxBytes=LoggerConfig.MAX_BYTES,
backupCount=LoggerConfig.BACKUP_COUNT,
encoding=LoggerConfig.ENCODING,
)
_shared_error_handler.setLevel(logging.ERROR)
formatter = logging.Formatter(
fmt=LoggerConfig.FILE_FORMAT,
datefmt=LoggerConfig.DATE_FORMAT,
style="{",
)
_shared_error_handler.setFormatter(formatter)
return _shared_error_handler
@lru_cache(maxsize=32)
def get_logger(name: str = "app") -> logging.Logger:
"""
로거 인스턴스 반환 (캐싱 적용)
Args:
name: 로거 이름 (모듈명 권장: "song", "lyric", "video" )
Returns:
설정된 로거 인스턴스
Example:
logger = get_logger("song")
logger.info("노래 처리 시작")
"""
logger = logging.getLogger(name)
# 이미 핸들러가 설정된 경우 반환
if logger.handlers:
return logger
# 로그 레벨 설정
logger.setLevel(getattr(logging, LoggerConfig.DEFAULT_LEVEL))
# 핸들러 추가 (설정에 따라 선택적으로 추가)
if LoggerConfig.CONSOLE_ENABLED:
logger.addHandler(_create_console_handler())
if LoggerConfig.FILE_ENABLED:
logger.addHandler(_get_shared_file_handler())
logger.addHandler(_get_shared_error_handler())
# 상위 로거로 전파 방지 (중복 출력 방지)
logger.propagate = False
return logger
def setup_uvicorn_logging() -> dict:
"""
Uvicorn 서버의 로깅 설정을 반환합니다.
============================================================
언제 사용하는가?
============================================================
Uvicorn 서버를 Python 코드로 직접 실행할 사용합니다.
CLI 명령어(uvicorn main:app --reload) 실행할 때는 적용되지 않습니다.
============================================================
사용 방법
============================================================
1. Python 코드에서 uvicorn.run() 호출 :
# run.py 또는 main.py 하단
import uvicorn
from app.utils.logger import setup_uvicorn_logging
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_config=setup_uvicorn_logging(), # 여기서 적용
)
2. 실행:
python run.py
또는
python main.py
============================================================
어떤 동작을 하는가?
============================================================
Uvicorn의 기본 로깅 형식을 애플리케이션의 LogSettings와 일치시킵니다.
- formatters: 로그 출력 형식 정의
- default: 일반 로그용 (서버 시작/종료, 에러 )
- access: HTTP 요청 로그용 (클라이언트 IP, 요청 경로, 상태 코드)
- handlers: 로그 출력 대상 설정
- stdout으로 콘솔에 출력
- loggers: Uvicorn 내부 로거 설정
- uvicorn: 메인 로거
- uvicorn.error: 에러/시작/종료 로그
- uvicorn.access: HTTP 요청 로그
============================================================
출력 예시
============================================================
적용 (Uvicorn 기본):
INFO: 127.0.0.1:52341 - "GET /docs HTTP/1.1" 200 OK
INFO: Uvicorn running on http://0.0.0.0:8000
적용 :
[2026-01-14 15:30:00] INFO [uvicorn.access] 127.0.0.1 - "GET /docs HTTP/1.1" 200
[2026-01-14 15:30:00] INFO [uvicorn:startup:45] Uvicorn running on http://0.0.0.0:8000
============================================================
반환값 구조 (Python logging.config.dictConfig 형식)
============================================================
{
"version": 1, # dictConfig 버전 (항상 1)
"disable_existing_loggers": False, # 기존 로거 유지
"formatters": { ... }, # 포맷터 정의
"handlers": { ... }, # 핸들러 정의
"loggers": { ... }, # 로거 정의
}
Returns:
dict: Uvicorn log_config 파라미터에 전달할 설정 딕셔너리
"""
return {
# --------------------------------------------------------
# dictConfig 버전 (필수, 항상 1)
# --------------------------------------------------------
"version": 1,
# --------------------------------------------------------
# 기존 로거 비활성화 여부
# False: 기존 로거 유지 (권장)
# True: 기존 로거 모두 비활성화
# --------------------------------------------------------
"disable_existing_loggers": False,
# --------------------------------------------------------
# 포맷터 정의
# 로그 메시지의 출력 형식을 지정합니다.
# --------------------------------------------------------
"formatters": {
# 일반 로그용 포맷터 (서버 시작/종료, 에러 등)
"default": {
"format": LoggerConfig.CONSOLE_FORMAT,
"datefmt": LoggerConfig.DATE_FORMAT,
"style": "{", # {변수명} 스타일 사용
},
# HTTP 요청 로그용 포맷터
# 사용 가능한 변수: client_addr, request_line, status_code
"access": {
"format": "[{asctime}] {levelname:8} [{name}] {client_addr} - \"{request_line}\" {status_code}",
"datefmt": LoggerConfig.DATE_FORMAT,
"style": "{",
},
},
# --------------------------------------------------------
# 핸들러 정의
# 로그를 어디에 출력할지 지정합니다.
# --------------------------------------------------------
"handlers": {
# 일반 로그 핸들러 (stdout 출력)
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
# HTTP 요청 로그 핸들러 (stdout 출력)
"access": {
"formatter": "access",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
},
# --------------------------------------------------------
# 로거 정의
# Uvicorn 내부에서 사용하는 로거들을 설정합니다.
# --------------------------------------------------------
"loggers": {
# Uvicorn 메인 로거
"uvicorn": {
"handlers": ["default"],
"level": "INFO",
"propagate": False, # 상위 로거로 전파 방지
},
# 에러/시작/종료 로그
"uvicorn.error": {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
},
# HTTP 요청 로그 (GET /path HTTP/1.1 200 등)
"uvicorn.access": {
"handlers": ["access"],
"level": "INFO",
"propagate": False,
},
},
}
# 편의를 위한 사전 정의된 로거 이름 상수
HOME_LOGGER = "home"
LYRIC_LOGGER = "lyric"
SONG_LOGGER = "song"
VIDEO_LOGGER = "video"
CELERY_LOGGER = "celery"
APP_LOGGER = "app"

View File

@ -1,15 +1,15 @@
import asyncio import asyncio
import json import json
import logging
import re import re
import aiohttp import aiohttp
import bs4 import bs4
from app.utils.logger import get_logger
from config import crawler_settings from config import crawler_settings
# 로거 설정 # 로거 설정
logger = logging.getLogger(__name__) logger = get_logger("scraper")
class GraphQLException(Exception): class GraphQLException(Exception):
@ -109,7 +109,7 @@ query getAccommodation($id: String!, $deviceType: String) {
self.scrap_type = "GraphQL" self.scrap_type = "GraphQL"
except GraphQLException: except GraphQLException:
print("fallback") logger.debug("GraphQL failed, fallback to Playwright")
self.scrap_type = "Playwright" self.scrap_type = "Playwright"
pass # 나중에 pw 이용한 crawling으로 fallback 추가 pass # 나중에 pw 이용한 crawling으로 fallback 추가
@ -138,7 +138,6 @@ query getAccommodation($id: String!, $deviceType: String) {
try: try:
logger.info(f"[NvMapScraper] Requesting place_id: {place_id}") logger.info(f"[NvMapScraper] Requesting place_id: {place_id}")
print(f"[NvMapScraper] Requesting place_id: {place_id}")
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post( async with session.post(
@ -148,24 +147,20 @@ query getAccommodation($id: String!, $deviceType: String) {
) as response: ) as response:
if response.status == 200: if response.status == 200:
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}") logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
print(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
return await response.json() return await response.json()
# 실패 상태 코드 # 실패 상태 코드
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}") logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
print(f"[NvMapScraper] 실패 상태 코드: {response.status}")
raise GraphQLException( raise GraphQLException(
f"Request failed with status {response.status}" f"Request failed with status {response.status}"
) )
except (TimeoutError, asyncio.TimeoutError): except (TimeoutError, asyncio.TimeoutError):
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}") logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
print(f"[NvMapScraper] Timeout - place_id: {place_id}")
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s") raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logger.error(f"[NvMapScraper] Client error: {e}") logger.error(f"[NvMapScraper] Client error: {e}")
print(f"[NvMapScraper] Client error: {e}")
raise GraphQLException(f"Client error: {e}") raise GraphQLException(f"Client error: {e}")
async def _get_facility_string(self, place_id: str) -> str | None: async def _get_facility_string(self, place_id: str) -> str | None:

View File

@ -32,17 +32,17 @@ URL 경로 형식:
""" """
import asyncio import asyncio
import logging
import time import time
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
import httpx import httpx
from app.utils.logger import get_logger
from config import azure_blob_settings from config import azure_blob_settings
# 로거 설정 # 로거 설정
logger = logging.getLogger(__name__) logger = get_logger("blob")
# ============================================================================= # =============================================================================
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴) # 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
@ -56,12 +56,12 @@ async def get_shared_blob_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다.""" """공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
global _shared_blob_client global _shared_blob_client
if _shared_blob_client is None or _shared_blob_client.is_closed: if _shared_blob_client is None or _shared_blob_client.is_closed:
print("[AzureBlobUploader] Creating shared HTTP client...") logger.info("[AzureBlobUploader] Creating shared HTTP client...")
_shared_blob_client = httpx.AsyncClient( _shared_blob_client = httpx.AsyncClient(
timeout=httpx.Timeout(180.0, connect=10.0), timeout=httpx.Timeout(180.0, connect=10.0),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20), limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
) )
print("[AzureBlobUploader] Shared HTTP client created - " logger.info("[AzureBlobUploader] Shared HTTP client created - "
"max_connections: 20, max_keepalive: 10") "max_connections: 20, max_keepalive: 10")
return _shared_blob_client return _shared_blob_client
@ -72,7 +72,7 @@ async def close_shared_blob_client() -> None:
if _shared_blob_client is not None and not _shared_blob_client.is_closed: if _shared_blob_client is not None and not _shared_blob_client.is_closed:
await _shared_blob_client.aclose() await _shared_blob_client.aclose()
_shared_blob_client = None _shared_blob_client = None
print("[AzureBlobUploader] Shared HTTP client closed") logger.info("[AzureBlobUploader] Shared HTTP client closed")
class AzureBlobUploader: class AzureBlobUploader:
@ -158,14 +158,14 @@ class AzureBlobUploader:
try: try:
logger.info(f"[{log_prefix}] Starting upload") logger.info(f"[{log_prefix}] Starting upload")
print(f"[{log_prefix}] Getting shared client...") logger.debug(f"[{log_prefix}] Getting shared client...")
client = await get_shared_blob_client() client = await get_shared_blob_client()
client_time = time.perf_counter() client_time = time.perf_counter()
elapsed_ms = (client_time - start_time) * 1000 elapsed_ms = (client_time - start_time) * 1000
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms") logger.debug(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
print(f"[{log_prefix}] Starting upload... " logger.debug(f"[{log_prefix}] Starting upload... "
f"(size: {size} bytes, timeout: {timeout}s)") f"(size: {size} bytes, timeout: {timeout}s)")
response = await asyncio.wait_for( response = await asyncio.wait_for(
@ -176,43 +176,37 @@ class AzureBlobUploader:
duration_ms = (upload_time - start_time) * 1000 duration_ms = (upload_time - start_time) * 1000
if response.status_code in [200, 201]: if response.status_code in [200, 201]:
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}") logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms") f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Public URL: {self._last_public_url}") logger.debug(f"[{log_prefix}] Public URL: {self._last_public_url}")
return True return True
# 업로드 실패 # 업로드 실패
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}") logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms") f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Response: {response.text[:500]}") logger.error(f"[{log_prefix}] Response: {response.text[:500]}")
return False return False
except asyncio.TimeoutError: except asyncio.TimeoutError:
elapsed = time.perf_counter() - start_time elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s") logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
return False return False
except httpx.ConnectError as e: except httpx.ConnectError as e:
elapsed = time.perf_counter() - start_time elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}") logger.error(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}") f"{type(e).__name__}: {e}")
return False return False
except httpx.ReadError as e: except httpx.ReadError as e:
elapsed = time.perf_counter() - start_time elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] READ_ERROR: {e}") logger.error(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}") f"{type(e).__name__}: {e}")
return False return False
except Exception as e: except Exception as e:
elapsed = time.perf_counter() - start_time elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] ERROR: {type(e).__name__}: {e}") logger.error(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}") f"{type(e).__name__}: {e}")
return False return False
@ -241,7 +235,7 @@ class AzureBlobUploader:
upload_url = self._build_upload_url(category, file_name) upload_url = self._build_upload_url(category, file_name)
self._last_public_url = self._build_public_url(category, file_name) self._last_public_url = self._build_public_url(category, file_name)
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
@ -306,7 +300,7 @@ class AzureBlobUploader:
upload_url = self._build_upload_url("song", file_name) upload_url = self._build_upload_url("song", file_name)
self._last_public_url = self._build_public_url("song", file_name) self._last_public_url = self._build_public_url("song", file_name)
log_prefix = "upload_music_bytes" log_prefix = "upload_music_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"} headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
@ -368,7 +362,7 @@ class AzureBlobUploader:
upload_url = self._build_upload_url("video", file_name) upload_url = self._build_upload_url("video", file_name)
self._last_public_url = self._build_public_url("video", file_name) self._last_public_url = self._build_public_url("video", file_name)
log_prefix = "upload_video_bytes" log_prefix = "upload_video_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"} headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
@ -434,7 +428,7 @@ class AzureBlobUploader:
upload_url = self._build_upload_url("image", file_name) upload_url = self._build_upload_url("image", file_name)
self._last_public_url = self._build_public_url("image", file_name) self._last_public_url = self._build_public_url("image", file_name)
log_prefix = "upload_image_bytes" log_prefix = "upload_image_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}") logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"} headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}

View File

@ -0,0 +1,3 @@
"""
Video API v1 라우터 모듈
"""

View File

@ -39,9 +39,11 @@ from app.video.schemas.video_schema import (
from app.video.worker.video_task import download_and_upload_video_to_blob from app.video.worker.video_task import download_and_upload_video_to_blob
from app.utils.creatomate import CreatomateService from app.utils.creatomate import CreatomateService
from app.utils.pagination import PaginatedResponse from app.utils.pagination import PaginatedResponse
from app.utils.logger import get_logger
logger = get_logger("video")
router = APIRouter(prefix="/video", tags=["video"]) router = APIRouter(prefix="/video", tags=["Video"])
@router.get( @router.get(
@ -115,7 +117,7 @@ async def generate_video(
from app.database.session import AsyncSessionLocal from app.database.session import AsyncSessionLocal
request_start = time.perf_counter() request_start = time.perf_counter()
print(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}") logger.info(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}")
# ========================================================================== # ==========================================================================
# 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음) # 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음)
@ -168,13 +170,13 @@ async def generate_video(
) )
query_time = time.perf_counter() query_time = time.perf_counter()
print(f"[generate_video] Queries completed - task_id: {task_id}, " logger.debug(f"[generate_video] Queries completed - task_id: {task_id}, "
f"elapsed: {(query_time - request_start)*1000:.1f}ms") f"elapsed: {(query_time - request_start)*1000:.1f}ms")
# ===== 결과 처리: Project ===== # ===== 결과 처리: Project =====
project = project_result.scalar_one_or_none() project = project_result.scalar_one_or_none()
if not project: if not project:
print(f"[generate_video] Project NOT FOUND - task_id: {task_id}") logger.warning(f"[generate_video] Project NOT FOUND - task_id: {task_id}")
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
@ -184,7 +186,7 @@ async def generate_video(
# ===== 결과 처리: Lyric ===== # ===== 결과 처리: Lyric =====
lyric = lyric_result.scalar_one_or_none() lyric = lyric_result.scalar_one_or_none()
if not lyric: if not lyric:
print(f"[generate_video] Lyric NOT FOUND - task_id: {task_id}") logger.warning(f"[generate_video] Lyric NOT FOUND - task_id: {task_id}")
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
@ -194,7 +196,7 @@ async def generate_video(
# ===== 결과 처리: Song ===== # ===== 결과 처리: Song =====
song = song_result.scalar_one_or_none() song = song_result.scalar_one_or_none()
if not song: if not song:
print(f"[generate_video] Song NOT FOUND - task_id: {task_id}") logger.warning(f"[generate_video] Song NOT FOUND - task_id: {task_id}")
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.",
@ -220,14 +222,14 @@ async def generate_video(
# ===== 결과 처리: Image ===== # ===== 결과 처리: Image =====
images = image_result.scalars().all() images = image_result.scalars().all()
if not images: if not images:
print(f"[generate_video] Image NOT FOUND - task_id: {task_id}") logger.warning(f"[generate_video] Image NOT FOUND - task_id: {task_id}")
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail=f"task_id '{task_id}'에 해당하는 이미지를 찾을 수 없습니다.", detail=f"task_id '{task_id}'에 해당하는 이미지를 찾을 수 없습니다.",
) )
image_urls = [img.img_url for img in images] image_urls = [img.img_url for img in images]
print( logger.info(
f"[generate_video] Data loaded - task_id: {task_id}, " f"[generate_video] Data loaded - task_id: {task_id}, "
f"project_id: {project_id}, lyric_id: {lyric_id}, " f"project_id: {project_id}, lyric_id: {lyric_id}, "
f"song_id: {song_id}, images: {len(image_urls)}" f"song_id: {song_id}, images: {len(image_urls)}"
@ -246,14 +248,14 @@ async def generate_video(
await session.commit() await session.commit()
video_id = video.id video_id = video.id
stage1_time = time.perf_counter() stage1_time = time.perf_counter()
print(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, " logger.info(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, "
f"stage1_elapsed: {(stage1_time - request_start)*1000:.1f}ms") f"stage1_elapsed: {(stage1_time - request_start)*1000:.1f}ms")
# 세션이 여기서 자동으로 닫힘 (async with 블록 종료) # 세션이 여기서 자동으로 닫힘 (async with 블록 종료)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
print(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}") logger.error(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}")
return GenerateVideoResponse( return GenerateVideoResponse(
success=False, success=False,
task_id=task_id, task_id=task_id,
@ -267,16 +269,16 @@ async def generate_video(
# ========================================================================== # ==========================================================================
stage2_start = time.perf_counter() stage2_start = time.perf_counter()
try: try:
print(f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}") logger.info(f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}")
creatomate_service = CreatomateService( creatomate_service = CreatomateService(
orientation=orientation, orientation=orientation,
target_duration=song_duration, target_duration=song_duration,
) )
print(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})") logger.debug(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})")
# 6-1. 템플릿 조회 (비동기) # 6-1. 템플릿 조회 (비동기)
template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id) template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id)
print(f"[generate_video] Template fetched - task_id: {task_id}") logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
# 6-2. elements에서 리소스 매핑 생성 # 6-2. elements에서 리소스 매핑 생성
modifications = creatomate_service.elements_connect_resource_blackbox( modifications = creatomate_service.elements_connect_resource_blackbox(
@ -285,7 +287,7 @@ async def generate_video(
lyric=lyrics, lyric=lyrics,
music_url=music_url, music_url=music_url,
) )
print(f"[generate_video] Modifications created - task_id: {task_id}") logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
# 6-3. elements 수정 # 6-3. elements 수정
new_elements = creatomate_service.modify_element( new_elements = creatomate_service.modify_element(
@ -293,20 +295,20 @@ async def generate_video(
modifications, modifications,
) )
template["source"]["elements"] = new_elements template["source"]["elements"] = new_elements
print(f"[generate_video] Elements modified - task_id: {task_id}") logger.debug(f"[generate_video] Elements modified - task_id: {task_id}")
# 6-4. duration 확장 # 6-4. duration 확장
final_template = creatomate_service.extend_template_duration( final_template = creatomate_service.extend_template_duration(
template, template,
creatomate_service.target_duration, creatomate_service.target_duration,
) )
print(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}") logger.debug(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}")
# 6-5. 커스텀 렌더링 요청 (비동기) # 6-5. 커스텀 렌더링 요청 (비동기)
render_response = await creatomate_service.make_creatomate_custom_call_async( render_response = await creatomate_service.make_creatomate_custom_call_async(
final_template["source"], final_template["source"],
) )
print(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") logger.debug(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}")
# 렌더 ID 추출 # 렌더 ID 추출
if isinstance(render_response, list) and len(render_response) > 0: if isinstance(render_response, list) and len(render_response) > 0:
@ -317,14 +319,14 @@ async def generate_video(
creatomate_render_id = None creatomate_render_id = None
stage2_time = time.perf_counter() stage2_time = time.perf_counter()
print( logger.info(
f"[generate_video] Stage 2 DONE - task_id: {task_id}, " f"[generate_video] Stage 2 DONE - task_id: {task_id}, "
f"render_id: {creatomate_render_id}, " f"render_id: {creatomate_render_id}, "
f"stage2_elapsed: {(stage2_time - stage2_start)*1000:.1f}ms" f"stage2_elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
) )
except Exception as e: except Exception as e:
print(f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}") logger.error(f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}")
# 외부 API 실패 시 Video 상태를 failed로 업데이트 # 외부 API 실패 시 Video 상태를 failed로 업데이트
from app.database.session import AsyncSessionLocal from app.database.session import AsyncSessionLocal
async with AsyncSessionLocal() as update_session: async with AsyncSessionLocal() as update_session:
@ -347,7 +349,7 @@ async def generate_video(
# 3단계: creatomate_render_id 업데이트 (새 세션으로 빠르게 처리) # 3단계: creatomate_render_id 업데이트 (새 세션으로 빠르게 처리)
# ========================================================================== # ==========================================================================
stage3_start = time.perf_counter() stage3_start = time.perf_counter()
print(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}") logger.info(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}")
try: try:
from app.database.session import AsyncSessionLocal from app.database.session import AsyncSessionLocal
async with AsyncSessionLocal() as update_session: async with AsyncSessionLocal() as update_session:
@ -361,11 +363,11 @@ async def generate_video(
stage3_time = time.perf_counter() stage3_time = time.perf_counter()
total_time = stage3_time - request_start total_time = stage3_time - request_start
print( logger.debug(
f"[generate_video] Stage 3 DONE - task_id: {task_id}, " f"[generate_video] Stage 3 DONE - task_id: {task_id}, "
f"stage3_elapsed: {(stage3_time - stage3_start)*1000:.1f}ms" f"stage3_elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
) )
print( logger.info(
f"[generate_video] SUCCESS - task_id: {task_id}, " f"[generate_video] SUCCESS - task_id: {task_id}, "
f"render_id: {creatomate_render_id}, " f"render_id: {creatomate_render_id}, "
f"total_time: {total_time*1000:.1f}ms" f"total_time: {total_time*1000:.1f}ms"
@ -380,7 +382,7 @@ async def generate_video(
) )
except Exception as e: except Exception as e:
print(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}") logger.error(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}")
return GenerateVideoResponse( return GenerateVideoResponse(
success=False, success=False,
task_id=task_id, task_id=task_id,
@ -439,11 +441,11 @@ async def get_video_status(
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고 succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고
Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다. Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다.
""" """
print(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}") logger.info(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}")
try: try:
creatomate_service = CreatomateService() creatomate_service = CreatomateService()
result = await creatomate_service.get_render_status_async(creatomate_render_id) result = await creatomate_service.get_render_status_async(creatomate_render_id)
print(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}") logger.debug(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}")
status = result.get("status", "unknown") status = result.get("status", "unknown")
video_url = result.get("url") video_url = result.get("url")
@ -481,7 +483,7 @@ async def get_video_status(
store_name = project.store_name if project else "video" store_name = project.store_name if project else "video"
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제 # 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
print(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}") logger.info(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}")
background_tasks.add_task( background_tasks.add_task(
download_and_upload_video_to_blob, download_and_upload_video_to_blob,
task_id=video.task_id, task_id=video.task_id,
@ -489,7 +491,7 @@ async def get_video_status(
store_name=store_name, store_name=store_name,
) )
elif video and video.status == "completed": elif video and video.status == "completed":
print(f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}") logger.debug(f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}")
render_data = VideoRenderData( render_data = VideoRenderData(
id=result.get("id"), id=result.get("id"),
@ -498,7 +500,7 @@ async def get_video_status(
snapshot_url=result.get("snapshot_url"), snapshot_url=result.get("snapshot_url"),
) )
print(f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}") logger.info(f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}")
return PollingVideoResponse( return PollingVideoResponse(
success=True, success=True,
status=status, status=status,
@ -511,7 +513,7 @@ async def get_video_status(
except Exception as e: except Exception as e:
import traceback import traceback
print(f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") logger.error(f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
return PollingVideoResponse( return PollingVideoResponse(
success=False, success=False,
status="error", status="error",
@ -563,7 +565,7 @@ async def download_video(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> DownloadVideoResponse: ) -> DownloadVideoResponse:
"""task_id로 Video 상태를 polling하고 completed 시 Project 정보와 영상 URL을 반환합니다.""" """task_id로 Video 상태를 polling하고 completed 시 Project 정보와 영상 URL을 반환합니다."""
print(f"[download_video] START - task_id: {task_id}") logger.info(f"[download_video] START - task_id: {task_id}")
try: try:
# task_id로 Video 조회 (여러 개 있을 경우 가장 최근 것 선택) # task_id로 Video 조회 (여러 개 있을 경우 가장 최근 것 선택)
video_result = await session.execute( video_result = await session.execute(
@ -575,7 +577,7 @@ async def download_video(
video = video_result.scalar_one_or_none() video = video_result.scalar_one_or_none()
if not video: if not video:
print(f"[download_video] Video NOT FOUND - task_id: {task_id}") logger.warning(f"[download_video] Video NOT FOUND - task_id: {task_id}")
return DownloadVideoResponse( return DownloadVideoResponse(
success=False, success=False,
status="not_found", status="not_found",
@ -583,11 +585,11 @@ async def download_video(
error_message="Video not found", error_message="Video not found",
) )
print(f"[download_video] Video found - task_id: {task_id}, status: {video.status}") logger.debug(f"[download_video] Video found - task_id: {task_id}, status: {video.status}")
# processing 상태인 경우 # processing 상태인 경우
if video.status == "processing": if video.status == "processing":
print(f"[download_video] PROCESSING - task_id: {task_id}") logger.debug(f"[download_video] PROCESSING - task_id: {task_id}")
return DownloadVideoResponse( return DownloadVideoResponse(
success=True, success=True,
status="processing", status="processing",
@ -597,7 +599,7 @@ async def download_video(
# failed 상태인 경우 # failed 상태인 경우
if video.status == "failed": if video.status == "failed":
print(f"[download_video] FAILED - task_id: {task_id}") logger.error(f"[download_video] FAILED - task_id: {task_id}")
return DownloadVideoResponse( return DownloadVideoResponse(
success=False, success=False,
status="failed", status="failed",
@ -612,7 +614,7 @@ async def download_video(
) )
project = project_result.scalar_one_or_none() project = project_result.scalar_one_or_none()
print(f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}") logger.info(f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}")
return DownloadVideoResponse( return DownloadVideoResponse(
success=True, success=True,
status="completed", status="completed",
@ -625,7 +627,7 @@ async def download_video(
) )
except Exception as e: except Exception as e:
print(f"[download_video] EXCEPTION - task_id: {task_id}, error: {e}") logger.error(f"[download_video] EXCEPTION - task_id: {task_id}, error: {e}")
return DownloadVideoResponse( return DownloadVideoResponse(
success=False, success=False,
status="error", status="error",
@ -674,7 +676,7 @@ async def get_videos(
pagination: PaginationParams = Depends(get_pagination_params), pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[VideoListItem]: ) -> PaginatedResponse[VideoListItem]:
"""완료된 영상 목록을 페이지네이션하여 반환합니다.""" """완료된 영상 목록을 페이지네이션하여 반환합니다."""
print(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}") logger.info(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}")
try: try:
offset = (pagination.page - 1) * pagination.page_size offset = (pagination.page - 1) * pagination.page_size
@ -732,14 +734,14 @@ async def get_videos(
page_size=pagination.page_size, page_size=pagination.page_size,
) )
print( logger.info(
f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, " f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, "
f"page_size: {pagination.page_size}, items_count: {len(items)}" f"page_size: {pagination.page_size}, items_count: {len(items)}"
) )
return response return response
except Exception as e: except Exception as e:
print(f"[get_videos] EXCEPTION - error: {e}") logger.error(f"[get_videos] EXCEPTION - error: {e}")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"영상 목록 조회에 실패했습니다: {str(e)}", detail=f"영상 목록 조회에 실패했습니다: {str(e)}",

View File

@ -1,8 +0,0 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]

View File

@ -14,6 +14,9 @@ from app.lyrics.schemas.lyrics_schema import (
StoreData, StoreData,
) )
from app.utils.chatgpt_prompt import chatgpt_api from app.utils.chatgpt_prompt import chatgpt_api
from app.utils.logger import get_logger
logger = get_logger("video")
async def get_store_info(conn: Connection) -> List[StoreData]: async def get_store_info(conn: Connection) -> List[StoreData]:
@ -38,13 +41,13 @@ async def get_store_info(conn: Connection) -> List[StoreData]:
result.close() result.close()
return all_store_info return all_store_info
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemy error in get_store_info: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_store_info: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -69,13 +72,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
result.close() result.close()
return all_attribute return all_attribute
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemy error in get_attribute: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_attribute: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -100,13 +103,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
result.close() result.close()
return all_attribute return all_attribute
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemy error in get_attribute: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_attribute: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -132,13 +135,13 @@ async def get_sample_song(conn: Connection) -> List[SongSampleData]:
result.close() result.close()
return all_sample_song return all_sample_song
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemy error in get_sample_song: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_sample_song: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -162,13 +165,13 @@ async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]:
result.close() result.close()
return all_prompt_template return all_prompt_template
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemy error in get_prompt_template: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_prompt_template: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -192,13 +195,13 @@ async def get_song_result(conn: Connection) -> List[PromptTemplateData]:
result.close() result.close()
return all_prompt_template return all_prompt_template
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(e) logger.error(f"SQLAlchemy error in get_song_result: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.", detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
) )
except Exception as e: except Exception as e:
print(e) logger.error(f"Unexpected error in get_song_result: {e}")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다", detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -210,11 +213,11 @@ async def make_song_result(request: Request, conn: Connection):
# 1. Form 데이터 파싱 # 1. Form 데이터 파싱
form_data = await SongFormData.from_form(request) form_data = await SongFormData.from_form(request)
print(f"\n{'=' * 60}") logger.info(f"{'=' * 60}")
print(f"Store ID: {form_data.store_id}") logger.info(f"Store ID: {form_data.store_id}")
print(f"Lyrics IDs: {form_data.lyrics_ids}") logger.info(f"Lyrics IDs: {form_data.lyrics_ids}")
print(f"Prompt IDs: {form_data.prompts}") logger.info(f"Prompt IDs: {form_data.prompts}")
print(f"{'=' * 60}\n") logger.info(f"{'=' * 60}")
# 2. Store 정보 조회 # 2. Store 정보 조회
store_query = """ store_query = """
@ -243,7 +246,7 @@ async def make_song_result(request: Request, conn: Connection):
) )
store_info = all_store_info[0] store_info = all_store_info[0]
print(f"Store: {store_info.store_name}") logger.info(f"Store: {store_info.store_name}")
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
@ -251,7 +254,7 @@ async def make_song_result(request: Request, conn: Connection):
combined_sample_song = None combined_sample_song = None
if form_data.lyrics_ids: if form_data.lyrics_ids:
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}") logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
lyrics_query = """ lyrics_query = """
SELECT sample_song FROM song_sample SELECT sample_song FROM song_sample
@ -270,11 +273,11 @@ async def make_song_result(request: Request, conn: Connection):
combined_sample_song = "\n\n".join( combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
) )
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else: else:
print("샘플 가사가 비어있습니다") logger.info("샘플 가사가 비어있습니다")
else: else:
print("선택된 lyrics가 없습니다") logger.info("선택된 lyrics가 없습니다")
# 5. 템플릿 가져오기 # 5. 템플릿 가져오기
if not form_data.prompts: if not form_data.prompts:
@ -283,7 +286,7 @@ async def make_song_result(request: Request, conn: Connection):
detail="프롬프트 ID가 필요합니다", detail="프롬프트 ID가 필요합니다",
) )
print("템플릿 가져오기") logger.info("템플릿 가져오기")
prompts_query = """ prompts_query = """
SELECT * FROM prompt_template WHERE id=:id; SELECT * FROM prompt_template WHERE id=:id;
@ -310,7 +313,7 @@ async def make_song_result(request: Request, conn: Connection):
) )
prompt = prompts_info[0] prompt = prompts_info[0]
print(f"Prompt Template: {prompt.prompt}") logger.debug(f"Prompt Template: {prompt.prompt}")
# ✅ 6. 프롬프트 조합 # ✅ 6. 프롬프트 조합
updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format( updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format(
@ -329,7 +332,7 @@ async def make_song_result(request: Request, conn: Connection):
{combined_sample_song} {combined_sample_song}
""" """
print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n") logger.debug(f"[업데이트된 프롬프트]\n{updated_prompt}")
# 7. 모델에게 요청 # 7. 모델에게 요청
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
@ -348,13 +351,12 @@ async def make_song_result(request: Request, conn: Connection):
전체 글자 (공백 포함): {total_chars_with_space} 전체 글자 (공백 포함): {total_chars_with_space}
전체 글자 (공백 제외): {total_chars_without_space}\r\n\r\n{generated_lyrics}""" 전체 글자 (공백 제외): {total_chars_without_space}\r\n\r\n{generated_lyrics}"""
print("=" * 40) logger.debug("=" * 40)
print("[translate:form_data.attributes_str:] ", form_data.attributes_str) logger.debug(f"[translate:form_data.attributes_str:] {form_data.attributes_str}")
print("[translate:total_chars_with_space:] ", total_chars_with_space) logger.debug(f"[translate:total_chars_with_space:] {total_chars_with_space}")
print("[translate:total_chars_without_space:] ", total_chars_without_space) logger.debug(f"[translate:total_chars_without_space:] {total_chars_without_space}")
print("[translate:final_lyrics:]") logger.debug(f"[translate:final_lyrics:]\n{final_lyrics}")
print(final_lyrics) logger.debug("=" * 40)
print("=" * 40)
# 8. DB 저장 # 8. DB 저장
insert_query = """ insert_query = """
@ -396,9 +398,9 @@ async def make_song_result(request: Request, conn: Connection):
await conn.execute(text(insert_query), insert_params) await conn.execute(text(insert_query), insert_params)
await conn.commit() await conn.commit()
print("결과 저장 완료") logger.info("결과 저장 완료")
print("\n전체 결과 조회 중...") logger.info("전체 결과 조회 중...")
# 9. 생성 결과 가져오기 (created_at 역순) # 9. 생성 결과 가져오기 (created_at 역순)
select_query = """ select_query = """
@ -430,26 +432,20 @@ async def make_song_result(request: Request, conn: Connection):
for row in all_results.fetchall() for row in all_results.fetchall()
] ]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n") logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
return results_list return results_list
except HTTPException: except HTTPException:
raise raise
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(f"Database Error: {e}") logger.error(f"Database Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.", detail="데이터베이스 연결에 문제가 발생했습니다.",
) )
except Exception as e: except Exception as e:
print(f"Unexpected Error: {e}") logger.error(f"Unexpected Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.", detail="서비스 처리 중 오류가 발생했습니다.",
@ -490,25 +486,19 @@ async def get_song_result(conn: Connection): # 반환 타입 수정
for row in all_results.fetchall() for row in all_results.fetchall()
] ]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n") logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
return results_list return results_list
except HTTPException: # HTTPException은 그대로 raise except HTTPException: # HTTPException은 그대로 raise
raise raise
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(f"Database Error: {e}") logger.error(f"Database Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.", detail="데이터베이스 연결에 문제가 발생했습니다.",
) )
except Exception as e: except Exception as e:
print(f"Unexpected Error: {e}") logger.error(f"Unexpected Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.", detail="서비스 처리 중 오류가 발생했습니다.",
@ -520,9 +510,9 @@ async def make_automation(request: Request, conn: Connection):
# 1. Form 데이터 파싱 # 1. Form 데이터 파싱
form_data = await SongFormData.from_form(request) form_data = await SongFormData.from_form(request)
print(f"\n{'=' * 60}") logger.info(f"{'=' * 60}")
print(f"Store ID: {form_data.store_id}") logger.info(f"Store ID: {form_data.store_id}")
print(f"{'=' * 60}\n") logger.info(f"{'=' * 60}")
# 2. Store 정보 조회 # 2. Store 정보 조회
store_query = """ store_query = """
@ -551,7 +541,7 @@ async def make_automation(request: Request, conn: Connection):
) )
store_info = all_store_info[0] store_info = all_store_info[0]
print(f"Store: {store_info.store_name}") logger.info(f"Store: {store_info.store_name}")
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음 # 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
attribute_query = """ attribute_query = """
@ -596,13 +586,13 @@ async def make_automation(request: Request, conn: Connection):
# 최종 문자열 생성 # 최종 문자열 생성
formatted_attributes = "\n".join(formatted_pairs) formatted_attributes = "\n".join(formatted_pairs)
print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n") logger.debug(f"[포맷팅된 문자열 속성 정보]\n{formatted_attributes}")
else: else:
print("속성 데이터가 없습니다") logger.info("속성 데이터가 없습니다")
formatted_attributes = "" formatted_attributes = ""
# 4. 템플릿 가져오기 # 4. 템플릿 가져오기
print("템플릿 가져오기 (ID=1)") logger.info("템플릿 가져오기 (ID=1)")
prompts_query = """ prompts_query = """
SELECT * FROM prompt_template WHERE id=1; SELECT * FROM prompt_template WHERE id=1;
@ -624,7 +614,7 @@ async def make_automation(request: Request, conn: Connection):
prompt=row[2], prompt=row[2],
) )
print(f"Prompt Template: {prompt.prompt}") logger.debug(f"Prompt Template: {prompt.prompt}")
# 5. 템플릿 조합 # 5. 템플릿 조합
@ -635,17 +625,17 @@ async def make_automation(request: Request, conn: Connection):
description=store_info.store_info or "", description=store_info.store_info or "",
) )
print("\n" + "=" * 80) logger.debug("=" * 80)
print("업데이트된 프롬프트") logger.debug("업데이트된 프롬프트")
print("=" * 80) logger.debug("=" * 80)
print(updated_prompt) logger.debug(updated_prompt)
print("=" * 80 + "\n") logger.debug("=" * 80)
# 4. Sample Song 조회 및 결합 # 4. Sample Song 조회 및 결합
combined_sample_song = None combined_sample_song = None
if form_data.lyrics_ids: if form_data.lyrics_ids:
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}") logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
lyrics_query = """ lyrics_query = """
SELECT sample_song FROM song_sample SELECT sample_song FROM song_sample
@ -664,14 +654,14 @@ async def make_automation(request: Request, conn: Connection):
combined_sample_song = "\n\n".join( combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
) )
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else: else:
print("샘플 가사가 비어있습니다") logger.info("샘플 가사가 비어있습니다")
else: else:
print("선택된 lyrics가 없습니다") logger.info("선택된 lyrics가 없습니다")
# 1. song_sample 테이블의 모든 ID 조회 # 1. song_sample 테이블의 모든 ID 조회
print("\n[샘플 가사 랜덤 선택]") logger.info("[샘플 가사 랜덤 선택]")
all_ids_query = """ all_ids_query = """
SELECT id FROM song_sample; SELECT id FROM song_sample;
@ -679,7 +669,7 @@ async def make_automation(request: Request, conn: Connection):
ids_result = await conn.execute(text(all_ids_query)) ids_result = await conn.execute(text(all_ids_query))
all_ids = [row.id for row in ids_result.fetchall()] all_ids = [row.id for row in ids_result.fetchall()]
print(f"전체 샘플 가사 개수: {len(all_ids)}") logger.info(f"전체 샘플 가사 개수: {len(all_ids)}")
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체) # 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
combined_sample_song = None combined_sample_song = None
@ -689,7 +679,7 @@ async def make_automation(request: Request, conn: Connection):
sample_count = min(3, len(all_ids)) sample_count = min(3, len(all_ids))
selected_ids = random.sample(all_ids, sample_count) selected_ids = random.sample(all_ids, sample_count)
print(f"랜덤 선택된 ID: {selected_ids}") logger.info(f"랜덤 선택된 ID: {selected_ids}")
# 3. 선택된 ID로 샘플 가사 조회 # 3. 선택된 ID로 샘플 가사 조회
lyrics_query = """ lyrics_query = """
@ -710,11 +700,11 @@ async def make_automation(request: Request, conn: Connection):
combined_sample_song = "\n\n".join( combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)] [f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
) )
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료") logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else: else:
print("샘플 가사가 비어있습니다") logger.info("샘플 가사가 비어있습니다")
else: else:
print("song_sample 테이블에 데이터가 없습니다") logger.info("song_sample 테이블에 데이터가 없습니다")
# 5. 프롬프트에 샘플 가사 추가 # 5. 프롬프트에 샘플 가사 추가
if combined_sample_song: if combined_sample_song:
@ -726,11 +716,11 @@ async def make_automation(request: Request, conn: Connection):
{combined_sample_song} {combined_sample_song}
""" """
print("샘플 가사 정보가 프롬프트에 추가되었습니다") logger.info("샘플 가사 정보가 프롬프트에 추가되었습니다")
else: else:
print("샘플 가사가 없어 기본 프롬프트만 사용합니다") logger.info("샘플 가사가 없어 기본 프롬프트만 사용합니다")
print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n") logger.debug(f"[최종 프롬프트 길이: {len(updated_prompt)} 자]")
# 7. 모델에게 요청 # 7. 모델에게 요청
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt) generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
@ -763,10 +753,9 @@ async def make_automation(request: Request, conn: Connection):
:sample_song, :result_song, NOW() :sample_song, :result_song, NOW()
); );
""" """
print("\n[insert_params 선택된 속성 확인]") logger.debug("[insert_params 선택된 속성 확인]")
print(f"Categories: {selected_categories}") logger.debug(f"Categories: {selected_categories}")
print(f"Values: {selected_values}") logger.debug(f"Values: {selected_values}")
print()
# attr_category, attr_value # attr_category, attr_value
insert_params = { insert_params = {
@ -792,9 +781,9 @@ async def make_automation(request: Request, conn: Connection):
await conn.execute(text(insert_query), insert_params) await conn.execute(text(insert_query), insert_params)
await conn.commit() await conn.commit()
print("결과 저장 완료") logger.info("결과 저장 완료")
print("\n전체 결과 조회 중...") logger.info("전체 결과 조회 중...")
# 9. 생성 결과 가져오기 (created_at 역순) # 9. 생성 결과 가져오기 (created_at 역순)
select_query = """ select_query = """
@ -826,26 +815,20 @@ async def make_automation(request: Request, conn: Connection):
for row in all_results.fetchall() for row in all_results.fetchall()
] ]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n") logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
return results_list return results_list
except HTTPException: except HTTPException:
raise raise
except SQLAlchemyError as e: except SQLAlchemyError as e:
print(f"Database Error: {e}") logger.error(f"Database Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.", detail="데이터베이스 연결에 문제가 발생했습니다.",
) )
except Exception as e: except Exception as e:
print(f"Unexpected Error: {e}") logger.error(f"Unexpected Error: {e}", exc_info=True)
import traceback
traceback.print_exc()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.", detail="서비스 처리 중 오류가 발생했습니다.",

View File

@ -4,7 +4,6 @@ Video Background Tasks
영상 생성 관련 백그라운드 태스크를 정의합니다. 영상 생성 관련 백그라운드 태스크를 정의합니다.
""" """
import logging
import traceback import traceback
from pathlib import Path from pathlib import Path
@ -16,9 +15,10 @@ from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal from app.database.session import BackgroundSessionLocal
from app.video.models import Video from app.video.models import Video
from app.utils.upload_blob_as_request import AzureBlobUploader from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.logger import get_logger
# 로거 설정 # 로거 설정
logger = logging.getLogger(__name__) logger = get_logger("video")
# HTTP 요청 설정 # HTTP 요청 설정
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분) REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
@ -66,20 +66,16 @@ async def _update_video_status(
video.result_movie_url = video_url video.result_movie_url = video_url
await session.commit() await session.commit()
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}") logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
print(f"[Video] Status updated - task_id: {task_id}, status: {status}")
return True return True
else: else:
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}") logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
print(f"[Video] NOT FOUND in DB - task_id: {task_id}")
return False return False
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}") logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
return False return False
except Exception as e: except Exception as e:
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}") logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False return False
@ -97,14 +93,12 @@ async def _download_video(url: str, task_id: str) -> bytes:
httpx.HTTPError: 다운로드 실패 httpx.HTTPError: 다운로드 실패
""" """
logger.info(f"[VideoDownload] Downloading - task_id: {task_id}") logger.info(f"[VideoDownload] Downloading - task_id: {task_id}")
print(f"[VideoDownload] Downloading - task_id: {task_id}")
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=REQUEST_TIMEOUT) response = await client.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status() response.raise_for_status()
logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes") logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
print(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
return response.content return response.content
@ -121,7 +115,6 @@ async def download_and_upload_video_to_blob(
store_name: 저장할 파일명에 사용할 업체명 store_name: 저장할 파일명에 사용할 업체명
""" """
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}") logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None temp_file_path: Path | None = None
try: try:
@ -136,12 +129,10 @@ async def download_and_upload_video_to_blob(
temp_dir = Path("media") / "temp" / task_id temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True) temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}") logger.debug(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
# 영상 파일 다운로드 # 영상 파일 다운로드
logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}") logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
content = await _download_video(video_url, task_id) content = await _download_video(video_url, task_id)
@ -149,7 +140,6 @@ async def download_and_upload_video_to_blob(
await f.write(content) await f.write(content)
logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}") logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드 # Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id) uploader = AzureBlobUploader(task_id=task_id)
@ -161,29 +151,21 @@ async def download_and_upload_video_to_blob(
# SAS 토큰이 제외된 public_url 사용 # SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}") logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Video 테이블 업데이트 # Video 테이블 업데이트
await _update_video_status(task_id, "completed", blob_url) await _update_video_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}") logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}") logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_video_status(task_id, "failed") await _update_video_status(task_id, "failed")
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}") logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_video_status(task_id, "failed") await _update_video_status(task_id, "failed")
except Exception as e: except Exception as e:
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}") logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
traceback.print_exc()
await _update_video_status(task_id, "failed") await _update_video_status(task_id, "failed")
finally: finally:
@ -191,11 +173,9 @@ async def download_and_upload_video_to_blob(
if temp_file_path and temp_file_path.exists(): if temp_file_path and temp_file_path.exists():
try: try:
temp_file_path.unlink() temp_file_path.unlink()
logger.info(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}") logger.debug(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e: except Exception as e:
logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}") logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도 # 임시 디렉토리 삭제 시도
temp_dir = Path("media") / "temp" / task_id temp_dir = Path("media") / "temp" / task_id
@ -219,7 +199,6 @@ async def download_and_upload_video_by_creatomate_render_id(
store_name: 저장할 파일명에 사용할 업체명 store_name: 저장할 파일명에 사용할 업체명
""" """
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}") logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
temp_file_path: Path | None = None temp_file_path: Path | None = None
task_id: str | None = None task_id: str | None = None
@ -236,12 +215,10 @@ async def download_and_upload_video_by_creatomate_render_id(
if not video: if not video:
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}") logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
return return
task_id = video.task_id task_id = video.task_id
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}") logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
# 파일명에 사용할 수 없는 문자 제거 # 파일명에 사용할 수 없는 문자 제거
safe_store_name = "".join( safe_store_name = "".join(
@ -254,12 +231,10 @@ async def download_and_upload_video_by_creatomate_render_id(
temp_dir = Path("media") / "temp" / task_id temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True) temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}") logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
# 영상 파일 다운로드 # 영상 파일 다운로드
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}") logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
content = await _download_video(video_url, task_id) content = await _download_video(video_url, task_id)
@ -267,7 +242,6 @@ async def download_and_upload_video_by_creatomate_render_id(
await f.write(content) await f.write(content)
logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}") logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드 # Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id) uploader = AzureBlobUploader(task_id=task_id)
@ -279,7 +253,6 @@ async def download_and_upload_video_by_creatomate_render_id(
# SAS 토큰이 제외된 public_url 사용 # SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}") logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
# Video 테이블 업데이트 # Video 테이블 업데이트
await _update_video_status( await _update_video_status(
@ -289,26 +262,19 @@ async def download_and_upload_video_by_creatomate_render_id(
creatomate_render_id=creatomate_render_id, creatomate_render_id=creatomate_render_id,
) )
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}") logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
except httpx.HTTPError as e: except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}") logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
if task_id: if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except SQLAlchemyError as e: except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}") logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
if task_id: if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except Exception as e: except Exception as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}") logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
if task_id: if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id) await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
@ -317,11 +283,9 @@ async def download_and_upload_video_by_creatomate_render_id(
if temp_file_path and temp_file_path.exists(): if temp_file_path and temp_file_path.exists():
try: try:
temp_file_path.unlink() temp_file_path.unlink()
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}") logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
except Exception as e: except Exception as e:
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}") logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도 # 임시 디렉토리 삭제 시도
if task_id: if task_id:

257
config.py
View File

@ -5,6 +5,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_DIR = Path(__file__).resolve().parent PROJECT_DIR = Path(__file__).resolve().parent
# 미디어 파일 저장 디렉토리
MEDIA_ROOT = PROJECT_DIR / "media"
MEDIA_ROOT.mkdir(exist_ok=True)
_base_config = SettingsConfigDict( _base_config = SettingsConfigDict(
env_file=PROJECT_DIR / ".env", env_file=PROJECT_DIR / ".env",
env_ignore_empty=True, env_ignore_empty=True,
@ -95,32 +99,6 @@ class DatabaseSettings(BaseSettings):
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}" return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}"
class SecuritySettings(BaseSettings):
JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전)
JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전)
model_config = _base_config
class NotificationSettings(BaseSettings):
MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가
MAIL_PASSWORD: str = "your-email-password" # 기본값 추가
MAIL_FROM: str = "your-email@example.com" # 기본값 추가
MAIL_PORT: int = 587 # 기본값 추가
MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가
MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가
MAIL_STARTTLS: bool = True
MAIL_SSL_TLS: bool = False
USE_CREDENTIALS: bool = True
VALIDATE_CERTS: bool = True
TWILIO_SID: str = "your-twilio-sid" # 기본값 추가
TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가
TWILIO_NUMBER: str = "+1234567890" # 기본값 추가
model_config = _base_config
class CrawlerSettings(BaseSettings): class CrawlerSettings(BaseSettings):
NAVER_COOKIES: str = Field(default="") NAVER_COOKIES: str = Field(default="")
@ -175,13 +153,236 @@ class PromptSettings(BaseSettings):
model_config = _base_config model_config = _base_config
class KakaoSettings(BaseSettings):
"""카카오 OAuth 설정"""
KAKAO_CLIENT_ID: str = Field(default="", description="카카오 REST API 키")
KAKAO_CLIENT_SECRET: str = Field(default="", description="카카오 Client Secret (선택)")
KAKAO_REDIRECT_URI: str = Field(
default="http://localhost:8000/api/v1/user/auth/kakao/callback",
description="카카오 로그인 후 리다이렉트 URI",
)
model_config = _base_config
class JWTSettings(BaseSettings):
"""JWT 토큰 설정"""
JWT_SECRET: str = Field(
default="your-super-secret-key-must-be-at-least-32-characters-long",
description="JWT 서명 비밀키 (최소 32자)",
)
JWT_ALGORITHM: str = Field(default="HS256", description="JWT 알고리즘")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(
default=60, description="액세스 토큰 만료 시간 (분)"
)
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(
default=7, description="리프레시 토큰 만료 시간 (일)"
)
model_config = _base_config
class LogSettings(BaseSettings):
"""
로깅 설정 클래스
애플리케이션의 로깅 동작을 제어하는 설정들을 관리합니다.
모든 설정은 .env 파일 또는 환경변수로 오버라이드 가능합니다.
사용 예시 (.env 파일):
LOG_LEVEL=INFO
LOG_CONSOLE_LEVEL=WARNING
LOG_FILE_LEVEL=DEBUG
LOG_DIR=/var/log/myapp
"""
# ============================================================
# 로그 디렉토리 설정
# ============================================================
# 로그 파일이 저장될 디렉토리 경로입니다.
# - 기본값: 프로젝트 루트의 logs 폴더
# - 운영 환경에서는 /www/log/uvicorn 또는 /var/log/app 등으로 설정 권장
# - 디렉토리가 존재하지 않으면 자동으로 생성됩니다.
# - .env 파일에서 LOG_DIR 환경변수로 오버라이드 가능
LOG_DIR: str = Field(
default="logs",
description="로그 파일 저장 디렉토리 (절대 경로 또는 상대 경로)",
)
# ============================================================
# 로그 출력 대상 설정
# ============================================================
# 콘솔 출력 활성화 여부
# - True: 터미널/콘솔에 로그 출력
# - False: 콘솔 출력 비활성화 (파일에만 기록)
LOG_CONSOLE_ENABLED: bool = Field(
default=True,
description="콘솔 로그 출력 활성화 여부",
)
# 파일 출력 활성화 여부
# - True: 로그 파일에 기록 (app.log, error.log)
# - False: 파일 출력 비활성화 (콘솔에만 출력)
LOG_FILE_ENABLED: bool = Field(
default=True,
description="파일 로그 출력 활성화 여부",
)
# ============================================================
# 로그 레벨 설정
# ============================================================
# 로그 레벨 우선순위 (낮음 → 높음):
# DEBUG < INFO < WARNING < ERROR < CRITICAL
#
# 설정된 레벨 이상의 로그만 출력됩니다.
# 예: INFO로 설정 시 DEBUG는 무시되고, INFO, WARNING, ERROR, CRITICAL만 출력
# ============================================================
# 기본 로그 레벨
# - 로거 자체의 최소 로그 레벨을 설정합니다.
# - 이 레벨보다 낮은 로그는 핸들러(콘솔/파일)로 전달되지 않습니다.
# - 가능한 값: DEBUG, INFO, WARNING, ERROR, CRITICAL
# - DEBUG: 개발 시 상세 디버깅 정보 (변수 값, 흐름 추적 등)
# - INFO: 일반적인 작업 진행 상황 (요청 시작/완료 등)
# - WARNING: 잠재적 문제 또는 주의가 필요한 상황
# - ERROR: 오류 발생, 하지만 애플리케이션은 계속 실행
# - CRITICAL: 심각한 오류, 애플리케이션 중단 가능성
LOG_LEVEL: str = Field(
default="DEBUG",
description="기본 로그 레벨",
)
# 콘솔 출력 로그 레벨
# - 터미널/콘솔에 출력되는 로그의 최소 레벨을 설정합니다.
# - 개발 환경: DEBUG 권장 (모든 로그 확인)
# - 운영 환경: INFO 또는 WARNING 권장 (중요한 정보만 출력)
# - LOG_LEVEL보다 낮게 설정해도 LOG_LEVEL이 우선 적용됩니다.
LOG_CONSOLE_LEVEL: str = Field(
default="DEBUG",
description="콘솔 출력 로그 레벨",
)
# 파일 출력 로그 레벨
# - 로그 파일에 기록되는 로그의 최소 레벨을 설정합니다.
# - 파일에는 더 상세한 로그를 남기고 싶을 때 DEBUG로 설정
# - 파일 저장 위치: logs/{날짜}_{모듈명}.log
# - 에러 로그는 별도로 logs/{날짜}_error.log에도 기록됩니다.
LOG_FILE_LEVEL: str = Field(
default="DEBUG",
description="파일 출력 로그 레벨",
)
# ============================================================
# 로그 파일 관리 설정
# ============================================================
# 로그 파일 최대 크기 (MB)
# - 파일이 이 크기를 초과하면 자동으로 새 파일로 롤오버됩니다.
# - 기존 파일은 .1, .2 등의 접미사가 붙어 백업됩니다.
# - 예: 15MB 설정 시, 파일이 15MB를 넘으면 새 파일 생성
LOG_MAX_SIZE_MB: int = Field(
default=15,
description="로그 파일 최대 크기 (MB)",
)
# 로그 파일 백업 개수
# - 롤오버 시 보관할 백업 파일의 최대 개수입니다.
# - 이 개수를 초과하면 가장 오래된 백업 파일이 삭제됩니다.
# - 예: 30 설정 시, 최대 30개의 백업 파일 유지
# - 디스크 용량 관리를 위해 적절한 값 설정 권장
LOG_BACKUP_COUNT: int = Field(
default=30,
description="로그 파일 백업 개수",
)
# ============================================================
# 로그 포맷 설정
# ============================================================
# 사용 가능한 포맷 변수:
# {asctime} - 로그 발생 시간 (LOG_DATE_FORMAT 형식)
# {levelname} - 로그 레벨 (DEBUG, INFO 등)
# {name} - 로거 이름 (home, song 등)
# {filename} - 소스 파일명
# {funcName} - 함수명
# {lineno} - 라인 번호
# {message} - 로그 메시지
# {module} - 모듈명
# {pathname} - 파일 전체 경로
#
# 포맷 예시:
# "[{asctime}] {levelname} {message}"
# 출력: [2024-01-14 15:30:00] INFO 서버 시작
# ============================================================
# 콘솔 로그 포맷
# - 터미널에 출력되는 로그의 형식을 지정합니다.
# - [{levelname}]은 로그 레벨을 대괄호로 감싸서 출력합니다.
LOG_CONSOLE_FORMAT: str = Field(
default="[{asctime}] [{levelname}] [{name}:{funcName}:{lineno}] {message}",
description="콘솔 로그 포맷",
)
# 파일 로그 포맷
# - 파일에 기록되는 로그의 형식을 지정합니다.
# - 파일에는 더 상세한 정보(filename 등)를 포함할 수 있습니다.
LOG_FILE_FORMAT: str = Field(
default="[{asctime}] [{levelname}] [{filename}:{name} -> {funcName}():{lineno}] {message}",
description="파일 로그 포맷",
)
# 날짜 포맷
# - {asctime}에 표시되는 시간의 형식을 지정합니다.
# - Python strftime 형식을 따릅니다.
# - 예시:
# "%Y-%m-%d %H:%M:%S" -> 2024-01-14 15:30:00
# "%Y/%m/%d %H:%M:%S.%f" -> 2024/01/14 15:30:00.123456
# "%d-%b-%Y %H:%M:%S" -> 14-Jan-2024 15:30:00
LOG_DATE_FORMAT: str = Field(
default="%Y-%m-%d %H:%M:%S",
description="로그 날짜 포맷",
)
model_config = _base_config
def get_log_dir(self) -> Path:
"""
로그 디렉토리 경로를 반환합니다.
우선순위:
1. .env의 LOG_DIR 설정값 (절대 경로인 경우)
2. /www/log/uvicorn 폴더가 존재하면 사용 (운영 서버)
3. 프로젝트 루트의 logs 폴더 (개발 환경 기본값)
Returns:
Path: 로그 디렉토리 경로 (존재하지 않으면 자동 생성)
"""
# 1. .env에서 설정한 경로가 절대 경로인 경우 우선 사용
log_dir_path = Path(self.LOG_DIR)
if log_dir_path.is_absolute():
log_dir_path.mkdir(parents=True, exist_ok=True)
return log_dir_path
# 2. 운영 서버 경로 확인 (/www/log/uvicorn)
production_log_dir = Path("/www/log/uvicorn")
if production_log_dir.exists():
return production_log_dir
# 3. 기본값: 프로젝트 루트의 logs 폴더
default_log_dir = PROJECT_DIR / self.LOG_DIR
default_log_dir.mkdir(parents=True, exist_ok=True)
return default_log_dir
prj_settings = ProjectSettings() prj_settings = ProjectSettings()
apikey_settings = APIKeySettings() apikey_settings = APIKeySettings()
db_settings = DatabaseSettings() db_settings = DatabaseSettings()
security_settings = SecuritySettings()
notification_settings = NotificationSettings()
cors_settings = CORSSettings() cors_settings = CORSSettings()
crawler_settings = CrawlerSettings() crawler_settings = CrawlerSettings()
azure_blob_settings = AzureBlobSettings() azure_blob_settings = AzureBlobSettings()
creatomate_settings = CreatomateSettings() creatomate_settings = CreatomateSettings()
prompt_settings = PromptSettings() prompt_settings = PromptSettings()
log_settings = LogSettings()
kakao_settings = KakaoSettings()
jwt_settings = JWTSettings()

340
docs/user/kakao.md Normal file
View File

@ -0,0 +1,340 @@
# 카카오 소셜 로그인 구현 가이드
## 목차
1. [개요](#1-개요)
2. [인증 흐름](#2-인증-흐름)
3. [User 모델 구조](#3-user-모델-구조)
4. [API 엔드포인트](#4-api-엔드포인트)
5. [환경 설정](#5-환경-설정)
6. [에러 처리](#6-에러-처리)
---
## 1. 개요
CastAD는 카카오 소셜 로그인만 지원하며, 인증 후 자체 JWT 토큰을 발급합니다.
### 인증 방식
| 항목 | 설명 |
|------|------|
| 소셜 로그인 | 카카오 OAuth 2.0 |
| 자체 인증 | JWT (Access Token + Refresh Token) |
| 카카오 토큰 저장 | X (1회 검증 후 폐기) |
---
## 2. 인증 흐름
### 2.1 전체 흐름도
```
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client │ │ Backend │ │ Kakao │ │ DB │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │
│ 1. 로그인 요청 │ │ │
│──────────────>│ │ │
│ │ │ │
│ 2. 카카오 로그인 URL 반환 │ │
<──────────────│ │ │
│ │ │ │
│ 3. 카카오 로그인 페이지로 이동 │ │
│──────────────────────────────>│ │
│ │ │ │
│ 4. 사용자 인증 후 code 반환 │ │
<──────────────────────────────│ │
│ │ │ │
│ 5. code 전달 │ │ │
│──────────────>│ │ │
│ │ │ │
│ │ 6. code로 토큰 요청 │
│ │──────────────>│ │
│ │ │ │
│ │ 7. access_token 반환 │
│ │<──────────────│ │
│ │ │ │
│ │ 8. 사용자 정보 조회 │
│ │──────────────>│ │
│ │ │ │
│ │ 9. 사용자 정보 반환 │
│ │<──────────────│ │
│ │ │ │
│ │ 10. kakao_id로 회원 조회 │
│ │──────────────────────────────>│
│ │ │ │
│ │ 11. 회원 정보 반환 (없으면 생성) │
│ │<──────────────────────────────│
│ │ │ │
│ 12. 자체 JWT 발급 및 반환 │ │
<──────────────│ │ │
│ │ │ │
```
### 2.2 단계별 설명
| 단계 | 설명 | 관련 API |
|------|------|----------|
| 1-2 | 클라이언트가 로그인 요청, 백엔드가 카카오 인증 URL 생성 | `GET /user/auth/kakao/login` |
| 3-4 | 사용자가 카카오에서 로그인, 인가 코드(code) 발급 | 카카오 OAuth |
| 5-9 | 백엔드가 code로 카카오 토큰/사용자정보 획득 | 카카오 API |
| 10-11 | DB에서 회원 조회, 없으면 신규 가입 | 내부 처리 |
| 12 | 자체 JWT 토큰 발급 후 클라이언트에 반환 | `POST /user/auth/kakao/callback` |
---
## 3. User 모델 구조
### 3.1 테이블 스키마
```
┌─────────────────────────────────────────────────────────────┐
│ user │
├─────────────────────┬───────────────┬───────────────────────┤
│ Column │ Type │ Description │
├─────────────────────┼───────────────┼───────────────────────┤
│ id │ BIGINT (PK) │ 고유 식별자 (자동증가) │
│ kakao_id │ BIGINT (UQ) │ 카카오 회원번호 │
│ email │ VARCHAR(255) │ 이메일 (선택) │
│ nickname │ VARCHAR(100) │ 닉네임 (선택) │
│ profile_image_url │ VARCHAR(2048) │ 프로필 이미지 URL │
│ thumbnail_image_url │ VARCHAR(2048) │ 썸네일 이미지 URL │
│ is_active │ BOOLEAN │ 계정 활성화 상태 │
│ is_admin │ BOOLEAN │ 관리자 권한 │
│ last_login_at │ DATETIME │ 마지막 로그인 일시 │
│ created_at │ DATETIME │ 생성 일시 │
│ updated_at │ DATETIME │ 수정 일시 │
└─────────────────────┴───────────────┴───────────────────────┘
```
### 3.2 카카오 API 응답 매핑
```json
// 카카오 API 응답 예시
{
"id": 1234567890,
"kakao_account": {
"email": "user@kakao.com",
"profile": {
"nickname": "홍길동",
"profile_image_url": "https://k.kakaocdn.net/.../profile.jpg",
"thumbnail_image_url": "https://k.kakaocdn.net/.../thumb.jpg"
}
}
}
```
| User 필드 | 카카오 응답 경로 |
|-----------|-----------------|
| `kakao_id` | `id` |
| `email` | `kakao_account.email` |
| `nickname` | `kakao_account.profile.nickname` |
| `profile_image_url` | `kakao_account.profile.profile_image_url` |
| `thumbnail_image_url` | `kakao_account.profile.thumbnail_image_url` |
---
## 4. API 엔드포인트
### 4.1 카카오 로그인 URL 요청
```
GET /user/auth/kakao/login
```
**Response:**
```json
{
"auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=...&redirect_uri=...&response_type=code"
}
```
### 4.2 카카오 콜백 (로그인/가입 처리)
```
POST /user/auth/kakao/callback
```
**Request:**
```json
{
"code": "인가코드"
}
```
**Response (성공):**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"id": 1,
"nickname": "홍길동",
"email": "user@kakao.com",
"profile_image_url": "https://...",
"is_new_user": false
}
}
```
### 4.3 토큰 갱신
```
POST /user/auth/refresh
```
**Request:**
```json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
```
**Response:**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
}
```
### 4.4 로그아웃
```
POST /user/auth/logout
Authorization: Bearer {access_token}
```
### 4.5 모든 기기에서 로그아웃
```
POST /user/auth/logout/all
Authorization: Bearer {access_token}
```
### 4.6 내 정보 조회
```
GET /user/auth/me
Authorization: Bearer {access_token}
```
**Response:**
```json
{
"id": 1,
"kakao_id": 1234567890,
"nickname": "홍길동",
"email": "user@kakao.com",
"profile_image_url": "https://...",
"is_admin": false,
"created_at": "2026-01-14T16:00:00"
}
```
---
## 5. 환경 설정
### 5.1 카카오 개발자 설정
1. [카카오 개발자 콘솔](https://developers.kakao.com) 접속
2. 애플리케이션 생성
3. 플랫폼 > Web 사이트 도메인 등록
4. 카카오 로그인 > Redirect URI 등록
5. 동의항목 > 필요한 정보 설정
### 5.2 .env 설정
```env
# 카카오 OAuth
KAKAO_CLIENT_ID=your_rest_api_key
KAKAO_CLIENT_SECRET=your_client_secret # 선택
KAKAO_REDIRECT_URI=https://your-domain.com/api/v1/auth/kakao/callback
# JWT
JWT_SECRET=your-super-secret-key-min-32-characters
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
```
### 5.3 config.py 설정
```python
class KakaoSettings(BaseSettings):
KAKAO_CLIENT_ID: str = Field(...)
KAKAO_CLIENT_SECRET: str = Field(default="")
KAKAO_REDIRECT_URI: str = Field(...)
model_config = _base_config
class JWTSettings(BaseSettings):
JWT_SECRET: str = Field(...)
JWT_ALGORITHM: str = Field(default="HS256")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60)
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
model_config = _base_config
```
---
## 6. 에러 처리
### 6.1 에러 코드 정의
| HTTP Status | Error Code | 설명 |
|-------------|------------|------|
| 400 | `INVALID_CODE` | 유효하지 않은 인가 코드 |
| 400 | `KAKAO_AUTH_FAILED` | 카카오 인증 실패 |
| 401 | `TOKEN_EXPIRED` | 토큰 만료 |
| 401 | `INVALID_TOKEN` | 유효하지 않은 토큰 |
| 401 | `TOKEN_REVOKED` | 취소된 토큰 |
| 403 | `USER_INACTIVE` | 비활성화된 계정 |
| 403 | `ADMIN_REQUIRED` | 관리자 권한 필요 |
| 404 | `USER_NOT_FOUND` | 사용자 없음 |
| 500 | `KAKAO_API_ERROR` | 카카오 API 오류 |
### 6.2 에러 응답 형식
```json
{
"detail": {
"code": "TOKEN_EXPIRED",
"message": "토큰이 만료되었습니다. 다시 로그인해주세요."
}
}
```
---
## 부록: 파일 구조
```
app/user/
├── __init__.py
├── models.py # User, RefreshToken 모델
├── exceptions.py # 사용자 정의 예외
├── schemas/
│ ├── __init__.py
│ └── user_schema.py # Pydantic 스키마
├── services/
│ ├── __init__.py
│ ├── auth.py # 인증 서비스
│ ├── jwt.py # JWT 유틸리티
│ └── kakao.py # 카카오 OAuth 클라이언트
├── dependencies/
│ ├── __init__.py
│ └── auth.py # 인증 의존성 (get_current_user 등)
└── api/
└── routers/
└── v1/
├── __init__.py
└── auth.py # 인증 API 라우터
```

121
main.py
View File

@ -1,18 +1,90 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from scalar_fastapi import get_scalar_api_reference from scalar_fastapi import get_scalar_api_reference
from app.admin_manager import init_admin from app.admin_manager import init_admin
from app.core.common import lifespan from app.core.common import lifespan
from app.database.session import engine from app.database.session import engine
# 주의: User 모델을 먼저 import해야 UserProject가 User를 참조할 수 있음
from app.user.models import User, RefreshToken # noqa: F401
from app.home.api.routers.v1.home import router as home_router from app.home.api.routers.v1.home import router as home_router
from app.user.api.routers.v1.auth import router as auth_router
from app.lyric.api.routers.v1.lyric import router as lyric_router from app.lyric.api.routers.v1.lyric import router as lyric_router
from app.song.api.routers.v1.song import router as song_router from app.song.api.routers.v1.song import router as song_router
from app.video.api.routers.v1.video import router as video_router from app.video.api.routers.v1.video import router as video_router
from app.utils.cors import CustomCORSMiddleware from app.utils.cors import CustomCORSMiddleware
from config import prj_settings from config import prj_settings
tags_metadata = [
{
"name": "Auth",
"description": """카카오 소셜 로그인 및 JWT 토큰 관리 API
## 인증 흐름
1. `GET /api/v1/user/auth/kakao/login` - 카카오 로그인 URL 획득
2. 사용자를 auth_url로 리다이렉트 카카오 로그인
3. 카카오에서 인가 코드(code) 발급
4. `POST /api/v1/user/auth/kakao/callback` - 인가 코드로 JWT 토큰 발급
5. 이후 API 호출 `Authorization: Bearer {access_token}` 헤더 사용
## 토큰 관리
- **Access Token**: 1시간 유효, API 호출 사용
- **Refresh Token**: 7 유효, Access Token 갱신 사용
""",
},
{
"name": "Home",
"description": "홈 화면 및 프로젝트 관리 API",
},
{
"name": "crawling",
"description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",
},
{
"name": "image",
"description": "이미지 업로드 API - 로컬 서버 또는 Azure Blob Storage",
},
{
"name": "Lyric",
"description": """가사 생성 및 관리 API
## 가사 생성 흐름
1. `POST /api/v1/lyric/generate` - 가사 생성 요청 (백그라운드 처리)
2. `GET /api/v1/lyric/status/{task_id}` - 생성 상태 확인
3. `GET /api/v1/lyric/{task_id}` - 생성된 가사 조회
""",
},
{
"name": "Song",
"description": """노래 생성 및 관리 API (Suno AI)
## 노래 생성 흐름
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
2. `GET /api/v1/song/status/{suno_task_id}` - Suno API 상태 확인
3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회
""",
},
{
"name": "Video",
"description": """영상 생성 및 관리 API (Creatomate)
## 영상 생성 흐름
1. `GET /api/v1/video/generate/{task_id}` - 영상 생성 요청
2. `GET /api/v1/video/status/{creatomate_render_id}` - Creatomate 상태 확인
3. `GET /api/v1/video/download/{task_id}` - 영상 다운로드 URL 조회
""",
},
]
app = FastAPI( app = FastAPI(
title=prj_settings.PROJECT_NAME, title=prj_settings.PROJECT_NAME,
version=prj_settings.VERSION, version=prj_settings.VERSION,
@ -20,8 +92,50 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
docs_url=None, # 기본 Swagger UI 비활성화 docs_url=None, # 기본 Swagger UI 비활성화
redoc_url=None, # 기본 ReDoc 비활성화 redoc_url=None, # 기본 ReDoc 비활성화
openapi_tags=tags_metadata,
) )
def custom_openapi():
"""커스텀 OpenAPI 스키마 생성 (Bearer 인증 추가)"""
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
tags=tags_metadata,
)
# Bearer 토큰 인증 스키마 추가
openapi_schema["components"]["securitySchemes"] = {
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "JWT 액세스 토큰을 입력하세요. 카카오 로그인 후 발급받은 access_token을 사용합니다.",
}
}
# 보안이 필요한 엔드포인트에 security 적용
for path, path_item in openapi_schema["paths"].items():
for method, operation in path_item.items():
if method in ["get", "post", "put", "patch", "delete"]:
# /auth/me, /auth/logout 등 인증이 필요한 엔드포인트
if any(
auth_path in path
for auth_path in ["/auth/me", "/auth/logout"]
):
operation["security"] = [{"BearerAuth": []}]
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
init_admin(app, engine) init_admin(app, engine)
custom_cors_middleware = CustomCORSMiddleware(app) custom_cors_middleware = CustomCORSMiddleware(app)
@ -49,6 +163,7 @@ def get_scalar_docs():
app.include_router(home_router) app.include_router(home_router)
app.include_router(lyric_router) # Lyric API 라우터 추가 app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
app.include_router(song_router) # Song API 라우터 추가 app.include_router(lyric_router)
app.include_router(video_router) # Video API 라우터 추가 app.include_router(song_router)
app.include_router(video_router)

View File

@ -14,6 +14,7 @@ dependencies = [
"fastapi[standard]>=0.125.0", "fastapi[standard]>=0.125.0",
"openai>=2.13.0", "openai>=2.13.0",
"pydantic-settings>=2.12.0", "pydantic-settings>=2.12.0",
"python-jose[cryptography]>=3.5.0",
"redis>=7.1.0", "redis>=7.1.0",
"ruff>=0.14.9", "ruff>=0.14.9",
"scalar-fastapi>=1.5.0", "scalar-fastapi>=1.5.0",

173
uv.lock
View File

@ -179,6 +179,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
] ]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.1" version = "8.3.1"
@ -200,6 +245,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "cryptography"
version = "46.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
[[package]] [[package]]
name = "distro" name = "distro"
version = "1.9.0" version = "1.9.0"
@ -218,6 +319,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
] ]
[[package]]
name = "ecdsa"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
]
[[package]] [[package]]
name = "email-validator" name = "email-validator"
version = "2.3.0" version = "2.3.0"
@ -769,6 +882,7 @@ dependencies = [
{ name = "fastapi-cli" }, { name = "fastapi-cli" },
{ name = "openai" }, { name = "openai" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "redis" }, { name = "redis" },
{ name = "ruff" }, { name = "ruff" },
{ name = "scalar-fastapi" }, { name = "scalar-fastapi" },
@ -794,6 +908,7 @@ requires-dist = [
{ name = "fastapi-cli", specifier = ">=0.0.16" }, { name = "fastapi-cli", specifier = ">=0.0.16" },
{ name = "openai", specifier = ">=2.13.0" }, { name = "openai", specifier = ">=2.13.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "redis", specifier = ">=7.1.0" }, { name = "redis", specifier = ">=7.1.0" },
{ name = "ruff", specifier = ">=0.14.9" }, { name = "ruff", specifier = ">=0.14.9" },
{ name = "scalar-fastapi", specifier = ">=1.5.0" }, { name = "scalar-fastapi", specifier = ">=1.5.0" },
@ -914,6 +1029,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
] ]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" version = "2.12.5"
@ -1056,6 +1189,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
] ]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.21" version = "0.0.21"
@ -1190,6 +1342,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
] ]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.14.10" version = "0.14.10"
@ -1247,6 +1411,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"