merge main
commit
2f384fb72a
|
|
@ -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 원칙 준수 여부 확인
|
||||
|
||||
## 출력
|
||||
설계 문서를 화면에 출력합니다.
|
||||
|
|
@ -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가 누락되지 않았는가?
|
||||
- 순환 참조가 발생하지 않는가?
|
||||
|
|
@ -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: 코드 스타일, 베스트 프랙티스 권장
|
||||
|
||||
## 출력
|
||||
코드 리뷰 리포트를 화면에 출력합니다.
|
||||
|
|
@ -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` 명령으로 개발 에이전트를 호출하여 구현을 진행합니다.
|
||||
|
|
@ -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` 명령으로 코드리뷰 에이전트를 호출하여 최종 검수를 진행합니다.
|
||||
|
|
@ -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,8 +8,8 @@ __pycache__/
|
|||
.env
|
||||
|
||||
# Claude AI related files
|
||||
.claude/
|
||||
.claudeignore
|
||||
# .claude/ 폴더는 커밋 대상 (에이전트 설정 포함)
|
||||
|
||||
# VSCode settings
|
||||
.vscode/
|
||||
|
|
@ -30,3 +30,9 @@ media/
|
|||
|
||||
|
||||
*.ipynb_checkpoint*
|
||||
# Static files
|
||||
static/
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
logs/
|
||||
|
|
|
|||
|
|
@ -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,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),
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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}')>"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -4,12 +4,16 @@ from contextlib import asynccontextmanager
|
|||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("core")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""FastAPI 애플리케이션 생명주기 관리"""
|
||||
# Startup - 애플리케이션 시작 시
|
||||
print("Starting up...")
|
||||
logger.info("Starting up...")
|
||||
|
||||
try:
|
||||
from config import prj_settings
|
||||
|
|
@ -19,20 +23,20 @@ async def lifespan(app: FastAPI):
|
|||
from app.database.session import create_db_tables
|
||||
|
||||
await create_db_tables()
|
||||
print("Database tables created (DEBUG mode)")
|
||||
logger.info("Database tables created (DEBUG mode)")
|
||||
except asyncio.TimeoutError:
|
||||
print("Database initialization timed out")
|
||||
logger.error("Database initialization timed out")
|
||||
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Database initialization failed: {e}")
|
||||
logger.error(f"Database initialization failed: {e}")
|
||||
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||
raise
|
||||
|
||||
yield # 애플리케이션 실행 중
|
||||
|
||||
# Shutdown - 애플리케이션 종료 시
|
||||
print("Shutting down...")
|
||||
logger.info("Shutting down...")
|
||||
|
||||
# 공유 HTTP 클라이언트 종료
|
||||
from app.utils.creatomate import close_shared_client
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import logging
|
||||
import traceback
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
|
@ -7,8 +6,10 @@ from fastapi import FastAPI, HTTPException, Request, Response, status
|
|||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger("core")
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
|
@ -159,16 +160,14 @@ def handle_db_exceptions(
|
|||
raise
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[DB Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[DB Error] {func.__name__}: {e}")
|
||||
logger.debug(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=error_message,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Unexpected Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[Unexpected Error] {func.__name__}: {e}")
|
||||
logger.debug(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.",
|
||||
|
|
@ -205,8 +204,7 @@ def handle_external_service_exceptions(
|
|||
except Exception as e:
|
||||
msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다."
|
||||
logger.error(f"[{service_name} Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[{service_name} Error] {func.__name__}: {e}")
|
||||
logger.debug(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=msg,
|
||||
|
|
@ -240,16 +238,14 @@ def handle_api_exceptions(
|
|||
raise
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[API DB Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[API DB Error] {func.__name__}: {e}")
|
||||
logger.debug(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[API Error] {func.__name__}: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
print(f"[API Error] {func.__name__}: {e}")
|
||||
logger.debug(traceback.format_exc())
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=error_message,
|
||||
|
|
@ -263,16 +259,7 @@ def handle_api_exceptions(
|
|||
def _get_handler(status: int, detail: str):
|
||||
# Define
|
||||
def handler(request: Request, exception: Exception) -> Response:
|
||||
# DEBUG PRINT STATEMENT 👇
|
||||
from rich import print, panel
|
||||
print(
|
||||
panel.Panel(
|
||||
exception.__class__.__name__,
|
||||
title="Handled Exception",
|
||||
border_style="red",
|
||||
),
|
||||
)
|
||||
# DEBUG PRINT STATEMENT 👆
|
||||
logger.debug(f"Handled Exception: {exception.__class__.__name__}")
|
||||
|
||||
# Raise HTTPException with given status and detail
|
||||
# can return JSONResponse as well
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ from sqlalchemy.ext.asyncio import (
|
|||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import db_settings
|
||||
|
||||
logger = get_logger("database")
|
||||
|
||||
|
||||
# Base 클래스 정의
|
||||
class Base(DeclarativeBase):
|
||||
|
|
@ -61,7 +64,7 @@ async def create_db_tables() -> None:
|
|||
async with engine.begin() as conn:
|
||||
# from app.database.models import Shipment, Seller # noqa: F401
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
print("MySQL tables created successfully")
|
||||
logger.info("MySQL tables created successfully")
|
||||
|
||||
|
||||
# 세션 제너레이터 (FastAPI Depends에 사용)
|
||||
|
|
@ -80,13 +83,13 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
|
||||
except Exception as e:
|
||||
await session.rollback() # 명시적 롤백 (선택적)
|
||||
print(f"Session rollback due to: {e}") # 로깅
|
||||
logger.error(f"Session rollback due to: {e}")
|
||||
raise
|
||||
finally:
|
||||
# 명시적 세션 종료 (Connection Pool에 반환)
|
||||
# context manager가 자동 처리하지만, 명시적으로 유지
|
||||
await session.close()
|
||||
print("session closed successfully")
|
||||
logger.debug("session closed successfully")
|
||||
# 또는 session.aclose() - Python 3.10+
|
||||
|
||||
|
||||
|
|
@ -94,4 +97,4 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
async def dispose_engine() -> None:
|
||||
"""애플리케이션 종료 시 모든 연결 해제"""
|
||||
await engine.dispose()
|
||||
print("Database engine disposed")
|
||||
logger.info("Database engine disposed")
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ from typing import AsyncGenerator
|
|||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import db_settings
|
||||
|
||||
logger = get_logger("database")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
|
@ -69,12 +72,14 @@ async def create_db_tables():
|
|||
import asyncio
|
||||
|
||||
# 모델 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.song.models import Song # 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 engine.begin() as connection:
|
||||
|
|
@ -87,7 +92,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
pool = engine.pool
|
||||
|
||||
# 커넥션 풀 상태 로깅 (디버깅용)
|
||||
print(
|
||||
logger.debug(
|
||||
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
f"overflow: {pool.overflow()}"
|
||||
|
|
@ -95,7 +100,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
|
||||
async with AsyncSessionLocal() as session:
|
||||
acquire_time = time.perf_counter()
|
||||
print(
|
||||
logger.debug(
|
||||
f"[get_session] Session acquired in "
|
||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
)
|
||||
|
|
@ -103,14 +108,14 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
yield session
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
print(
|
||||
logger.error(
|
||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
print(
|
||||
logger.debug(
|
||||
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
||||
f"pool_out: {pool.checkedout()}"
|
||||
)
|
||||
|
|
@ -121,7 +126,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
start_time = time.perf_counter()
|
||||
pool = background_engine.pool
|
||||
|
||||
print(
|
||||
logger.debug(
|
||||
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
f"overflow: {pool.overflow()}"
|
||||
|
|
@ -129,7 +134,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
|
||||
async with BackgroundSessionLocal() as session:
|
||||
acquire_time = time.perf_counter()
|
||||
print(
|
||||
logger.debug(
|
||||
f"[get_background_session] Session acquired in "
|
||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
)
|
||||
|
|
@ -137,7 +142,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
yield session
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
print(
|
||||
logger.error(
|
||||
f"[get_background_session] ROLLBACK - "
|
||||
f"error: {type(e).__name__}: {e}, "
|
||||
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||
|
|
@ -145,7 +150,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
print(
|
||||
logger.debug(
|
||||
f"[get_background_session] RELEASE - "
|
||||
f"duration: {total_time*1000:.1f}ms, "
|
||||
f"pool_out: {pool.checkedout()}"
|
||||
|
|
@ -154,8 +159,8 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
|
||||
# 앱 종료 시 엔진 리소스 정리 함수
|
||||
async def dispose_engine() -> None:
|
||||
print("[dispose_engine] Disposing database engines...")
|
||||
logger.info("[dispose_engine] Disposing database engines...")
|
||||
await engine.dispose()
|
||||
print("[dispose_engine] Main engine disposed")
|
||||
logger.info("[dispose_engine] Main engine disposed")
|
||||
await background_engine.dispose()
|
||||
print("[dispose_engine] Background engine disposed - ALL DONE")
|
||||
logger.info("[dispose_engine] Background engine disposed - ALL DONE")
|
||||
|
|
|
|||
|
|
@ -1,15 +1,3 @@
|
|||
"""API 1 Version Router Module."""
|
||||
|
||||
# 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])
|
||||
"""
|
||||
Home API v1 라우터 모듈
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import json
|
||||
import logging
|
||||
import traceback
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
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.chatgpt_prompt import ChatgptService
|
||||
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.prompts.prompts import marketing_prompt
|
||||
from config import MEDIA_ROOT
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MEDIA_ROOT = Path("media")
|
||||
logger = get_logger("home")
|
||||
|
||||
# 전국 시 이름 목록 (roadAddress에서 region 추출용)
|
||||
# fmt: off
|
||||
|
|
@ -63,7 +62,7 @@ KOREAN_CITIES = [
|
|||
]
|
||||
# fmt: on
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(tags=["Home"])
|
||||
|
||||
|
||||
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):
|
||||
"""네이버 지도 장소 크롤링"""
|
||||
import time
|
||||
|
||||
request_start = time.perf_counter()
|
||||
logger.info(f"[crawling] START - url: {request_body.url[:80]}...")
|
||||
print(f"[crawling] ========== START ==========")
|
||||
print(f"[crawling] URL: {request_body.url[:80]}...")
|
||||
logger.info("[crawling] ========== START ==========")
|
||||
logger.info(f"[crawling] URL: {request_body.url[:80]}...")
|
||||
|
||||
# ========== Step 1: 네이버 지도 크롤링 ==========
|
||||
step1_start = time.perf_counter()
|
||||
print(f"[crawling] Step 1: 네이버 지도 크롤링 시작...")
|
||||
logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...")
|
||||
|
||||
try:
|
||||
scraper = NvMapScraper(request_body.url)
|
||||
|
|
@ -124,7 +120,6 @@ async def crawling(request_body: CrawlingRequest):
|
|||
except GraphQLException as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
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(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
||||
|
|
@ -132,8 +127,7 @@ async def crawling(request_body: CrawlingRequest):
|
|||
except Exception as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)")
|
||||
traceback.print_exc()
|
||||
logger.exception("[crawling] Step 1 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="네이버 지도 크롤링 중 오류가 발생했습니다.",
|
||||
|
|
@ -142,11 +136,10 @@ async def crawling(request_body: CrawlingRequest):
|
|||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
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)")
|
||||
print(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
|
||||
|
||||
# ========== Step 2: 정보 가공 ==========
|
||||
step2_start = time.perf_counter()
|
||||
print(f"[crawling] Step 2: 정보 가공 시작...")
|
||||
logger.info("[crawling] Step 2: 정보 가공 시작...")
|
||||
|
||||
processed_info = None
|
||||
marketing_analysis = None
|
||||
|
|
@ -164,18 +157,17 @@ async def crawling(request_body: CrawlingRequest):
|
|||
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
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 마케팅 분석 ==========
|
||||
step3_start = time.perf_counter()
|
||||
print(f"[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
|
||||
logger.info("[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
|
||||
|
||||
try:
|
||||
# Step 3-1: ChatGPT 서비스 초기화
|
||||
step3_1_start = time.perf_counter()
|
||||
chatgpt_service = ChatgptService()
|
||||
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: 프롬프트 생성
|
||||
# step3_2_start = time.perf_counter()
|
||||
|
|
@ -187,19 +179,18 @@ async def crawling(request_body: CrawlingRequest):
|
|||
# prompt = chatgpt_service.build_market_analysis_prompt()
|
||||
# prompt1 = marketing_prompt.build_prompt(input_marketing_data)
|
||||
# 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 호출
|
||||
step3_3_start = time.perf_counter()
|
||||
structured_report = await chatgpt_service.generate_structured_output(marketing_prompt, input_marketing_data)
|
||||
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
|
||||
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 전달)
|
||||
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에서 추출 중
|
||||
# parsed = await chatgpt_service.parse_marketing_analysis(
|
||||
|
|
@ -218,36 +209,32 @@ async def crawling(request_body: CrawlingRequest):
|
|||
# print(sp['keywords'])
|
||||
# print(sp['description'])
|
||||
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
|
||||
logger.info(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
|
||||
|
||||
except Exception as e:
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.error(f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 3 FAILED - {e} ({step3_elapsed:.1f}ms)")
|
||||
traceback.print_exc()
|
||||
logger.exception("[crawling] Step 3 상세 오류:")
|
||||
# GPT 실패 시에도 크롤링 결과는 반환
|
||||
marketing_analysis = None
|
||||
else:
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.warning(f"[crawling] Step 2 - base_info 없음 ({step2_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)")
|
||||
logger.warning(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)")
|
||||
|
||||
# ========== 완료 ==========
|
||||
total_elapsed = (time.perf_counter() - request_start) * 1000
|
||||
logger.info(f"[crawling] COMPLETE - 총 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[crawling] ========== COMPLETE ==========")
|
||||
print(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
|
||||
logger.info("[crawling] ========== COMPLETE ==========")
|
||||
logger.info(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
|
||||
logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
|
||||
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():
|
||||
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():
|
||||
print(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
|
||||
logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
|
||||
|
||||
return {
|
||||
"image_list": scraper.image_link_list,
|
||||
|
|
@ -629,12 +616,11 @@ async def upload_images_blob(
|
|||
- Stage 2: Azure Blob 업로드 (세션 없음)
|
||||
- Stage 3: DB 저장 (새 세션으로 빠르게 처리)
|
||||
"""
|
||||
import time
|
||||
request_start = time.perf_counter()
|
||||
|
||||
# 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: 입력 검증 및 파일 데이터 준비 (세션 없음) ==========
|
||||
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()
|
||||
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"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}"
|
||||
|
||||
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)")
|
||||
|
||||
# Azure Blob Storage에 직접 업로드
|
||||
|
|
@ -719,18 +705,18 @@ async def upload_images_blob(
|
|||
blob_url = uploader.public_url
|
||||
blob_upload_results.append((original_name, blob_url))
|
||||
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:
|
||||
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()
|
||||
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"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms")
|
||||
|
||||
# ========== 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] = []
|
||||
img_order = 0
|
||||
|
||||
|
|
@ -786,13 +772,13 @@ async def upload_images_blob(
|
|||
|
||||
await session.commit()
|
||||
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"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
|
||||
|
||||
except SQLAlchemyError as 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(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
||||
|
|
@ -800,7 +786,7 @@ async def upload_images_blob(
|
|||
except Exception as e:
|
||||
logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - "
|
||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}")
|
||||
traceback.print_exc()
|
||||
logger.exception("[upload_images_blob] Stage 3 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="이미지 업로드 중 오류가 발생했습니다.",
|
||||
|
|
@ -810,7 +796,7 @@ async def upload_images_blob(
|
|||
image_urls = [img.img_url for img in result_images]
|
||||
|
||||
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")
|
||||
|
||||
return ImageUploadResponse(
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ Home 모듈 SQLAlchemy 모델 정의
|
|||
이 모듈은 영상 제작 파이프라인의 핵심 데이터 모델을 정의합니다.
|
||||
- Project: 프로젝트(사용자 입력 이력) 관리
|
||||
- Image: 업로드된 이미지 URL 관리
|
||||
- UserProject: User와 Project 간 M:N 관계 중계 테이블
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
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 app.database.session import Base
|
||||
|
|
@ -17,8 +18,125 @@ from app.database.session import Base
|
|||
if TYPE_CHECKING:
|
||||
from app.lyric.models import Lyric
|
||||
from app.song.models import Song
|
||||
from app.user.models import User
|
||||
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):
|
||||
"""
|
||||
|
|
@ -39,6 +157,8 @@ class Project(Base):
|
|||
lyrics: 생성된 가사 목록
|
||||
songs: 생성된 노래 목록
|
||||
videos: 최종 영상 결과 목록
|
||||
user_projects: User와의 M:N 관계 (중계 테이블 통한 연결)
|
||||
users: 프로젝트에 참여한 사용자 목록 (Association Proxy)
|
||||
"""
|
||||
|
||||
__tablename__ = "project"
|
||||
|
|
@ -123,6 +243,20 @@ class Project(Base):
|
|||
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 truncate(value: str | None, max_len: int = 10) -> str:
|
||||
if value is None:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import pytest
|
|||
from sqlalchemy import text
|
||||
|
||||
from app.database.session import AsyncSessionLocal, engine
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("test_db")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -27,4 +31,4 @@ async def test_database_version():
|
|||
result = await session.execute(text("SELECT VERSION()"))
|
||||
version = result.scalar()
|
||||
assert version is not None
|
||||
print(f"MySQL Version: {version}")
|
||||
logger.info(f"MySQL Version: {version}")
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ from fastapi import UploadFile
|
|||
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
|
||||
MEDIA_ROOT = Path("media")
|
||||
|
||||
|
||||
async def save_upload_file(file: UploadFile, save_path: Path) -> None:
|
||||
"""업로드 파일을 지정된 경로에 저장"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Lyric API v1 라우터 모듈
|
||||
"""
|
||||
|
|
@ -41,10 +41,13 @@ from app.lyric.schemas.lyric import (
|
|||
)
|
||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
||||
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.prompts.prompts import lyric_prompt
|
||||
import traceback as tb
|
||||
# 로거 설정
|
||||
logger = get_logger("lyric")
|
||||
|
||||
router = APIRouter(prefix="/lyric", tags=["lyric"])
|
||||
|
||||
|
|
@ -77,7 +80,7 @@ async def get_lyric_status_by_task_id(
|
|||
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(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
|
|
@ -87,7 +90,7 @@ async def get_lyric_status_by_task_id(
|
|||
lyric = result.scalar_one_or_none()
|
||||
|
||||
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(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||
|
|
@ -99,7 +102,7 @@ async def get_lyric_status_by_task_id(
|
|||
"failed": "가사 생성에 실패했습니다.",
|
||||
}
|
||||
|
||||
print(
|
||||
logger.info(
|
||||
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
|
||||
)
|
||||
return LyricStatusResponse(
|
||||
|
|
@ -130,7 +133,7 @@ async def get_lyric_by_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(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == task_id)
|
||||
|
|
@ -140,19 +143,18 @@ async def get_lyric_by_task_id(
|
|||
lyric = result.scalar_one_or_none()
|
||||
|
||||
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(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
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(
|
||||
id=lyric.id,
|
||||
task_id=lyric.task_id,
|
||||
project_id=lyric.project_id,
|
||||
status=lyric.status,
|
||||
lyric_prompt=lyric.lyric_prompt,
|
||||
lyric_result=lyric.lyric_result,
|
||||
created_at=lyric.created_at,
|
||||
)
|
||||
|
|
@ -229,8 +231,8 @@ async def generate_lyric(
|
|||
task_id = request_body.task_id
|
||||
|
||||
|
||||
print(f"[generate_lyric] ========== START ==========")
|
||||
print(
|
||||
logger.info(f"[generate_lyric] ========== START ==========")
|
||||
logger.info(
|
||||
f"[generate_lyric] task_id: {task_id}, "
|
||||
f"customer_name: {request_body.customer_name}, "
|
||||
f"region: {request_body.region}"
|
||||
|
|
@ -239,7 +241,7 @@ async def generate_lyric(
|
|||
try:
|
||||
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
|
||||
step1_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
||||
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
||||
|
||||
# service = ChatgptService(
|
||||
# customer_name=request_body.customer_name,
|
||||
|
|
@ -279,11 +281,11 @@ async def generate_lyric(
|
|||
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
||||
|
||||
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 테이블에 데이터 저장 ==========
|
||||
step2_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 2: Project 저장...")
|
||||
logger.debug(f"[generate_lyric] Step 2: Project 저장...")
|
||||
|
||||
project = Project(
|
||||
store_name=request_body.customer_name,
|
||||
|
|
@ -297,11 +299,11 @@ async def generate_lyric(
|
|||
await session.refresh(project)
|
||||
|
||||
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 테이블에 데이터 저장 ==========
|
||||
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)
|
||||
lyric = Lyric(
|
||||
|
|
@ -317,11 +319,11 @@ async def generate_lyric(
|
|||
await session.refresh(lyric)
|
||||
|
||||
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: 백그라운드 태스크 스케줄링 ==========
|
||||
step4_start = time.perf_counter()
|
||||
print(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
||||
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
||||
|
||||
background_tasks.add_task(
|
||||
generate_lyric_background,
|
||||
|
|
@ -331,17 +333,17 @@ async def generate_lyric(
|
|||
)
|
||||
|
||||
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
|
||||
print(f"[generate_lyric] ========== COMPLETE ==========")
|
||||
print(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
|
||||
logger.info(f"[generate_lyric] ========== COMPLETE ==========")
|
||||
logger.info(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
|
||||
logger.debug(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
|
||||
logger.debug(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
|
||||
logger.debug(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms")
|
||||
logger.debug(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
|
||||
logger.debug(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
|
||||
|
||||
# 5. 즉시 응답 반환
|
||||
return GenerateLyricResponse(
|
||||
|
|
@ -354,7 +356,7 @@ async def generate_lyric(
|
|||
|
||||
except Exception as e:
|
||||
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()
|
||||
return GenerateLyricResponse(
|
||||
success=False,
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
@ -140,7 +140,6 @@ class LyricDetailResponse(BaseModel):
|
|||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"project_id": 1,
|
||||
"status": "completed",
|
||||
"lyric_prompt": "고객명: 스테이 머뭄, 지역: 군산...",
|
||||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
|
|
@ -151,7 +150,6 @@ class LyricDetailResponse(BaseModel):
|
|||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
project_id: int = Field(..., description="프로젝트 ID")
|
||||
status: str = Field(..., description="처리 상태")
|
||||
lyric_prompt: str = Field(..., description="가사 생성 프롬프트")
|
||||
lyric_result: Optional[str] = Field(None, description="생성된 가사")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ Lyric Background Tasks
|
|||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from sqlalchemy import select
|
||||
|
|
@ -14,9 +13,10 @@ from app.database.session import BackgroundSessionLocal
|
|||
from app.lyric.models import Lyric
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
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(
|
||||
|
|
@ -50,20 +50,16 @@ async def _update_lyric_status(
|
|||
lyric.lyric_result = result
|
||||
await session.commit()
|
||||
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
|
||||
else:
|
||||
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
|
||||
|
||||
except SQLAlchemyError as 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
|
||||
except Exception as 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
|
||||
|
||||
|
||||
|
|
@ -83,15 +79,15 @@ async def generate_lyric_background(
|
|||
|
||||
task_start = time.perf_counter()
|
||||
logger.info(f"[generate_lyric_background] START - task_id: {task_id}")
|
||||
print(f"[generate_lyric_background] ========== START ==========")
|
||||
print(f"[generate_lyric_background] task_id: {task_id}")
|
||||
print(f"[generate_lyric_background] language: {lyric_input_data['language']}")
|
||||
#print(f"[generate_lyric_background] prompt length: {len(prompt)}자")
|
||||
logger.debug(f"[generate_lyric_background] ========== START ==========")
|
||||
logger.debug(f"[generate_lyric_background] task_id: {task_id}")
|
||||
logger.debug(f"[generate_lyric_background] language: {lyric_input_data['language']}")
|
||||
#logger.debug(f"[generate_lyric_background] prompt length: {len(prompt)}자")
|
||||
|
||||
try:
|
||||
# ========== Step 1: ChatGPT 서비스 초기화 ==========
|
||||
step1_start = time.perf_counter()
|
||||
print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
||||
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
||||
|
||||
# service = ChatgptService(
|
||||
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||
|
|
@ -103,48 +99,43 @@ async def generate_lyric_background(
|
|||
chatgpt = ChatgptService()
|
||||
|
||||
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 호출 (가사 생성) ==========
|
||||
step2_start = time.perf_counter()
|
||||
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_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
|
||||
result = result_response['lyric']
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
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 상태 업데이트 ==========
|
||||
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)
|
||||
|
||||
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
|
||||
logger.info(f"[generate_lyric_background] SUCCESS - task_id: {task_id}, 총 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric_background] ========== SUCCESS ==========")
|
||||
print(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms")
|
||||
print(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms")
|
||||
print(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] ========== SUCCESS ==========")
|
||||
logger.debug(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms")
|
||||
logger.debug(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms")
|
||||
logger.debug(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms")
|
||||
logger.debug(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
||||
print(f"[generate_lyric_background] DB ERROR - {e} ({elapsed:.1f}ms)")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
|
||||
print(f"[generate_lyric_background] EXCEPTION - {e} ({elapsed:.1f}ms)")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Song API v1 라우터 모듈
|
||||
"""
|
||||
|
|
@ -32,11 +32,13 @@ from app.song.schemas.song_schema import (
|
|||
PollingSongResponse,
|
||||
SongListItem,
|
||||
)
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
from app.utils.suno import SunoService
|
||||
|
||||
logger = get_logger("song")
|
||||
|
||||
router = APIRouter(prefix="/song", tags=["song"])
|
||||
router = APIRouter(prefix="/song", tags=["Song"])
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -99,7 +101,7 @@ async def generate_song(
|
|||
from app.database.session import AsyncSessionLocal
|
||||
|
||||
request_start = time.perf_counter()
|
||||
print(
|
||||
logger.info(
|
||||
f"[generate_song] START - task_id: {task_id}, "
|
||||
f"genre: {request_body.genre}, language: {request_body.language}"
|
||||
)
|
||||
|
|
@ -124,7 +126,7 @@ async def generate_song(
|
|||
project = project_result.scalar_one_or_none()
|
||||
|
||||
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(
|
||||
status_code=404,
|
||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||
|
|
@ -141,7 +143,7 @@ async def generate_song(
|
|||
lyric = lyric_result.scalar_one_or_none()
|
||||
|
||||
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(
|
||||
status_code=404,
|
||||
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
|
||||
|
|
@ -149,7 +151,7 @@ async def generate_song(
|
|||
lyric_id = lyric.id
|
||||
|
||||
query_time = time.perf_counter()
|
||||
print(
|
||||
logger.info(
|
||||
f"[generate_song] Queries completed - task_id: {task_id}, "
|
||||
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
||||
f"elapsed: {(query_time - request_start)*1000:.1f}ms"
|
||||
|
|
@ -174,7 +176,7 @@ async def generate_song(
|
|||
song_id = song.id
|
||||
|
||||
stage1_time = time.perf_counter()
|
||||
print(
|
||||
logger.info(
|
||||
f"[generate_song] Stage 1 DONE - Song saved - "
|
||||
f"task_id: {task_id}, song_id: {song_id}, "
|
||||
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms"
|
||||
|
|
@ -184,7 +186,7 @@ async def generate_song(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(
|
||||
logger.error(
|
||||
f"[generate_song] Stage 1 EXCEPTION - "
|
||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
||||
)
|
||||
|
|
@ -203,7 +205,7 @@ async def generate_song(
|
|||
suno_task_id: str | None = None
|
||||
|
||||
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_task_id = await suno_service.generate(
|
||||
prompt=request_body.lyrics,
|
||||
|
|
@ -211,14 +213,14 @@ async def generate_song(
|
|||
)
|
||||
|
||||
stage2_time = time.perf_counter()
|
||||
print(
|
||||
logger.info(
|
||||
f"[generate_song] Stage 2 DONE - task_id: {task_id}, "
|
||||
f"suno_task_id: {suno_task_id}, "
|
||||
f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(
|
||||
logger.error(
|
||||
f"[generate_song] Stage 2 EXCEPTION - Suno API failed - "
|
||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
||||
)
|
||||
|
|
@ -244,7 +246,7 @@ async def generate_song(
|
|||
# 3단계: suno_task_id 업데이트 (새 세션으로 빠르게 처리)
|
||||
# ==========================================================================
|
||||
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:
|
||||
async with AsyncSessionLocal() as update_session:
|
||||
|
|
@ -258,11 +260,11 @@ async def generate_song(
|
|||
|
||||
stage3_time = time.perf_counter()
|
||||
total_time = stage3_time - request_start
|
||||
print(
|
||||
logger.info(
|
||||
f"[generate_song] Stage 3 DONE - task_id: {task_id}, "
|
||||
f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
|
||||
)
|
||||
print(
|
||||
logger.info(
|
||||
f"[generate_song] SUCCESS - task_id: {task_id}, "
|
||||
f"suno_task_id: {suno_task_id}, "
|
||||
f"total_time: {total_time*1000:.1f}ms"
|
||||
|
|
@ -277,7 +279,7 @@ async def generate_song(
|
|||
)
|
||||
|
||||
except Exception as e:
|
||||
print(
|
||||
logger.error(
|
||||
f"[generate_song] Stage 3 EXCEPTION - "
|
||||
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로,
|
||||
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:
|
||||
suno_service = SunoService()
|
||||
result = await suno_service.get_task_status(suno_task_id)
|
||||
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에 직접 저장
|
||||
if parsed_response.status == "SUCCESS" and parsed_response.clips:
|
||||
|
|
@ -354,7 +356,7 @@ async def get_song_status(
|
|||
first_clip = parsed_response.clips[0]
|
||||
audio_url = first_clip.audio_url
|
||||
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:
|
||||
# suno_task_id로 Song 조회
|
||||
|
|
@ -373,17 +375,17 @@ async def get_song_status(
|
|||
if clip_duration is not None:
|
||||
song.duration = clip_duration
|
||||
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":
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
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(
|
||||
success=False,
|
||||
status="error",
|
||||
|
|
@ -438,7 +440,7 @@ async def download_song(
|
|||
session: AsyncSession = Depends(get_session),
|
||||
) -> DownloadSongResponse:
|
||||
"""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:
|
||||
# task_id로 Song 조회 (여러 개 있을 경우 가장 최근 것 선택)
|
||||
song_result = await session.execute(
|
||||
|
|
@ -450,7 +452,7 @@ async def download_song(
|
|||
song = song_result.scalar_one_or_none()
|
||||
|
||||
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(
|
||||
success=False,
|
||||
status="not_found",
|
||||
|
|
@ -458,11 +460,11 @@ async def download_song(
|
|||
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 상태인 경우
|
||||
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(
|
||||
success=True,
|
||||
status="processing",
|
||||
|
|
@ -472,7 +474,7 @@ async def download_song(
|
|||
|
||||
# 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(
|
||||
success=False,
|
||||
status="failed",
|
||||
|
|
@ -487,7 +489,7 @@ async def download_song(
|
|||
)
|
||||
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(
|
||||
success=True,
|
||||
status="completed",
|
||||
|
|
@ -502,7 +504,7 @@ async def download_song(
|
|||
)
|
||||
|
||||
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(
|
||||
success=False,
|
||||
status="error",
|
||||
|
|
@ -550,7 +552,7 @@ async def get_songs(
|
|||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
) -> 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:
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
|
||||
|
|
@ -622,14 +624,14 @@ async def get_songs(
|
|||
page_size=pagination.page_size,
|
||||
)
|
||||
|
||||
print(
|
||||
logger.info(
|
||||
f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, "
|
||||
f"page_size: {pagination.page_size}, items_count: {len(items)}"
|
||||
)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
print(f"[get_songs] EXCEPTION - error: {e}")
|
||||
logger.error(f"[get_songs] EXCEPTION - error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"노래 목록 조회에 실패했습니다: {str(e)}",
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
@ -6,6 +6,7 @@ from fastapi.exceptions import HTTPException
|
|||
from sqlalchemy import Connection, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.lyrics.schemas.lyrics_schema import (
|
||||
AttributeData,
|
||||
PromptTemplateData,
|
||||
|
|
@ -15,6 +16,8 @@ from app.lyrics.schemas.lyrics_schema import (
|
|||
)
|
||||
from app.utils.chatgpt_prompt import chatgpt_api
|
||||
|
||||
logger = get_logger("song")
|
||||
|
||||
|
||||
async def get_store_info(conn: Connection) -> List[StoreData]:
|
||||
try:
|
||||
|
|
@ -38,13 +41,13 @@ async def get_store_info(conn: Connection) -> List[StoreData]:
|
|||
result.close()
|
||||
return all_store_info
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemyError in get_store_info: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_store_info: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -69,13 +72,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
|
|||
result.close()
|
||||
return all_attribute
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemyError in get_attribute: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_attribute: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -100,13 +103,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
|
|||
result.close()
|
||||
return all_attribute
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemyError in get_attribute (duplicate): {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_attribute (duplicate): {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -132,13 +135,13 @@ async def get_sample_song(conn: Connection) -> List[SongSampleData]:
|
|||
result.close()
|
||||
return all_sample_song
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemyError in get_sample_song: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_sample_song: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -162,13 +165,13 @@ async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]:
|
|||
result.close()
|
||||
return all_prompt_template
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemyError in get_prompt_template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_prompt_template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -192,13 +195,13 @@ async def get_song_result(conn: Connection) -> List[PromptTemplateData]:
|
|||
result.close()
|
||||
return all_prompt_template
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemyError in get_song_result (prompt_template): {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_song_result (prompt_template): {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -210,11 +213,11 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
# 1. Form 데이터 파싱
|
||||
form_data = await SongFormData.from_form(request)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Store ID: {form_data.store_id}")
|
||||
print(f"Lyrics IDs: {form_data.lyrics_ids}")
|
||||
print(f"Prompt IDs: {form_data.prompts}")
|
||||
print(f"{'=' * 60}\n")
|
||||
logger.info(f"{'=' * 60}")
|
||||
logger.info(f"Store ID: {form_data.store_id}")
|
||||
logger.info(f"Lyrics IDs: {form_data.lyrics_ids}")
|
||||
logger.info(f"Prompt IDs: {form_data.prompts}")
|
||||
logger.info(f"{'=' * 60}")
|
||||
|
||||
# 2. Store 정보 조회
|
||||
store_query = """
|
||||
|
|
@ -243,7 +246,7 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
)
|
||||
|
||||
store_info = all_store_info[0]
|
||||
print(f"Store: {store_info.store_name}")
|
||||
logger.info(f"Store: {store_info.store_name}")
|
||||
|
||||
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
||||
|
||||
|
|
@ -251,7 +254,7 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
combined_sample_song = None
|
||||
|
||||
if form_data.lyrics_ids:
|
||||
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||
|
||||
lyrics_query = """
|
||||
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(
|
||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||
)
|
||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
else:
|
||||
print("샘플 가사가 비어있습니다")
|
||||
logger.info("샘플 가사가 비어있습니다")
|
||||
else:
|
||||
print("선택된 lyrics가 없습니다")
|
||||
logger.info("선택된 lyrics가 없습니다")
|
||||
|
||||
# 5. 템플릿 가져오기
|
||||
if not form_data.prompts:
|
||||
|
|
@ -283,7 +286,7 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
detail="프롬프트 ID가 필요합니다",
|
||||
)
|
||||
|
||||
print("템플릿 가져오기")
|
||||
logger.info("템플릿 가져오기")
|
||||
|
||||
prompts_query = """
|
||||
SELECT * FROM prompt_template WHERE id=:id;
|
||||
|
|
@ -310,7 +313,7 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
)
|
||||
|
||||
prompt = prompts_info[0]
|
||||
print(f"Prompt Template: {prompt.prompt}")
|
||||
logger.debug(f"Prompt Template: {prompt.prompt}")
|
||||
|
||||
# ✅ 6. 프롬프트 조합
|
||||
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}
|
||||
"""
|
||||
|
||||
print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n")
|
||||
logger.debug(f"[업데이트된 프롬프트]\n{updated_prompt}")
|
||||
|
||||
# 7. 모델에게 요청
|
||||
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_without_space}자\r\n\r\n{generated_lyrics}"""
|
||||
|
||||
print("=" * 40)
|
||||
print("[translate:form_data.attributes_str:] ", form_data.attributes_str)
|
||||
print("[translate:total_chars_with_space:] ", total_chars_with_space)
|
||||
print("[translate:total_chars_without_space:] ", total_chars_without_space)
|
||||
print("[translate:final_lyrics:]")
|
||||
print(final_lyrics)
|
||||
print("=" * 40)
|
||||
logger.debug("=" * 40)
|
||||
logger.debug(f"[translate:form_data.attributes_str:] {form_data.attributes_str}")
|
||||
logger.debug(f"[translate:total_chars_with_space:] {total_chars_with_space}")
|
||||
logger.debug(f"[translate:total_chars_without_space:] {total_chars_without_space}")
|
||||
logger.debug(f"[translate:final_lyrics:]\n{final_lyrics}")
|
||||
logger.debug("=" * 40)
|
||||
|
||||
# 8. DB 저장
|
||||
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.commit()
|
||||
|
||||
print("결과 저장 완료")
|
||||
logger.info("make_song_result 결과 저장 완료")
|
||||
|
||||
print("\n전체 결과 조회 중...")
|
||||
logger.info("make_song_result 전체 결과 조회 중...")
|
||||
|
||||
# 9. 생성 결과 가져오기 (created_at 역순)
|
||||
select_query = """
|
||||
|
|
@ -430,26 +432,20 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
for row in all_results.fetchall()
|
||||
]
|
||||
|
||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
||||
logger.info(f"make_song_result 전체 {len(results_list)}개의 결과 조회 완료")
|
||||
|
||||
return results_list
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
print(f"Database Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"make_song_result Database Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Unexpected Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"make_song_result Unexpected Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
|
|
@ -490,25 +486,19 @@ async def get_song_result(conn: Connection): # 반환 타입 수정
|
|||
for row in all_results.fetchall()
|
||||
]
|
||||
|
||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
||||
logger.info(f"get_song_result 전체 {len(results_list)}개의 결과 조회 완료")
|
||||
|
||||
return results_list
|
||||
except HTTPException: # HTTPException은 그대로 raise
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
print(f"Database Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"get_song_result Database Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Unexpected Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"get_song_result Unexpected Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
|
|
@ -520,9 +510,9 @@ async def make_automation(request: Request, conn: Connection):
|
|||
# 1. Form 데이터 파싱
|
||||
form_data = await SongFormData.from_form(request)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Store ID: {form_data.store_id}")
|
||||
print(f"{'=' * 60}\n")
|
||||
logger.info(f"{'=' * 60}")
|
||||
logger.info(f"make_automation Store ID: {form_data.store_id}")
|
||||
logger.info(f"{'=' * 60}")
|
||||
|
||||
# 2. Store 정보 조회
|
||||
store_query = """
|
||||
|
|
@ -551,7 +541,7 @@ async def make_automation(request: Request, conn: Connection):
|
|||
)
|
||||
|
||||
store_info = all_store_info[0]
|
||||
print(f"Store: {store_info.store_name}")
|
||||
logger.info(f"make_automation Store: {store_info.store_name}")
|
||||
|
||||
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
||||
attribute_query = """
|
||||
|
|
@ -596,13 +586,13 @@ async def make_automation(request: Request, conn: Connection):
|
|||
# 최종 문자열 생성
|
||||
formatted_attributes = "\n".join(formatted_pairs)
|
||||
|
||||
print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n")
|
||||
logger.debug(f"[포맷팅된 문자열 속성 정보]\n{formatted_attributes}")
|
||||
else:
|
||||
print("속성 데이터가 없습니다")
|
||||
logger.info("속성 데이터가 없습니다")
|
||||
formatted_attributes = ""
|
||||
|
||||
# 4. 템플릿 가져오기
|
||||
print("템플릿 가져오기 (ID=1)")
|
||||
logger.info("템플릿 가져오기 (ID=1)")
|
||||
|
||||
prompts_query = """
|
||||
SELECT * FROM prompt_template WHERE id=1;
|
||||
|
|
@ -624,7 +614,7 @@ async def make_automation(request: Request, conn: Connection):
|
|||
prompt=row[2],
|
||||
)
|
||||
|
||||
print(f"Prompt Template: {prompt.prompt}")
|
||||
logger.debug(f"Prompt Template: {prompt.prompt}")
|
||||
|
||||
# 5. 템플릿 조합
|
||||
|
||||
|
|
@ -635,17 +625,17 @@ async def make_automation(request: Request, conn: Connection):
|
|||
description=store_info.store_info or "",
|
||||
)
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("업데이트된 프롬프트")
|
||||
print("=" * 80)
|
||||
print(updated_prompt)
|
||||
print("=" * 80 + "\n")
|
||||
logger.debug("=" * 80)
|
||||
logger.debug("업데이트된 프롬프트")
|
||||
logger.debug("=" * 80)
|
||||
logger.debug(updated_prompt)
|
||||
logger.debug("=" * 80)
|
||||
|
||||
# 4. Sample Song 조회 및 결합
|
||||
combined_sample_song = None
|
||||
|
||||
if form_data.lyrics_ids:
|
||||
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||
|
||||
lyrics_query = """
|
||||
SELECT sample_song FROM song_sample
|
||||
|
|
@ -664,14 +654,14 @@ async def make_automation(request: Request, conn: Connection):
|
|||
combined_sample_song = "\n\n".join(
|
||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||
)
|
||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
else:
|
||||
print("샘플 가사가 비어있습니다")
|
||||
logger.info("샘플 가사가 비어있습니다")
|
||||
else:
|
||||
print("선택된 lyrics가 없습니다")
|
||||
logger.info("선택된 lyrics가 없습니다")
|
||||
|
||||
# 1. song_sample 테이블의 모든 ID 조회
|
||||
print("\n[샘플 가사 랜덤 선택]")
|
||||
logger.info("[샘플 가사 랜덤 선택]")
|
||||
|
||||
all_ids_query = """
|
||||
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))
|
||||
all_ids = [row.id for row in ids_result.fetchall()]
|
||||
|
||||
print(f"전체 샘플 가사 개수: {len(all_ids)}개")
|
||||
logger.info(f"전체 샘플 가사 개수: {len(all_ids)}개")
|
||||
|
||||
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
|
||||
combined_sample_song = None
|
||||
|
|
@ -689,7 +679,7 @@ async def make_automation(request: Request, conn: Connection):
|
|||
sample_count = min(3, len(all_ids))
|
||||
selected_ids = random.sample(all_ids, sample_count)
|
||||
|
||||
print(f"랜덤 선택된 ID: {selected_ids}")
|
||||
logger.debug(f"랜덤 선택된 ID: {selected_ids}")
|
||||
|
||||
# 3. 선택된 ID로 샘플 가사 조회
|
||||
lyrics_query = """
|
||||
|
|
@ -710,11 +700,11 @@ async def make_automation(request: Request, conn: Connection):
|
|||
combined_sample_song = "\n\n".join(
|
||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||
)
|
||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
else:
|
||||
print("샘플 가사가 비어있습니다")
|
||||
logger.info("샘플 가사가 비어있습니다")
|
||||
else:
|
||||
print("song_sample 테이블에 데이터가 없습니다")
|
||||
logger.info("song_sample 테이블에 데이터가 없습니다")
|
||||
|
||||
# 5. 프롬프트에 샘플 가사 추가
|
||||
if combined_sample_song:
|
||||
|
|
@ -726,11 +716,11 @@ async def make_automation(request: Request, conn: Connection):
|
|||
|
||||
{combined_sample_song}
|
||||
"""
|
||||
print("샘플 가사 정보가 프롬프트에 추가되었습니다")
|
||||
logger.info("샘플 가사 정보가 프롬프트에 추가되었습니다")
|
||||
else:
|
||||
print("샘플 가사가 없어 기본 프롬프트만 사용합니다")
|
||||
logger.info("샘플 가사가 없어 기본 프롬프트만 사용합니다")
|
||||
|
||||
print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n")
|
||||
logger.info(f"[최종 프롬프트 길이: {len(updated_prompt)} 자]")
|
||||
|
||||
# 7. 모델에게 요청
|
||||
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()
|
||||
);
|
||||
"""
|
||||
print("\n[insert_params 선택된 속성 확인]")
|
||||
print(f"Categories: {selected_categories}")
|
||||
print(f"Values: {selected_values}")
|
||||
print()
|
||||
logger.debug("[insert_params 선택된 속성 확인]")
|
||||
logger.debug(f"Categories: {selected_categories}")
|
||||
logger.debug(f"Values: {selected_values}")
|
||||
|
||||
# attr_category, attr_value
|
||||
insert_params = {
|
||||
|
|
@ -792,9 +781,9 @@ async def make_automation(request: Request, conn: Connection):
|
|||
await conn.execute(text(insert_query), insert_params)
|
||||
await conn.commit()
|
||||
|
||||
print("결과 저장 완료")
|
||||
logger.info("make_automation 결과 저장 완료")
|
||||
|
||||
print("\n전체 결과 조회 중...")
|
||||
logger.info("make_automation 전체 결과 조회 중...")
|
||||
|
||||
# 9. 생성 결과 가져오기 (created_at 역순)
|
||||
select_query = """
|
||||
|
|
@ -826,26 +815,20 @@ async def make_automation(request: Request, conn: Connection):
|
|||
for row in all_results.fetchall()
|
||||
]
|
||||
|
||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
||||
logger.info(f"make_automation 전체 {len(results_list)}개의 결과 조회 완료")
|
||||
|
||||
return results_list
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
print(f"Database Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"make_automation Database Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Unexpected Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"make_automation Unexpected Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ Song Background Tasks
|
|||
노래 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
|
@ -17,11 +16,12 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||
from app.database.session import BackgroundSessionLocal
|
||||
from app.song.models import Song
|
||||
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 config import prj_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger("song")
|
||||
|
||||
# HTTP 요청 설정
|
||||
REQUEST_TIMEOUT = 120.0 # 초
|
||||
|
|
@ -73,20 +73,16 @@ async def _update_song_status(
|
|||
song.duration = duration
|
||||
await session.commit()
|
||||
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
|
||||
else:
|
||||
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
|
||||
|
||||
except SQLAlchemyError as 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
|
||||
except Exception as 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
|
||||
|
||||
|
||||
|
|
@ -104,14 +100,12 @@ async def _download_audio(url: str, task_id: str) -> bytes:
|
|||
httpx.HTTPError: 다운로드 실패 시
|
||||
"""
|
||||
logger.info(f"[Download] Downloading - task_id: {task_id}")
|
||||
print(f"[Download] Downloading - task_id: {task_id}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -128,7 +122,6 @@ async def download_and_save_song(
|
|||
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:
|
||||
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
|
||||
|
|
@ -146,11 +139,9 @@ async def download_and_save_song(
|
|||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = media_dir / file_name
|
||||
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}")
|
||||
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
|
||||
|
||||
content = await _download_audio(audio_url, task_id)
|
||||
|
||||
|
|
@ -158,36 +149,27 @@ async def download_and_save_song(
|
|||
await f.write(content)
|
||||
|
||||
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 생성
|
||||
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
|
||||
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
|
||||
file_url = f"{base_url}{relative_path}"
|
||||
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 테이블 업데이트
|
||||
await _update_song_status(task_id, "completed", file_url)
|
||||
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:
|
||||
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
|
||||
|
|
@ -204,7 +186,6 @@ async def download_and_upload_song_to_blob(
|
|||
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
|
||||
|
||||
try:
|
||||
|
|
@ -220,11 +201,9 @@ async def download_and_upload_song_to_blob(
|
|||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file_path = temp_dir / file_name
|
||||
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}")
|
||||
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)
|
||||
|
||||
|
|
@ -232,7 +211,6 @@ async def download_and_upload_song_to_blob(
|
|||
await f.write(content)
|
||||
|
||||
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에 업로드
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
|
|
@ -244,29 +222,21 @@ async def download_and_upload_song_to_blob(
|
|||
# SAS 토큰이 제외된 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}")
|
||||
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
|
||||
# Song 테이블 업데이트
|
||||
await _update_song_status(task_id, "completed", blob_url)
|
||||
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:
|
||||
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_song_status(task_id, "failed")
|
||||
|
||||
finally:
|
||||
|
|
@ -275,10 +245,8 @@ async def download_and_upload_song_to_blob(
|
|||
try:
|
||||
temp_file_path.unlink()
|
||||
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:
|
||||
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
|
||||
|
|
@ -304,7 +272,6 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
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
|
||||
task_id: str | None = None
|
||||
|
||||
|
|
@ -321,12 +288,10 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
|
||||
if not song:
|
||||
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
|
||||
|
||||
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}")
|
||||
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(
|
||||
|
|
@ -340,11 +305,9 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
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}")
|
||||
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}")
|
||||
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)
|
||||
|
||||
|
|
@ -352,7 +315,6 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
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}")
|
||||
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에 업로드
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
|
|
@ -364,7 +326,6 @@ async def download_and_upload_song_by_suno_task_id(
|
|||
# SAS 토큰이 제외된 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}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
|
||||
|
||||
# Song 테이블 업데이트
|
||||
await _update_song_status(
|
||||
|
|
@ -375,26 +336,19 @@ async def download_and_upload_song_by_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:
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
|
||||
if task_id:
|
||||
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||
|
||||
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}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
|
||||
if task_id:
|
||||
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
||||
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
|
||||
if 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:
|
||||
temp_file_path.unlink()
|
||||
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:
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
User API v1 라우터 모듈
|
||||
"""
|
||||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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,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,
|
||||
)
|
||||
|
|
@ -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")>"
|
||||
)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import apikey_settings
|
||||
from app.utils.prompts.prompts import Prompt
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger("chatgpt")
|
||||
|
||||
# fmt: on
|
||||
|
||||
|
|
@ -44,5 +44,4 @@ class ChatgptService:
|
|||
|
||||
# GPT API 호출
|
||||
response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -30,16 +30,16 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
|
|||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
import time
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import apikey_settings, creatomate_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger("creatomate")
|
||||
|
||||
|
||||
# Orientation 타입 정의
|
||||
|
|
@ -76,14 +76,14 @@ async def close_shared_client() -> None:
|
|||
if _shared_client is not None and not _shared_client.is_closed:
|
||||
await _shared_client.aclose()
|
||||
_shared_client = None
|
||||
print("[CreatomateService] Shared HTTP client closed")
|
||||
logger.info("[CreatomateService] Shared HTTP client closed")
|
||||
|
||||
|
||||
def clear_template_cache() -> None:
|
||||
"""템플릿 캐시를 전체 삭제합니다."""
|
||||
global _template_cache
|
||||
_template_cache.clear()
|
||||
print("[CreatomateService] Template cache cleared")
|
||||
logger.info("[CreatomateService] Template cache cleared")
|
||||
|
||||
|
||||
def _is_cache_valid(cached_at: float) -> bool:
|
||||
|
|
@ -164,7 +164,6 @@ class CreatomateService:
|
|||
httpx.HTTPError: 요청 실패 시
|
||||
"""
|
||||
logger.info(f"[Creatomate] {method} {url}")
|
||||
print(f"[Creatomate] {method} {url}")
|
||||
|
||||
client = await get_shared_client()
|
||||
|
||||
|
|
@ -180,7 +179,6 @@ class CreatomateService:
|
|||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
logger.info(f"[Creatomate] Response - Status: {response.status_code}")
|
||||
print(f"[Creatomate] Response - Status: {response.status_code}")
|
||||
return response
|
||||
|
||||
async def get_all_templates_data(self) -> dict:
|
||||
|
|
@ -210,12 +208,12 @@ class CreatomateService:
|
|||
if use_cache and template_id in _template_cache:
|
||||
cached = _template_cache[template_id]
|
||||
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"])
|
||||
else:
|
||||
# 만료된 캐시 삭제
|
||||
del _template_cache[template_id]
|
||||
print(f"[CreatomateService] Cache EXPIRED - {template_id}")
|
||||
logger.debug(f"[CreatomateService] Cache EXPIRED - {template_id}")
|
||||
|
||||
# API 호출
|
||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||
|
|
@ -228,7 +226,7 @@ class CreatomateService:
|
|||
"data": data,
|
||||
"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)
|
||||
|
||||
|
|
@ -444,12 +442,13 @@ class CreatomateService:
|
|||
if animation["transition"]:
|
||||
total_template_duration -= animation["duration"]
|
||||
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
|
||||
|
||||
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||
target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
||||
template["duration"] = target_duration
|
||||
total_template_duration = self.calc_scene_duration(template)
|
||||
extend_rate = target_duration / total_template_duration
|
||||
|
|
@ -466,7 +465,7 @@ class CreatomateService:
|
|||
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||
animation["duration"] = animation["duration"] * extend_rate
|
||||
except Exception as e:
|
||||
print(
|
||||
logger.error(
|
||||
f"[extend_template_duration] Error processing element: {elem}, {e}"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import aiohttp
|
||||
import bs4
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import crawler_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger("scraper")
|
||||
|
||||
|
||||
class GraphQLException(Exception):
|
||||
|
|
@ -109,7 +109,7 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||
self.scrap_type = "GraphQL"
|
||||
|
||||
except GraphQLException:
|
||||
print("fallback")
|
||||
logger.debug("GraphQL failed, fallback to Playwright")
|
||||
self.scrap_type = "Playwright"
|
||||
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
||||
|
||||
|
|
@ -138,7 +138,6 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||
|
||||
try:
|
||||
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 session.post(
|
||||
|
|
@ -148,24 +147,20 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||
) as response:
|
||||
if response.status == 200:
|
||||
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
||||
print(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
||||
return await response.json()
|
||||
|
||||
# 실패 상태 코드
|
||||
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
|
||||
print(f"[NvMapScraper] 실패 상태 코드: {response.status}")
|
||||
raise GraphQLException(
|
||||
f"Request failed with status {response.status}"
|
||||
)
|
||||
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
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")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"[NvMapScraper] Client error: {e}")
|
||||
print(f"[NvMapScraper] Client error: {e}")
|
||||
raise GraphQLException(f"Client error: {e}")
|
||||
|
||||
async def _get_facility_string(self, place_id: str) -> str | None:
|
||||
|
|
|
|||
|
|
@ -32,17 +32,17 @@ URL 경로 형식:
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import httpx
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import azure_blob_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger("blob")
|
||||
|
||||
# =============================================================================
|
||||
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
||||
|
|
@ -56,12 +56,12 @@ async def get_shared_blob_client() -> httpx.AsyncClient:
|
|||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||
global _shared_blob_client
|
||||
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(
|
||||
timeout=httpx.Timeout(180.0, connect=10.0),
|
||||
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")
|
||||
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:
|
||||
await _shared_blob_client.aclose()
|
||||
_shared_blob_client = None
|
||||
print("[AzureBlobUploader] Shared HTTP client closed")
|
||||
logger.info("[AzureBlobUploader] Shared HTTP client closed")
|
||||
|
||||
|
||||
class AzureBlobUploader:
|
||||
|
|
@ -158,14 +158,14 @@ class AzureBlobUploader:
|
|||
|
||||
try:
|
||||
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_time = time.perf_counter()
|
||||
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)")
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
|
|
@ -176,43 +176,37 @@ class AzureBlobUploader:
|
|||
duration_ms = (upload_time - start_time) * 1000
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}")
|
||||
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
||||
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
||||
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
|
||||
|
||||
# 업로드 실패
|
||||
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}")
|
||||
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
||||
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
||||
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
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
||||
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
|
||||
return False
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}")
|
||||
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
||||
logger.error(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
except httpx.ReadError as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] READ_ERROR: {e}")
|
||||
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
||||
logger.error(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
elapsed = time.perf_counter() - start_time
|
||||
logger.error(f"[{log_prefix}] ERROR: {type(e).__name__}: {e}")
|
||||
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
||||
logger.error(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
|
|
@ -241,7 +235,7 @@ class AzureBlobUploader:
|
|||
|
||||
upload_url = self._build_upload_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"}
|
||||
|
||||
|
|
@ -306,7 +300,7 @@ class AzureBlobUploader:
|
|||
upload_url = self._build_upload_url("song", file_name)
|
||||
self._last_public_url = self._build_public_url("song", file_name)
|
||||
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"}
|
||||
|
||||
|
|
@ -368,7 +362,7 @@ class AzureBlobUploader:
|
|||
upload_url = self._build_upload_url("video", file_name)
|
||||
self._last_public_url = self._build_public_url("video", file_name)
|
||||
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"}
|
||||
|
||||
|
|
@ -434,7 +428,7 @@ class AzureBlobUploader:
|
|||
upload_url = self._build_upload_url("image", file_name)
|
||||
self._last_public_url = self._build_public_url("image", file_name)
|
||||
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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Video API v1 라우터 모듈
|
||||
"""
|
||||
|
|
@ -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.utils.creatomate import CreatomateService
|
||||
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(
|
||||
|
|
@ -115,7 +117,7 @@ async def generate_video(
|
|||
from app.database.session import AsyncSessionLocal
|
||||
|
||||
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 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음)
|
||||
|
|
@ -168,13 +170,13 @@ async def generate_video(
|
|||
)
|
||||
|
||||
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")
|
||||
|
||||
# ===== 결과 처리: Project =====
|
||||
project = project_result.scalar_one_or_none()
|
||||
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(
|
||||
status_code=404,
|
||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||
|
|
@ -184,7 +186,7 @@ async def generate_video(
|
|||
# ===== 결과 처리: Lyric =====
|
||||
lyric = lyric_result.scalar_one_or_none()
|
||||
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(
|
||||
status_code=404,
|
||||
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
|
||||
|
|
@ -194,7 +196,7 @@ async def generate_video(
|
|||
# ===== 결과 처리: Song =====
|
||||
song = song_result.scalar_one_or_none()
|
||||
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(
|
||||
status_code=404,
|
||||
detail=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.",
|
||||
|
|
@ -220,14 +222,14 @@ async def generate_video(
|
|||
# ===== 결과 처리: Image =====
|
||||
images = image_result.scalars().all()
|
||||
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(
|
||||
status_code=404,
|
||||
detail=f"task_id '{task_id}'에 해당하는 이미지를 찾을 수 없습니다.",
|
||||
)
|
||||
image_urls = [img.img_url for img in images]
|
||||
|
||||
print(
|
||||
logger.info(
|
||||
f"[generate_video] Data loaded - task_id: {task_id}, "
|
||||
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
||||
f"song_id: {song_id}, images: {len(image_urls)}"
|
||||
|
|
@ -246,14 +248,14 @@ async def generate_video(
|
|||
await session.commit()
|
||||
video_id = video.id
|
||||
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")
|
||||
# 세션이 여기서 자동으로 닫힘 (async with 블록 종료)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
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(
|
||||
success=False,
|
||||
task_id=task_id,
|
||||
|
|
@ -267,16 +269,16 @@ async def generate_video(
|
|||
# ==========================================================================
|
||||
stage2_start = time.perf_counter()
|
||||
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(
|
||||
orientation=orientation,
|
||||
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. 템플릿 조회 (비동기)
|
||||
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에서 리소스 매핑 생성
|
||||
modifications = creatomate_service.elements_connect_resource_blackbox(
|
||||
|
|
@ -285,7 +287,7 @@ async def generate_video(
|
|||
lyric=lyrics,
|
||||
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 수정
|
||||
new_elements = creatomate_service.modify_element(
|
||||
|
|
@ -293,20 +295,20 @@ async def generate_video(
|
|||
modifications,
|
||||
)
|
||||
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 확장
|
||||
final_template = creatomate_service.extend_template_duration(
|
||||
template,
|
||||
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. 커스텀 렌더링 요청 (비동기)
|
||||
render_response = await creatomate_service.make_creatomate_custom_call_async(
|
||||
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 추출
|
||||
if isinstance(render_response, list) and len(render_response) > 0:
|
||||
|
|
@ -317,14 +319,14 @@ async def generate_video(
|
|||
creatomate_render_id = None
|
||||
|
||||
stage2_time = time.perf_counter()
|
||||
print(
|
||||
logger.info(
|
||||
f"[generate_video] Stage 2 DONE - task_id: {task_id}, "
|
||||
f"render_id: {creatomate_render_id}, "
|
||||
f"stage2_elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
|
||||
)
|
||||
|
||||
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로 업데이트
|
||||
from app.database.session import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as update_session:
|
||||
|
|
@ -347,7 +349,7 @@ async def generate_video(
|
|||
# 3단계: creatomate_render_id 업데이트 (새 세션으로 빠르게 처리)
|
||||
# ==========================================================================
|
||||
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:
|
||||
from app.database.session import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as update_session:
|
||||
|
|
@ -361,11 +363,11 @@ async def generate_video(
|
|||
|
||||
stage3_time = time.perf_counter()
|
||||
total_time = stage3_time - request_start
|
||||
print(
|
||||
logger.debug(
|
||||
f"[generate_video] Stage 3 DONE - task_id: {task_id}, "
|
||||
f"stage3_elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
|
||||
)
|
||||
print(
|
||||
logger.info(
|
||||
f"[generate_video] SUCCESS - task_id: {task_id}, "
|
||||
f"render_id: {creatomate_render_id}, "
|
||||
f"total_time: {total_time*1000:.1f}ms"
|
||||
|
|
@ -380,7 +382,7 @@ async def generate_video(
|
|||
)
|
||||
|
||||
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(
|
||||
success=False,
|
||||
task_id=task_id,
|
||||
|
|
@ -439,11 +441,11 @@ async def get_video_status(
|
|||
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고
|
||||
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:
|
||||
creatomate_service = CreatomateService()
|
||||
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")
|
||||
video_url = result.get("url")
|
||||
|
|
@ -481,7 +483,7 @@ async def get_video_status(
|
|||
store_name = project.store_name if project else "video"
|
||||
|
||||
# 백그라운드 태스크로 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(
|
||||
download_and_upload_video_to_blob,
|
||||
task_id=video.task_id,
|
||||
|
|
@ -489,7 +491,7 @@ async def get_video_status(
|
|||
store_name=store_name,
|
||||
)
|
||||
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(
|
||||
id=result.get("id"),
|
||||
|
|
@ -498,7 +500,7 @@ async def get_video_status(
|
|||
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(
|
||||
success=True,
|
||||
status=status,
|
||||
|
|
@ -511,7 +513,7 @@ async def get_video_status(
|
|||
except Exception as e:
|
||||
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(
|
||||
success=False,
|
||||
status="error",
|
||||
|
|
@ -563,7 +565,7 @@ async def download_video(
|
|||
session: AsyncSession = Depends(get_session),
|
||||
) -> DownloadVideoResponse:
|
||||
"""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:
|
||||
# task_id로 Video 조회 (여러 개 있을 경우 가장 최근 것 선택)
|
||||
video_result = await session.execute(
|
||||
|
|
@ -575,7 +577,7 @@ async def download_video(
|
|||
video = video_result.scalar_one_or_none()
|
||||
|
||||
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(
|
||||
success=False,
|
||||
status="not_found",
|
||||
|
|
@ -583,11 +585,11 @@ async def download_video(
|
|||
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 상태인 경우
|
||||
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(
|
||||
success=True,
|
||||
status="processing",
|
||||
|
|
@ -597,7 +599,7 @@ async def download_video(
|
|||
|
||||
# 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(
|
||||
success=False,
|
||||
status="failed",
|
||||
|
|
@ -612,7 +614,7 @@ async def download_video(
|
|||
)
|
||||
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(
|
||||
success=True,
|
||||
status="completed",
|
||||
|
|
@ -625,7 +627,7 @@ async def download_video(
|
|||
)
|
||||
|
||||
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(
|
||||
success=False,
|
||||
status="error",
|
||||
|
|
@ -674,7 +676,7 @@ async def get_videos(
|
|||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
) -> 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:
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
|
||||
|
|
@ -732,14 +734,14 @@ async def get_videos(
|
|||
page_size=pagination.page_size,
|
||||
)
|
||||
|
||||
print(
|
||||
logger.info(
|
||||
f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, "
|
||||
f"page_size: {pagination.page_size}, items_count: {len(items)}"
|
||||
)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
print(f"[get_videos] EXCEPTION - error: {e}")
|
||||
logger.error(f"[get_videos] EXCEPTION - error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
@ -14,6 +14,9 @@ from app.lyrics.schemas.lyrics_schema import (
|
|||
StoreData,
|
||||
)
|
||||
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]:
|
||||
|
|
@ -38,13 +41,13 @@ async def get_store_info(conn: Connection) -> List[StoreData]:
|
|||
result.close()
|
||||
return all_store_info
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemy error in get_store_info: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_store_info: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -69,13 +72,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
|
|||
result.close()
|
||||
return all_attribute
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemy error in get_attribute: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_attribute: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -100,13 +103,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
|
|||
result.close()
|
||||
return all_attribute
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemy error in get_attribute: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_attribute: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -132,13 +135,13 @@ async def get_sample_song(conn: Connection) -> List[SongSampleData]:
|
|||
result.close()
|
||||
return all_sample_song
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemy error in get_sample_song: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_sample_song: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -162,13 +165,13 @@ async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]:
|
|||
result.close()
|
||||
return all_prompt_template
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemy error in get_prompt_template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_prompt_template: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -192,13 +195,13 @@ async def get_song_result(conn: Connection) -> List[PromptTemplateData]:
|
|||
result.close()
|
||||
return all_prompt_template
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
logger.error(f"SQLAlchemy error in get_song_result: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
logger.error(f"Unexpected error in get_song_result: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
|
|
@ -210,11 +213,11 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
# 1. Form 데이터 파싱
|
||||
form_data = await SongFormData.from_form(request)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Store ID: {form_data.store_id}")
|
||||
print(f"Lyrics IDs: {form_data.lyrics_ids}")
|
||||
print(f"Prompt IDs: {form_data.prompts}")
|
||||
print(f"{'=' * 60}\n")
|
||||
logger.info(f"{'=' * 60}")
|
||||
logger.info(f"Store ID: {form_data.store_id}")
|
||||
logger.info(f"Lyrics IDs: {form_data.lyrics_ids}")
|
||||
logger.info(f"Prompt IDs: {form_data.prompts}")
|
||||
logger.info(f"{'=' * 60}")
|
||||
|
||||
# 2. Store 정보 조회
|
||||
store_query = """
|
||||
|
|
@ -243,7 +246,7 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
)
|
||||
|
||||
store_info = all_store_info[0]
|
||||
print(f"Store: {store_info.store_name}")
|
||||
logger.info(f"Store: {store_info.store_name}")
|
||||
|
||||
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
||||
|
||||
|
|
@ -251,7 +254,7 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
combined_sample_song = None
|
||||
|
||||
if form_data.lyrics_ids:
|
||||
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||
|
||||
lyrics_query = """
|
||||
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(
|
||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||
)
|
||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
else:
|
||||
print("샘플 가사가 비어있습니다")
|
||||
logger.info("샘플 가사가 비어있습니다")
|
||||
else:
|
||||
print("선택된 lyrics가 없습니다")
|
||||
logger.info("선택된 lyrics가 없습니다")
|
||||
|
||||
# 5. 템플릿 가져오기
|
||||
if not form_data.prompts:
|
||||
|
|
@ -283,7 +286,7 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
detail="프롬프트 ID가 필요합니다",
|
||||
)
|
||||
|
||||
print("템플릿 가져오기")
|
||||
logger.info("템플릿 가져오기")
|
||||
|
||||
prompts_query = """
|
||||
SELECT * FROM prompt_template WHERE id=:id;
|
||||
|
|
@ -310,7 +313,7 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
)
|
||||
|
||||
prompt = prompts_info[0]
|
||||
print(f"Prompt Template: {prompt.prompt}")
|
||||
logger.debug(f"Prompt Template: {prompt.prompt}")
|
||||
|
||||
# ✅ 6. 프롬프트 조합
|
||||
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}
|
||||
"""
|
||||
|
||||
print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n")
|
||||
logger.debug(f"[업데이트된 프롬프트]\n{updated_prompt}")
|
||||
|
||||
# 7. 모델에게 요청
|
||||
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_without_space}자\r\n\r\n{generated_lyrics}"""
|
||||
|
||||
print("=" * 40)
|
||||
print("[translate:form_data.attributes_str:] ", form_data.attributes_str)
|
||||
print("[translate:total_chars_with_space:] ", total_chars_with_space)
|
||||
print("[translate:total_chars_without_space:] ", total_chars_without_space)
|
||||
print("[translate:final_lyrics:]")
|
||||
print(final_lyrics)
|
||||
print("=" * 40)
|
||||
logger.debug("=" * 40)
|
||||
logger.debug(f"[translate:form_data.attributes_str:] {form_data.attributes_str}")
|
||||
logger.debug(f"[translate:total_chars_with_space:] {total_chars_with_space}")
|
||||
logger.debug(f"[translate:total_chars_without_space:] {total_chars_without_space}")
|
||||
logger.debug(f"[translate:final_lyrics:]\n{final_lyrics}")
|
||||
logger.debug("=" * 40)
|
||||
|
||||
# 8. DB 저장
|
||||
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.commit()
|
||||
|
||||
print("결과 저장 완료")
|
||||
logger.info("결과 저장 완료")
|
||||
|
||||
print("\n전체 결과 조회 중...")
|
||||
logger.info("전체 결과 조회 중...")
|
||||
|
||||
# 9. 생성 결과 가져오기 (created_at 역순)
|
||||
select_query = """
|
||||
|
|
@ -430,26 +432,20 @@ async def make_song_result(request: Request, conn: Connection):
|
|||
for row in all_results.fetchall()
|
||||
]
|
||||
|
||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
||||
logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
|
||||
|
||||
return results_list
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
print(f"Database Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"Database Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Unexpected Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"Unexpected Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
|
|
@ -490,25 +486,19 @@ async def get_song_result(conn: Connection): # 반환 타입 수정
|
|||
for row in all_results.fetchall()
|
||||
]
|
||||
|
||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
||||
logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
|
||||
|
||||
return results_list
|
||||
except HTTPException: # HTTPException은 그대로 raise
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
print(f"Database Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"Database Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Unexpected Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"Unexpected Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
|
|
@ -520,9 +510,9 @@ async def make_automation(request: Request, conn: Connection):
|
|||
# 1. Form 데이터 파싱
|
||||
form_data = await SongFormData.from_form(request)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Store ID: {form_data.store_id}")
|
||||
print(f"{'=' * 60}\n")
|
||||
logger.info(f"{'=' * 60}")
|
||||
logger.info(f"Store ID: {form_data.store_id}")
|
||||
logger.info(f"{'=' * 60}")
|
||||
|
||||
# 2. Store 정보 조회
|
||||
store_query = """
|
||||
|
|
@ -551,7 +541,7 @@ async def make_automation(request: Request, conn: Connection):
|
|||
)
|
||||
|
||||
store_info = all_store_info[0]
|
||||
print(f"Store: {store_info.store_name}")
|
||||
logger.info(f"Store: {store_info.store_name}")
|
||||
|
||||
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
||||
attribute_query = """
|
||||
|
|
@ -596,13 +586,13 @@ async def make_automation(request: Request, conn: Connection):
|
|||
# 최종 문자열 생성
|
||||
formatted_attributes = "\n".join(formatted_pairs)
|
||||
|
||||
print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n")
|
||||
logger.debug(f"[포맷팅된 문자열 속성 정보]\n{formatted_attributes}")
|
||||
else:
|
||||
print("속성 데이터가 없습니다")
|
||||
logger.info("속성 데이터가 없습니다")
|
||||
formatted_attributes = ""
|
||||
|
||||
# 4. 템플릿 가져오기
|
||||
print("템플릿 가져오기 (ID=1)")
|
||||
logger.info("템플릿 가져오기 (ID=1)")
|
||||
|
||||
prompts_query = """
|
||||
SELECT * FROM prompt_template WHERE id=1;
|
||||
|
|
@ -624,7 +614,7 @@ async def make_automation(request: Request, conn: Connection):
|
|||
prompt=row[2],
|
||||
)
|
||||
|
||||
print(f"Prompt Template: {prompt.prompt}")
|
||||
logger.debug(f"Prompt Template: {prompt.prompt}")
|
||||
|
||||
# 5. 템플릿 조합
|
||||
|
||||
|
|
@ -635,17 +625,17 @@ async def make_automation(request: Request, conn: Connection):
|
|||
description=store_info.store_info or "",
|
||||
)
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("업데이트된 프롬프트")
|
||||
print("=" * 80)
|
||||
print(updated_prompt)
|
||||
print("=" * 80 + "\n")
|
||||
logger.debug("=" * 80)
|
||||
logger.debug("업데이트된 프롬프트")
|
||||
logger.debug("=" * 80)
|
||||
logger.debug(updated_prompt)
|
||||
logger.debug("=" * 80)
|
||||
|
||||
# 4. Sample Song 조회 및 결합
|
||||
combined_sample_song = None
|
||||
|
||||
if form_data.lyrics_ids:
|
||||
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||
|
||||
lyrics_query = """
|
||||
SELECT sample_song FROM song_sample
|
||||
|
|
@ -664,14 +654,14 @@ async def make_automation(request: Request, conn: Connection):
|
|||
combined_sample_song = "\n\n".join(
|
||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||
)
|
||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
else:
|
||||
print("샘플 가사가 비어있습니다")
|
||||
logger.info("샘플 가사가 비어있습니다")
|
||||
else:
|
||||
print("선택된 lyrics가 없습니다")
|
||||
logger.info("선택된 lyrics가 없습니다")
|
||||
|
||||
# 1. song_sample 테이블의 모든 ID 조회
|
||||
print("\n[샘플 가사 랜덤 선택]")
|
||||
logger.info("[샘플 가사 랜덤 선택]")
|
||||
|
||||
all_ids_query = """
|
||||
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))
|
||||
all_ids = [row.id for row in ids_result.fetchall()]
|
||||
|
||||
print(f"전체 샘플 가사 개수: {len(all_ids)}개")
|
||||
logger.info(f"전체 샘플 가사 개수: {len(all_ids)}개")
|
||||
|
||||
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
|
||||
combined_sample_song = None
|
||||
|
|
@ -689,7 +679,7 @@ async def make_automation(request: Request, conn: Connection):
|
|||
sample_count = min(3, len(all_ids))
|
||||
selected_ids = random.sample(all_ids, sample_count)
|
||||
|
||||
print(f"랜덤 선택된 ID: {selected_ids}")
|
||||
logger.info(f"랜덤 선택된 ID: {selected_ids}")
|
||||
|
||||
# 3. 선택된 ID로 샘플 가사 조회
|
||||
lyrics_query = """
|
||||
|
|
@ -710,11 +700,11 @@ async def make_automation(request: Request, conn: Connection):
|
|||
combined_sample_song = "\n\n".join(
|
||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||
)
|
||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
else:
|
||||
print("샘플 가사가 비어있습니다")
|
||||
logger.info("샘플 가사가 비어있습니다")
|
||||
else:
|
||||
print("song_sample 테이블에 데이터가 없습니다")
|
||||
logger.info("song_sample 테이블에 데이터가 없습니다")
|
||||
|
||||
# 5. 프롬프트에 샘플 가사 추가
|
||||
if combined_sample_song:
|
||||
|
|
@ -726,11 +716,11 @@ async def make_automation(request: Request, conn: Connection):
|
|||
|
||||
{combined_sample_song}
|
||||
"""
|
||||
print("샘플 가사 정보가 프롬프트에 추가되었습니다")
|
||||
logger.info("샘플 가사 정보가 프롬프트에 추가되었습니다")
|
||||
else:
|
||||
print("샘플 가사가 없어 기본 프롬프트만 사용합니다")
|
||||
logger.info("샘플 가사가 없어 기본 프롬프트만 사용합니다")
|
||||
|
||||
print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n")
|
||||
logger.debug(f"[최종 프롬프트 길이: {len(updated_prompt)} 자]")
|
||||
|
||||
# 7. 모델에게 요청
|
||||
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()
|
||||
);
|
||||
"""
|
||||
print("\n[insert_params 선택된 속성 확인]")
|
||||
print(f"Categories: {selected_categories}")
|
||||
print(f"Values: {selected_values}")
|
||||
print()
|
||||
logger.debug("[insert_params 선택된 속성 확인]")
|
||||
logger.debug(f"Categories: {selected_categories}")
|
||||
logger.debug(f"Values: {selected_values}")
|
||||
|
||||
# attr_category, attr_value
|
||||
insert_params = {
|
||||
|
|
@ -792,9 +781,9 @@ async def make_automation(request: Request, conn: Connection):
|
|||
await conn.execute(text(insert_query), insert_params)
|
||||
await conn.commit()
|
||||
|
||||
print("결과 저장 완료")
|
||||
logger.info("결과 저장 완료")
|
||||
|
||||
print("\n전체 결과 조회 중...")
|
||||
logger.info("전체 결과 조회 중...")
|
||||
|
||||
# 9. 생성 결과 가져오기 (created_at 역순)
|
||||
select_query = """
|
||||
|
|
@ -826,26 +815,20 @@ async def make_automation(request: Request, conn: Connection):
|
|||
for row in all_results.fetchall()
|
||||
]
|
||||
|
||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
||||
logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
|
||||
|
||||
return results_list
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
print(f"Database Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"Database Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Unexpected Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"Unexpected Error: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ Video Background Tasks
|
|||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -16,9 +15,10 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||
from app.database.session import BackgroundSessionLocal
|
||||
from app.video.models import Video
|
||||
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 요청 설정
|
||||
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
|
||||
|
|
@ -66,20 +66,16 @@ async def _update_video_status(
|
|||
video.result_movie_url = video_url
|
||||
await session.commit()
|
||||
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
|
||||
else:
|
||||
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
|
||||
|
||||
except SQLAlchemyError as 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
|
||||
except Exception as 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
|
||||
|
||||
|
||||
|
|
@ -97,14 +93,12 @@ async def _download_video(url: str, task_id: str) -> bytes:
|
|||
httpx.HTTPError: 다운로드 실패 시
|
||||
"""
|
||||
logger.info(f"[VideoDownload] Downloading - task_id: {task_id}")
|
||||
print(f"[VideoDownload] Downloading - task_id: {task_id}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, timeout=REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -121,7 +115,6 @@ async def download_and_upload_video_to_blob(
|
|||
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
|
||||
|
||||
try:
|
||||
|
|
@ -136,12 +129,10 @@ async def download_and_upload_video_to_blob(
|
|||
temp_dir = Path("media") / "temp" / task_id
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file_path = temp_dir / file_name
|
||||
logger.info(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.debug(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}")
|
||||
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)
|
||||
|
||||
|
|
@ -149,7 +140,6 @@ async def download_and_upload_video_to_blob(
|
|||
await f.write(content)
|
||||
|
||||
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에 업로드
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
|
|
@ -161,29 +151,21 @@ async def download_and_upload_video_to_blob(
|
|||
# SAS 토큰이 제외된 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}")
|
||||
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
|
||||
|
||||
# Video 테이블 업데이트
|
||||
await _update_video_status(task_id, "completed", blob_url)
|
||||
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:
|
||||
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
||||
await _update_video_status(task_id, "failed")
|
||||
|
||||
finally:
|
||||
|
|
@ -191,11 +173,9 @@ async def download_and_upload_video_to_blob(
|
|||
if temp_file_path and temp_file_path.exists():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
logger.info(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}")
|
||||
logger.debug(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as 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
|
||||
|
|
@ -219,7 +199,6 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||
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
|
||||
task_id: str | None = None
|
||||
|
||||
|
|
@ -236,12 +215,10 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||
|
||||
if not video:
|
||||
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
|
||||
|
||||
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}")
|
||||
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(
|
||||
|
|
@ -254,12 +231,10 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||
temp_dir = Path("media") / "temp" / task_id
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
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}")
|
||||
print(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}")
|
||||
|
||||
# 영상 파일 다운로드
|
||||
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)
|
||||
|
||||
|
|
@ -267,7 +242,6 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||
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}")
|
||||
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에 업로드
|
||||
uploader = AzureBlobUploader(task_id=task_id)
|
||||
|
|
@ -279,7 +253,6 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||
# SAS 토큰이 제외된 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}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
|
||||
|
||||
# Video 테이블 업데이트
|
||||
await _update_video_status(
|
||||
|
|
@ -289,26 +262,19 @@ async def download_and_upload_video_by_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}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
||||
|
||||
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}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
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}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
||||
if task_id:
|
||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
traceback.print_exc()
|
||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
||||
if task_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():
|
||||
try:
|
||||
temp_file_path.unlink()
|
||||
logger.info(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}")
|
||||
logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
|
||||
except Exception as 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:
|
||||
|
|
|
|||
257
config.py
257
config.py
|
|
@ -5,6 +5,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||
|
||||
PROJECT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# 미디어 파일 저장 디렉토리
|
||||
MEDIA_ROOT = PROJECT_DIR / "media"
|
||||
MEDIA_ROOT.mkdir(exist_ok=True)
|
||||
|
||||
_base_config = SettingsConfigDict(
|
||||
env_file=PROJECT_DIR / ".env",
|
||||
env_ignore_empty=True,
|
||||
|
|
@ -95,32 +99,6 @@ class DatabaseSettings(BaseSettings):
|
|||
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):
|
||||
NAVER_COOKIES: str = Field(default="")
|
||||
|
||||
|
|
@ -175,13 +153,236 @@ class PromptSettings(BaseSettings):
|
|||
|
||||
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()
|
||||
apikey_settings = APIKeySettings()
|
||||
db_settings = DatabaseSettings()
|
||||
security_settings = SecuritySettings()
|
||||
notification_settings = NotificationSettings()
|
||||
cors_settings = CORSSettings()
|
||||
crawler_settings = CrawlerSettings()
|
||||
azure_blob_settings = AzureBlobSettings()
|
||||
creatomate_settings = CreatomateSettings()
|
||||
prompt_settings = PromptSettings()
|
||||
log_settings = LogSettings()
|
||||
kakao_settings = KakaoSettings()
|
||||
jwt_settings = JWTSettings()
|
||||
|
|
|
|||
|
|
@ -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
121
main.py
|
|
@ -1,18 +1,90 @@
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from scalar_fastapi import get_scalar_api_reference
|
||||
|
||||
from app.admin_manager import init_admin
|
||||
from app.core.common import lifespan
|
||||
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.user.api.routers.v1.auth import router as auth_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.video.api.routers.v1.video import router as video_router
|
||||
from app.utils.cors import CustomCORSMiddleware
|
||||
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(
|
||||
title=prj_settings.PROJECT_NAME,
|
||||
version=prj_settings.VERSION,
|
||||
|
|
@ -20,8 +92,50 @@ app = FastAPI(
|
|||
lifespan=lifespan,
|
||||
docs_url=None, # 기본 Swagger UI 비활성화
|
||||
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)
|
||||
|
||||
custom_cors_middleware = CustomCORSMiddleware(app)
|
||||
|
|
@ -49,6 +163,7 @@ def get_scalar_docs():
|
|||
|
||||
|
||||
app.include_router(home_router)
|
||||
app.include_router(lyric_router) # Lyric API 라우터 추가
|
||||
app.include_router(song_router) # Song API 라우터 추가
|
||||
app.include_router(video_router) # Video API 라우터 추가
|
||||
app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
|
||||
app.include_router(lyric_router)
|
||||
app.include_router(song_router)
|
||||
app.include_router(video_router)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ dependencies = [
|
|||
"fastapi[standard]>=0.125.0",
|
||||
"openai>=2.13.0",
|
||||
"pydantic-settings>=2.12.0",
|
||||
"python-jose[cryptography]>=3.5.0",
|
||||
"redis>=7.1.0",
|
||||
"ruff>=0.14.9",
|
||||
"scalar-fastapi>=1.5.0",
|
||||
|
|
|
|||
173
uv.lock
173
uv.lock
|
|
@ -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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "click"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "distro"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
|
|
@ -769,6 +882,7 @@ dependencies = [
|
|||
{ name = "fastapi-cli" },
|
||||
{ name = "openai" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
{ name = "scalar-fastapi" },
|
||||
|
|
@ -794,6 +908,7 @@ requires-dist = [
|
|||
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
||||
{ name = "openai", specifier = ">=2.13.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||
{ name = "redis", specifier = ">=7.1.0" },
|
||||
{ name = "ruff", specifier = ">=0.14.9" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pydantic"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-multipart"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "ruff"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
|
|
|
|||
Loading…
Reference in New Issue