Compare commits
9 Commits
ba26284451
...
2f384fb72a
| Author | SHA1 | Date |
|---|---|---|
|
|
2f384fb72a | |
|
|
4c47d6e0fc | |
|
|
4e15e44cbe | |
|
|
a9d0a3ee7f | |
|
|
a3d3c75463 | |
|
|
f5130b73d7 | |
|
|
d7120bb0ba | |
|
|
bf7b53c8e8 | |
|
|
1acd8846ab |
|
|
@ -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/
|
||||
|
|
@ -29,4 +29,10 @@ build/
|
|||
media/
|
||||
|
||||
|
||||
*.ipynb_checkpoint*
|
||||
*.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,17 +259,8 @@ 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
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -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,12 +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
|
||||
|
|
@ -62,7 +62,7 @@ KOREAN_CITIES = [
|
|||
]
|
||||
# fmt: on
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(tags=["Home"])
|
||||
|
||||
|
||||
def _extract_region_from_address(road_address: str | None) -> str:
|
||||
|
|
@ -106,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)
|
||||
|
|
@ -123,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}",
|
||||
|
|
@ -131,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="네이버 지도 크롤링 중 오류가 발생했습니다.",
|
||||
|
|
@ -141,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
|
||||
|
|
@ -163,80 +157,90 @@ 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(
|
||||
customer_name=customer_name,
|
||||
region=region,
|
||||
detail_region_info=road_address or "",
|
||||
)
|
||||
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()
|
||||
prompt = chatgpt_service.build_market_analysis_prompt()
|
||||
step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000
|
||||
print(f"[crawling] Step 3-2: 프롬프트 생성 완료 - {len(prompt)}자 ({step3_2_elapsed:.1f}ms)")
|
||||
# step3_2_start = time.perf_counter()
|
||||
input_marketing_data = {
|
||||
"customer_name" : customer_name,
|
||||
"region" : region,
|
||||
"detail_region_info" : road_address or ""
|
||||
}
|
||||
# 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
|
||||
|
||||
# Step 3-3: GPT API 호출
|
||||
step3_3_start = time.perf_counter()
|
||||
raw_response = await chatgpt_service.generate(prompt)
|
||||
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 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)")
|
||||
print(f"[crawling] Step 3-3: GPT API 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)")
|
||||
logger.info(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}")
|
||||
parsed = await chatgpt_service.parse_marketing_analysis(
|
||||
raw_response, 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(
|
||||
# structured_report, facility_info=scraper.facility_info
|
||||
# )
|
||||
|
||||
# marketing_analysis = MarketingAnalysis(**parsed)
|
||||
|
||||
marketing_analysis = MarketingAnalysis(
|
||||
report=structured_report["report"],
|
||||
tags=structured_report["tags"],
|
||||
facilities = list([sp['keywords'] for sp in structured_report["selling_points"]])# [json.dumps(structured_report["selling_points"])] # 나중에 Selling Points로 변수와 데이터구조 변경할 것
|
||||
)
|
||||
marketing_analysis = MarketingAnalysis(**parsed)
|
||||
# Selling Points 구조
|
||||
# print(sp['category'])
|
||||
# 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,
|
||||
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
||||
"processed_info": processed_info,
|
||||
"marketing_analysis": marketing_analysis,
|
||||
"marketing_analysis": marketing_analysis
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -612,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() != ""
|
||||
|
|
@ -671,9 +674,9 @@ async def upload_images_blob(
|
|||
)
|
||||
|
||||
stage1_time = time.perf_counter()
|
||||
print(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")
|
||||
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")
|
||||
|
||||
# ========== Stage 2: Azure Blob 업로드 (세션 없음) ==========
|
||||
# 업로드 결과를 저장할 리스트 (나중에 DB에 저장)
|
||||
|
|
@ -692,8 +695,8 @@ 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}: "
|
||||
f"{filename} ({len(file_content)} bytes)")
|
||||
logger.debug(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: "
|
||||
f"{filename} ({len(file_content)} bytes)")
|
||||
|
||||
# Azure Blob Storage에 직접 업로드
|
||||
upload_success = await uploader.upload_image_bytes(file_content, filename)
|
||||
|
|
@ -702,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: "
|
||||
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
|
||||
f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms")
|
||||
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
|
||||
|
||||
|
|
@ -769,21 +772,21 @@ async def upload_images_blob(
|
|||
|
||||
await session.commit()
|
||||
stage3_time = time.perf_counter()
|
||||
print(f"[upload_images_blob] Stage 3 done - "
|
||||
f"saved: {len(result_images)}, "
|
||||
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
|
||||
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="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
||||
)
|
||||
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()
|
||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}")
|
||||
logger.exception("[upload_images_blob] Stage 3 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="이미지 업로드 중 오류가 발생했습니다.",
|
||||
|
|
@ -793,8 +796,8 @@ 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}, "
|
||||
f"total: {saved_count}, total_time: {total_time*1000:.1f}ms")
|
||||
logger.info(f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||
f"total: {saved_count}, total_time: {total_time*1000:.1f}ms")
|
||||
|
||||
return ImageUploadResponse(
|
||||
task_id=task_id,
|
||||
|
|
|
|||
|
|
@ -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,8 +41,14 @@ 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"])
|
||||
|
||||
|
||||
|
|
@ -74,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)
|
||||
|
|
@ -84,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}'에 해당하는 가사를 찾을 수 없습니다.",
|
||||
|
|
@ -96,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(
|
||||
|
|
@ -127,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)
|
||||
|
|
@ -137,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,
|
||||
)
|
||||
|
|
@ -224,9 +229,10 @@ async def generate_lyric(
|
|||
|
||||
request_start = time.perf_counter()
|
||||
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}"
|
||||
|
|
@ -235,22 +241,51 @@ 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,
|
||||
region=request_body.region,
|
||||
detail_region_info=request_body.detail_region_info or "",
|
||||
language=request_body.language,
|
||||
)
|
||||
prompt = service.build_lyrics_prompt()
|
||||
# service = ChatgptService(
|
||||
# customer_name=request_body.customer_name,
|
||||
# region=request_body.region,
|
||||
# detail_region_info=request_body.detail_region_info or "",
|
||||
# language=request_body.language,
|
||||
# )
|
||||
|
||||
# prompt = service.build_lyrics_prompt()
|
||||
# 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨.
|
||||
# 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성
|
||||
|
||||
promotional_expressions = {
|
||||
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
|
||||
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
|
||||
"Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地",
|
||||
"Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景",
|
||||
"Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย",
|
||||
"Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp"
|
||||
}# HARD CODED, 어디에 정리하지? 아직 정리되지 않음
|
||||
|
||||
timing_rules = {
|
||||
"60s" : """
|
||||
8–12 lines
|
||||
Full verse flow, immersive mood
|
||||
"""
|
||||
}
|
||||
lyric_input_data = {
|
||||
"customer_name" : request_body.customer_name,
|
||||
"region" : request_body.region,
|
||||
"detail_region_info" : request_body.detail_region_info or "",
|
||||
"marketing_intelligence_summary" : None, # task_idx 변경 후 marketing intelligence summary DB에 저장하고 사용할 필요가 있음
|
||||
"language" : request_body.language,
|
||||
"promotional_expression_example" : promotional_expressions[request_body.language],
|
||||
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||
}
|
||||
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,
|
||||
|
|
@ -264,17 +299,18 @@ 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(
|
||||
project_id=project.id,
|
||||
task_id=task_id,
|
||||
status="processing",
|
||||
lyric_prompt=prompt,
|
||||
lyric_prompt=estimated_prompt,
|
||||
lyric_result=None,
|
||||
language=request_body.language,
|
||||
)
|
||||
|
|
@ -283,31 +319,31 @@ 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,
|
||||
task_id=task_id,
|
||||
prompt=prompt,
|
||||
language=request_body.language,
|
||||
prompt=lyric_prompt,
|
||||
lyric_input_data=lyric_input_data,
|
||||
)
|
||||
|
||||
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(
|
||||
|
|
@ -320,14 +356,14 @@ 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,
|
||||
task_id=task_id,
|
||||
lyric=None,
|
||||
language=request_body.language,
|
||||
error_message=str(e),
|
||||
error_message=''.join(tb.format_exception(None, e, e.__traceback__)),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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="생성 일시")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,852 +0,0 @@
|
|||
import random
|
||||
from typing import List
|
||||
|
||||
from fastapi import Request, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
from sqlalchemy import Connection, text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.lyric.schemas.lyrics_schema import (
|
||||
AttributeData,
|
||||
PromptTemplateData,
|
||||
SongFormData,
|
||||
SongSampleData,
|
||||
StoreData,
|
||||
)
|
||||
from app.utils.chatgpt_prompt import chatgpt_api
|
||||
|
||||
|
||||
async def get_store_info(conn: Connection) -> List[StoreData]:
|
||||
try:
|
||||
query = """SELECT * FROM store_default_info;"""
|
||||
result = await conn.execute(text(query))
|
||||
|
||||
all_store_info = [
|
||||
StoreData(
|
||||
id=row[0],
|
||||
store_info=row[1],
|
||||
store_name=row[2],
|
||||
store_category=row[3],
|
||||
store_region=row[4],
|
||||
store_address=row[5],
|
||||
store_phone_number=row[6],
|
||||
created_at=row[7],
|
||||
)
|
||||
for row in result
|
||||
]
|
||||
|
||||
result.close()
|
||||
return all_store_info
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
)
|
||||
|
||||
|
||||
async def get_attribute(conn: Connection) -> List[AttributeData]:
|
||||
try:
|
||||
query = """SELECT * FROM attribute;"""
|
||||
result = await conn.execute(text(query))
|
||||
|
||||
all_attribute = [
|
||||
AttributeData(
|
||||
id=row[0],
|
||||
attr_category=row[1],
|
||||
attr_value=row[2],
|
||||
created_at=row[3],
|
||||
)
|
||||
for row in result
|
||||
]
|
||||
|
||||
result.close()
|
||||
return all_attribute
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
)
|
||||
|
||||
|
||||
async def get_attribute(conn: Connection) -> List[AttributeData]:
|
||||
try:
|
||||
query = """SELECT * FROM attribute;"""
|
||||
result = await conn.execute(text(query))
|
||||
|
||||
all_attribute = [
|
||||
AttributeData(
|
||||
id=row[0],
|
||||
attr_category=row[1],
|
||||
attr_value=row[2],
|
||||
created_at=row[3],
|
||||
)
|
||||
for row in result
|
||||
]
|
||||
|
||||
result.close()
|
||||
return all_attribute
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
)
|
||||
|
||||
|
||||
async def get_sample_song(conn: Connection) -> List[SongSampleData]:
|
||||
try:
|
||||
query = """SELECT * FROM song_sample;"""
|
||||
result = await conn.execute(text(query))
|
||||
|
||||
all_sample_song = [
|
||||
SongSampleData(
|
||||
id=row[0],
|
||||
ai=row[1],
|
||||
ai_model=row[2],
|
||||
genre=row[3],
|
||||
sample_song=row[4],
|
||||
)
|
||||
for row in result
|
||||
]
|
||||
|
||||
result.close()
|
||||
return all_sample_song
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
)
|
||||
|
||||
|
||||
async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]:
|
||||
try:
|
||||
query = """SELECT * FROM prompt_template;"""
|
||||
result = await conn.execute(text(query))
|
||||
|
||||
all_prompt_template = [
|
||||
PromptTemplateData(
|
||||
id=row[0],
|
||||
description=row[1],
|
||||
prompt=row[2],
|
||||
)
|
||||
for row in result
|
||||
]
|
||||
|
||||
result.close()
|
||||
return all_prompt_template
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
)
|
||||
|
||||
|
||||
async def get_song_result(conn: Connection) -> List[PromptTemplateData]:
|
||||
try:
|
||||
query = """SELECT * FROM prompt_template;"""
|
||||
result = await conn.execute(text(query))
|
||||
|
||||
all_prompt_template = [
|
||||
PromptTemplateData(
|
||||
id=row[0],
|
||||
description=row[1],
|
||||
prompt=row[2],
|
||||
)
|
||||
for row in result
|
||||
]
|
||||
|
||||
result.close()
|
||||
return all_prompt_template
|
||||
except SQLAlchemyError as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
|
||||
)
|
||||
|
||||
|
||||
async def make_song_result(request: Request, conn: Connection):
|
||||
try:
|
||||
# 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")
|
||||
|
||||
# 2. Store 정보 조회
|
||||
store_query = """
|
||||
SELECT * FROM store_default_info WHERE id=:id;
|
||||
"""
|
||||
store_result = await conn.execute(text(store_query), {"id": form_data.store_id})
|
||||
|
||||
all_store_info = [
|
||||
StoreData(
|
||||
id=row[0],
|
||||
store_info=row[1],
|
||||
store_name=row[2],
|
||||
store_category=row[3],
|
||||
store_region=row[4],
|
||||
store_address=row[5],
|
||||
store_phone_number=row[6],
|
||||
created_at=row[7],
|
||||
)
|
||||
for row in store_result
|
||||
]
|
||||
|
||||
if not all_store_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Store not found: {form_data.store_id}",
|
||||
)
|
||||
|
||||
store_info = all_store_info[0]
|
||||
print(f"Store: {store_info.store_name}")
|
||||
|
||||
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
||||
|
||||
# 4. Sample Song 조회 및 결합
|
||||
combined_sample_song = None
|
||||
|
||||
if form_data.lyrics_ids:
|
||||
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||
|
||||
lyrics_query = """
|
||||
SELECT sample_song FROM song_sample
|
||||
WHERE id IN :ids
|
||||
ORDER BY created_at;
|
||||
"""
|
||||
lyrics_result = await conn.execute(
|
||||
text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)}
|
||||
)
|
||||
|
||||
sample_songs = [
|
||||
row.sample_song for row in lyrics_result.fetchall() if row.sample_song
|
||||
]
|
||||
|
||||
if sample_songs:
|
||||
combined_sample_song = "\n\n".join(
|
||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||
)
|
||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
else:
|
||||
print("샘플 가사가 비어있습니다")
|
||||
else:
|
||||
print("선택된 lyrics가 없습니다")
|
||||
|
||||
# 5. 템플릿 가져오기
|
||||
if not form_data.prompts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="프롬프트 ID가 필요합니다",
|
||||
)
|
||||
|
||||
print("템플릿 가져오기")
|
||||
|
||||
prompts_query = """
|
||||
SELECT * FROM prompt_template WHERE id=:id;
|
||||
"""
|
||||
|
||||
# ✅ 수정: store_query → prompts_query
|
||||
prompts_result = await conn.execute(
|
||||
text(prompts_query), {"id": form_data.prompts}
|
||||
)
|
||||
|
||||
prompts_info = [
|
||||
PromptTemplateData(
|
||||
id=row[0],
|
||||
description=row[1],
|
||||
prompt=row[2],
|
||||
)
|
||||
for row in prompts_result
|
||||
]
|
||||
|
||||
if not prompts_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Prompt not found: {form_data.prompts}",
|
||||
)
|
||||
|
||||
prompt = prompts_info[0]
|
||||
print(f"Prompt Template: {prompt.prompt}")
|
||||
|
||||
# ✅ 6. 프롬프트 조합
|
||||
updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format(
|
||||
name=store_info.store_name or "",
|
||||
address=store_info.store_address or "",
|
||||
category=store_info.store_category or "",
|
||||
description=store_info.store_info or "",
|
||||
)
|
||||
|
||||
updated_prompt += f"""
|
||||
|
||||
다음은 참고해야 하는 샘플 가사 정보입니다.
|
||||
|
||||
샘플 가사를 참고하여 작곡을 해주세요.
|
||||
|
||||
{combined_sample_song}
|
||||
"""
|
||||
|
||||
print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n")
|
||||
|
||||
# 7. 모델에게 요청
|
||||
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
|
||||
|
||||
# 글자 수 계산
|
||||
total_chars_with_space = len(generated_lyrics)
|
||||
total_chars_without_space = len(
|
||||
generated_lyrics.replace(" ", "")
|
||||
.replace("\n", "")
|
||||
.replace("\r", "")
|
||||
.replace("\t", "")
|
||||
)
|
||||
|
||||
# final_lyrics 생성
|
||||
final_lyrics = f"""속성 {form_data.attributes_str}
|
||||
전체 글자 수 (공백 포함): {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)
|
||||
|
||||
# 8. DB 저장
|
||||
insert_query = """
|
||||
INSERT INTO song_results_all (
|
||||
store_info, store_name, store_category, store_address, store_phone_number,
|
||||
description, prompt, attr_category, attr_value,
|
||||
ai, ai_model, genre,
|
||||
sample_song, result_song, created_at
|
||||
) VALUES (
|
||||
:store_info, :store_name, :store_category, :store_address, :store_phone_number,
|
||||
:description, :prompt, :attr_category, :attr_value,
|
||||
:ai, :ai_model, :genre,
|
||||
:sample_song, :result_song, NOW()
|
||||
);
|
||||
"""
|
||||
|
||||
# ✅ attr_category, attr_value 추가
|
||||
insert_params = {
|
||||
"store_info": store_info.store_info or "",
|
||||
"store_name": store_info.store_name,
|
||||
"store_category": store_info.store_category or "",
|
||||
"store_address": store_info.store_address or "",
|
||||
"store_phone_number": store_info.store_phone_number or "",
|
||||
"description": store_info.store_info or "",
|
||||
"prompt": form_data.prompts,
|
||||
"attr_category": ", ".join(form_data.attributes.keys())
|
||||
if form_data.attributes
|
||||
else "",
|
||||
"attr_value": ", ".join(form_data.attributes.values())
|
||||
if form_data.attributes
|
||||
else "",
|
||||
"ai": "ChatGPT",
|
||||
"ai_model": form_data.llm_model,
|
||||
"genre": "후크송",
|
||||
"sample_song": combined_sample_song or "없음",
|
||||
"result_song": final_lyrics,
|
||||
}
|
||||
|
||||
await conn.execute(text(insert_query), insert_params)
|
||||
await conn.commit()
|
||||
|
||||
print("결과 저장 완료")
|
||||
|
||||
print("\n전체 결과 조회 중...")
|
||||
|
||||
# 9. 생성 결과 가져오기 (created_at 역순)
|
||||
select_query = """
|
||||
SELECT * FROM song_results_all
|
||||
ORDER BY created_at DESC;
|
||||
"""
|
||||
|
||||
all_results = await conn.execute(text(select_query))
|
||||
|
||||
results_list = [
|
||||
{
|
||||
"id": row.id,
|
||||
"store_info": row.store_info,
|
||||
"store_name": row.store_name,
|
||||
"store_category": row.store_category,
|
||||
"store_address": row.store_address,
|
||||
"store_phone_number": row.store_phone_number,
|
||||
"description": row.description,
|
||||
"prompt": row.prompt,
|
||||
"attr_category": row.attr_category,
|
||||
"attr_value": row.attr_value,
|
||||
"ai": row.ai,
|
||||
"ai_model": row.ai_model,
|
||||
"genre": row.genre,
|
||||
"sample_song": row.sample_song,
|
||||
"result_song": row.result_song,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
for row in all_results.fetchall()
|
||||
]
|
||||
|
||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
||||
|
||||
return results_list
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
print(f"Database Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Unexpected Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
|
||||
async def get_song_result(conn: Connection): # 반환 타입 수정
|
||||
try:
|
||||
select_query = """
|
||||
SELECT * FROM song_results_all
|
||||
ORDER BY created_at DESC;
|
||||
"""
|
||||
|
||||
all_results = await conn.execute(text(select_query))
|
||||
|
||||
results_list = [
|
||||
{
|
||||
"id": row.id,
|
||||
"store_info": row.store_info,
|
||||
"store_name": row.store_name,
|
||||
"store_category": row.store_category,
|
||||
"store_address": row.store_address,
|
||||
"store_phone_number": row.store_phone_number,
|
||||
"description": row.description,
|
||||
"prompt": row.prompt,
|
||||
"attr_category": row.attr_category,
|
||||
"attr_value": row.attr_value,
|
||||
"ai": row.ai,
|
||||
"ai_model": row.ai_model,
|
||||
"season": row.season,
|
||||
"num_of_people": row.num_of_people,
|
||||
"people_category": row.people_category,
|
||||
"genre": row.genre,
|
||||
"sample_song": row.sample_song,
|
||||
"result_song": row.result_song,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
for row in all_results.fetchall()
|
||||
]
|
||||
|
||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
||||
|
||||
return results_list
|
||||
except HTTPException: # HTTPException은 그대로 raise
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
print(f"Database Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Unexpected Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
||||
|
||||
async def make_automation(request: Request, conn: Connection):
|
||||
try:
|
||||
# 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")
|
||||
|
||||
# 2. Store 정보 조회
|
||||
store_query = """
|
||||
SELECT * FROM store_default_info WHERE id=:id;
|
||||
"""
|
||||
store_result = await conn.execute(text(store_query), {"id": form_data.store_id})
|
||||
|
||||
all_store_info = [
|
||||
StoreData(
|
||||
id=row[0],
|
||||
store_info=row[1],
|
||||
store_name=row[2],
|
||||
store_category=row[3],
|
||||
store_region=row[4],
|
||||
store_address=row[5],
|
||||
store_phone_number=row[6],
|
||||
created_at=row[7],
|
||||
)
|
||||
for row in store_result
|
||||
]
|
||||
|
||||
if not all_store_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Store not found: {form_data.store_id}",
|
||||
)
|
||||
|
||||
store_info = all_store_info[0]
|
||||
print(f"Store: {store_info.store_name}")
|
||||
|
||||
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
|
||||
attribute_query = """
|
||||
SELECT * FROM attribute;
|
||||
"""
|
||||
|
||||
attribute_results = await conn.execute(text(attribute_query))
|
||||
|
||||
# 결과 가져오기
|
||||
attribute_rows = attribute_results.fetchall()
|
||||
|
||||
formatted_attributes = ""
|
||||
selected_categories = []
|
||||
selected_values = []
|
||||
|
||||
if attribute_rows:
|
||||
attribute_list = [
|
||||
AttributeData(
|
||||
id=row[0],
|
||||
attr_category=row[1],
|
||||
attr_value=row[2],
|
||||
created_at=row[3],
|
||||
)
|
||||
for row in attribute_rows
|
||||
]
|
||||
|
||||
# ✅ 각 category에서 하나의 value만 랜덤 선택
|
||||
formatted_pairs = []
|
||||
for attr in attribute_list:
|
||||
# 쉼표로 분리 및 공백 제거
|
||||
values = [v.strip() for v in attr.attr_value.split(",") if v.strip()]
|
||||
|
||||
if values:
|
||||
# 랜덤하게 하나만 선택
|
||||
selected_value = random.choice(values)
|
||||
formatted_pairs.append(f"{attr.attr_category} : {selected_value}")
|
||||
|
||||
# ✅ 선택된 category와 value 저장
|
||||
selected_categories.append(attr.attr_category)
|
||||
selected_values.append(selected_value)
|
||||
|
||||
# 최종 문자열 생성
|
||||
formatted_attributes = "\n".join(formatted_pairs)
|
||||
|
||||
print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n")
|
||||
else:
|
||||
print("속성 데이터가 없습니다")
|
||||
formatted_attributes = ""
|
||||
|
||||
# 4. 템플릿 가져오기
|
||||
print("템플릿 가져오기 (ID=1)")
|
||||
|
||||
prompts_query = """
|
||||
SELECT * FROM prompt_template WHERE id=1;
|
||||
"""
|
||||
|
||||
prompts_result = await conn.execute(text(prompts_query))
|
||||
|
||||
row = prompts_result.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Prompt ID 1 not found",
|
||||
)
|
||||
|
||||
prompt = PromptTemplateData(
|
||||
id=row[0],
|
||||
description=row[1],
|
||||
prompt=row[2],
|
||||
)
|
||||
|
||||
print(f"Prompt Template: {prompt.prompt}")
|
||||
|
||||
# 5. 템플릿 조합
|
||||
|
||||
updated_prompt = prompt.prompt.replace("###", formatted_attributes).format(
|
||||
name=store_info.store_name or "",
|
||||
address=store_info.store_address or "",
|
||||
category=store_info.store_category or "",
|
||||
description=store_info.store_info or "",
|
||||
)
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("업데이트된 프롬프트")
|
||||
print("=" * 80)
|
||||
print(updated_prompt)
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
# 4. Sample Song 조회 및 결합
|
||||
combined_sample_song = None
|
||||
|
||||
if form_data.lyrics_ids:
|
||||
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}개")
|
||||
|
||||
lyrics_query = """
|
||||
SELECT sample_song FROM song_sample
|
||||
WHERE id IN :ids
|
||||
ORDER BY created_at;
|
||||
"""
|
||||
lyrics_result = await conn.execute(
|
||||
text(lyrics_query), {"ids": tuple(form_data.lyrics_ids)}
|
||||
)
|
||||
|
||||
sample_songs = [
|
||||
row.sample_song for row in lyrics_result.fetchall() if row.sample_song
|
||||
]
|
||||
|
||||
if sample_songs:
|
||||
combined_sample_song = "\n\n".join(
|
||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||
)
|
||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
else:
|
||||
print("샘플 가사가 비어있습니다")
|
||||
else:
|
||||
print("선택된 lyrics가 없습니다")
|
||||
|
||||
# 1. song_sample 테이블의 모든 ID 조회
|
||||
print("\n[샘플 가사 랜덤 선택]")
|
||||
|
||||
all_ids_query = """
|
||||
SELECT id FROM song_sample;
|
||||
"""
|
||||
ids_result = await conn.execute(text(all_ids_query))
|
||||
all_ids = [row.id for row in ids_result.fetchall()]
|
||||
|
||||
print(f"전체 샘플 가사 개수: {len(all_ids)}개")
|
||||
|
||||
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
|
||||
combined_sample_song = None
|
||||
|
||||
if all_ids:
|
||||
# 3개 또는 전체 개수 중 작은 값 선택
|
||||
sample_count = min(3, len(all_ids))
|
||||
selected_ids = random.sample(all_ids, sample_count)
|
||||
|
||||
print(f"랜덤 선택된 ID: {selected_ids}")
|
||||
|
||||
# 3. 선택된 ID로 샘플 가사 조회
|
||||
lyrics_query = """
|
||||
SELECT sample_song FROM song_sample
|
||||
WHERE id IN :ids
|
||||
ORDER BY created_at;
|
||||
"""
|
||||
lyrics_result = await conn.execute(
|
||||
text(lyrics_query), {"ids": tuple(selected_ids)}
|
||||
)
|
||||
|
||||
sample_songs = [
|
||||
row.sample_song for row in lyrics_result.fetchall() if row.sample_song
|
||||
]
|
||||
|
||||
# 4. combined_sample_song 생성
|
||||
if sample_songs:
|
||||
combined_sample_song = "\n\n".join(
|
||||
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
|
||||
)
|
||||
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
|
||||
else:
|
||||
print("샘플 가사가 비어있습니다")
|
||||
else:
|
||||
print("song_sample 테이블에 데이터가 없습니다")
|
||||
|
||||
# 5. 프롬프트에 샘플 가사 추가
|
||||
if combined_sample_song:
|
||||
updated_prompt += f"""
|
||||
|
||||
다음은 참고해야 하는 샘플 가사 정보입니다.
|
||||
|
||||
샘플 가사를 참고하여 작곡을 해주세요.
|
||||
|
||||
{combined_sample_song}
|
||||
"""
|
||||
print("샘플 가사 정보가 프롬프트에 추가되었습니다")
|
||||
else:
|
||||
print("샘플 가사가 없어 기본 프롬프트만 사용합니다")
|
||||
|
||||
print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n")
|
||||
|
||||
# 7. 모델에게 요청
|
||||
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
|
||||
|
||||
# 글자 수 계산
|
||||
total_chars_with_space = len(generated_lyrics)
|
||||
total_chars_without_space = len(
|
||||
generated_lyrics.replace(" ", "")
|
||||
.replace("\n", "")
|
||||
.replace("\r", "")
|
||||
.replace("\t", "")
|
||||
)
|
||||
|
||||
# final_lyrics 생성
|
||||
final_lyrics = f"""속성 {formatted_attributes}
|
||||
전체 글자 수 (공백 포함): {total_chars_with_space}자
|
||||
전체 글자 수 (공백 제외): {total_chars_without_space}자\r\n\r\n{generated_lyrics}"""
|
||||
|
||||
# 8. DB 저장
|
||||
insert_query = """
|
||||
INSERT INTO song_results_all (
|
||||
store_info, store_name, store_category, store_address, store_phone_number,
|
||||
description, prompt, attr_category, attr_value,
|
||||
ai, ai_model, genre,
|
||||
sample_song, result_song, created_at
|
||||
) VALUES (
|
||||
:store_info, :store_name, :store_category, :store_address, :store_phone_number,
|
||||
:description, :prompt, :attr_category, :attr_value,
|
||||
:ai, :ai_model, :genre,
|
||||
:sample_song, :result_song, NOW()
|
||||
);
|
||||
"""
|
||||
print("\n[insert_params 선택된 속성 확인]")
|
||||
print(f"Categories: {selected_categories}")
|
||||
print(f"Values: {selected_values}")
|
||||
print()
|
||||
|
||||
# attr_category, attr_value
|
||||
insert_params = {
|
||||
"store_info": store_info.store_info or "",
|
||||
"store_name": store_info.store_name,
|
||||
"store_category": store_info.store_category or "",
|
||||
"store_address": store_info.store_address or "",
|
||||
"store_phone_number": store_info.store_phone_number or "",
|
||||
"description": store_info.store_info or "",
|
||||
"prompt": prompt.id,
|
||||
# 랜덤 선택된 category와 value 사용
|
||||
"attr_category": ", ".join(selected_categories)
|
||||
if selected_categories
|
||||
else "",
|
||||
"attr_value": ", ".join(selected_values) if selected_values else "",
|
||||
"ai": "ChatGPT",
|
||||
"ai_model": "gpt-5-mini",
|
||||
"genre": "후크송",
|
||||
"sample_song": combined_sample_song or "없음",
|
||||
"result_song": final_lyrics,
|
||||
}
|
||||
|
||||
await conn.execute(text(insert_query), insert_params)
|
||||
await conn.commit()
|
||||
|
||||
print("결과 저장 완료")
|
||||
|
||||
print("\n전체 결과 조회 중...")
|
||||
|
||||
# 9. 생성 결과 가져오기 (created_at 역순)
|
||||
select_query = """
|
||||
SELECT * FROM song_results_all
|
||||
ORDER BY created_at DESC;
|
||||
"""
|
||||
|
||||
all_results = await conn.execute(text(select_query))
|
||||
|
||||
results_list = [
|
||||
{
|
||||
"id": row.id,
|
||||
"store_info": row.store_info,
|
||||
"store_name": row.store_name,
|
||||
"store_category": row.store_category,
|
||||
"store_address": row.store_address,
|
||||
"store_phone_number": row.store_phone_number,
|
||||
"description": row.description,
|
||||
"prompt": row.prompt,
|
||||
"attr_category": row.attr_category,
|
||||
"attr_value": row.attr_value,
|
||||
"ai": row.ai,
|
||||
"ai_model": row.ai_model,
|
||||
"genre": row.genre,
|
||||
"sample_song": row.sample_song,
|
||||
"result_song": row.result_song,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
for row in all_results.fetchall()
|
||||
]
|
||||
|
||||
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
|
||||
|
||||
return results_list
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError as e:
|
||||
print(f"Database Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="데이터베이스 연결에 문제가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Unexpected Error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="서비스 처리 중 오류가 발생했습니다.",
|
||||
)
|
||||
|
|
@ -4,7 +4,6 @@ Lyric Background Tasks
|
|||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from sqlalchemy import select
|
||||
|
|
@ -13,9 +12,11 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||
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(
|
||||
|
|
@ -49,27 +50,23 @@ 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
|
||||
|
||||
|
||||
async def generate_lyric_background(
|
||||
task_id: str,
|
||||
prompt: str,
|
||||
language: str,
|
||||
prompt: Prompt,
|
||||
lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input
|
||||
) -> None:
|
||||
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
|
||||
|
||||
|
|
@ -82,65 +79,63 @@ 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: {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="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||
region="",
|
||||
detail_region_info="",
|
||||
language=language,
|
||||
)
|
||||
# service = ChatgptService(
|
||||
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
||||
# region="",
|
||||
# detail_region_info="",
|
||||
# language=language,
|
||||
# )
|
||||
|
||||
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 호출 시작...")
|
||||
|
||||
result = await service.generate(prompt=prompt)
|
||||
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,13 +398,13 @@ 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 = """
|
||||
SELECT * FROM song_results_all
|
||||
SELECT * FROM song_results_all
|
||||
ORDER BY created_at DESC;
|
||||
"""
|
||||
|
||||
|
|
@ -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,211 +1,15 @@
|
|||
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: off
|
||||
LYRICS_PROMPT_TEMPLATE_ORI = """
|
||||
1.Act as a content marketing expert with domain knowledges in [pension/staying services] in Korea, Goal: plan viral content creation that lead online reservations and promotion
|
||||
2.Conduct an in-depth analysis of [업체명:{customer_name}] in [지역명:{region}] by examining their official website or informations, photos on never map and online presence. Create a comprehensive "[지역 상세: {detail_region_info}]_Brand & Marketing Intelligence Report in Korean, that includes:
|
||||
|
||||
**Core Analysis:**
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs) and competitive differentiators
|
||||
- Comprehensive competitor landscape analysis (direct & indirect competitors)
|
||||
- Market positioning assessment
|
||||
|
||||
**Content Strategy Framework:**
|
||||
- Seasonal content calendar with trend integration
|
||||
- Visual storytelling direction (shot-by-shot creative guidance)
|
||||
- Brand tone & voice guidelines
|
||||
- Content themes aligned with target audience behaviors
|
||||
|
||||
**SEO & AEO Optimization:**
|
||||
- Recommended primary and long-tail keywords
|
||||
- SEO-optimized taglines and meta descriptions
|
||||
- Answer Engine Optimization (AEO) content suggestions
|
||||
- Local search optimization strategies
|
||||
|
||||
**Actionable Recommendations:**
|
||||
- Content distribution strategy across platforms
|
||||
- KPI measurement framework
|
||||
- Budget allocation recommendations by content type
|
||||
|
||||
콘텐츠 기획(Lyrics, Prompt for SUNO)
|
||||
1. Based on the Brand & Marketing Intelligence Report for [업체명 + 지역명 / {customer_name} ({region})], create original lyrics and define music attributes (song mood, BPM, genres, and key musical motifs, Prompt for Suno.com) specifically tailored for viral content.
|
||||
2. The lyrics should include, the name of [ Promotion Subject], [location], [main target],[Famous place, accessible in 10min], promotional words including but not limited to [인스타 감성], [사진같은 하루]
|
||||
|
||||
Deliver outputs optimized for three formats:1 minute. Ensure that each version aligns with the brand's core identity and is suitable for use in digital marketing and social media campaigns, in Korean
|
||||
""".strip()
|
||||
# fmt: on
|
||||
|
||||
LYRICS_PROMPT_TEMPLATE = """
|
||||
[ROLE]
|
||||
Content marketing expert and creative songwriter specializing in pension/accommodation services
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
- Output Language: {language}
|
||||
|
||||
[INTERNAL ANALYSIS - DO NOT OUTPUT]
|
||||
Analyze the following internally to inform lyrics creation:
|
||||
- Target customer segments and personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Regional characteristics and nearby attractions (within 10 min access)
|
||||
- Seasonal appeal points
|
||||
- Emotional triggers for the target audience
|
||||
|
||||
[LYRICS REQUIREMENTS]
|
||||
1. Must Include Elements:
|
||||
- Business name (TRANSLATED or TRANSLITERATED to {language})
|
||||
- Region name (TRANSLATED or TRANSLITERATED to {language})
|
||||
- Main target audience appeal
|
||||
- Nearby famous places or regional characteristics
|
||||
|
||||
2. Keywords to Incorporate (use language-appropriate trendy expressions):
|
||||
- Korean: 인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소
|
||||
- English: Instagram vibes, picture-perfect day, healing, travel, getaway
|
||||
- Chinese: 网红打卡, 治愈系, 旅行, 度假, 拍照圣地
|
||||
- Japanese: インスタ映え, 写真のような一日, 癒し, 旅行, 絶景
|
||||
- Thai: ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย
|
||||
- Vietnamese: check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp
|
||||
|
||||
3. Structure:
|
||||
- Length: For 1-minute video (approximately 8-12 lines)
|
||||
- Flow: Verse structure suitable for music
|
||||
- Rhythm: Natural speech rhythm in the specified language
|
||||
|
||||
4. Tone:
|
||||
- Emotional and heartfelt
|
||||
- Trendy and viral-friendly
|
||||
- Relatable to target audience
|
||||
|
||||
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
||||
ALL OUTPUT MUST BE 100% WRITTEN IN {language} - NO EXCEPTIONS
|
||||
- ALL lyrics content: {language} ONLY
|
||||
- ALL proper nouns (business names, region names, place names): MUST be translated or transliterated to {language}
|
||||
- Korean input like "군산" must become "Gunsan" in English, "群山" in Chinese, "グンサン" in Japanese, etc.
|
||||
- Korean input like "스테이 머뭄" must become "Stay Meoum" in English, "住留" in Chinese, "ステイモーム" in Japanese, etc.
|
||||
- ZERO Korean characters (한글) allowed when output language is NOT Korean
|
||||
- ZERO mixing of languages - the entire output must be monolingual in {language}
|
||||
- This is a NON-NEGOTIABLE requirement
|
||||
- Any output containing characters from other languages is considered a COMPLETE FAILURE
|
||||
- Violation of this rule invalidates the entire response
|
||||
|
||||
[OUTPUT RULES - STRICTLY ENFORCED]
|
||||
- Output lyrics ONLY
|
||||
- Lyrics MUST be written ENTIRELY in {language} - NO EXCEPTIONS
|
||||
- ALL names and places MUST be in {language} script/alphabet
|
||||
- NO Korean (한글), Chinese (漢字), Japanese (仮名), Thai (ไทย), or Vietnamese (Tiếng Việt) characters unless that is the selected output language
|
||||
- NO titles, descriptions, analysis, or explanations
|
||||
- NO greetings or closing remarks
|
||||
- NO additional commentary before or after lyrics
|
||||
- NO line numbers or labels
|
||||
- Follow the exact format below
|
||||
|
||||
[OUTPUT FORMAT - SUCCESS]
|
||||
---
|
||||
[Lyrics ENTIRELY in {language} here - no other language characters allowed]
|
||||
---
|
||||
|
||||
[OUTPUT FORMAT - FAILURE]
|
||||
If you cannot generate lyrics due to insufficient information, invalid input, or any other reason:
|
||||
---
|
||||
ERROR: [Brief reason for failure in English]
|
||||
---
|
||||
""".strip()
|
||||
# fmt: on
|
||||
|
||||
MARKETING_ANALYSIS_PROMPT_TEMPLATE = """
|
||||
[ROLE]
|
||||
Content marketing expert specializing in pension/accommodation services in Korea
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[ANALYSIS REQUIREMENTS]
|
||||
Provide comprehensive marketing analysis including:
|
||||
1. Target Customer Segments
|
||||
- Primary and secondary target personas
|
||||
- Age groups, travel preferences, booking patterns
|
||||
2. Unique Selling Propositions (USPs)
|
||||
- Key differentiators based on location and region details
|
||||
- Competitive advantages
|
||||
3. Regional Characteristics
|
||||
- Nearby attractions and famous places (within 10 min access)
|
||||
- Local food, activities, and experiences
|
||||
- Transportation accessibility
|
||||
4. Seasonal Appeal Points
|
||||
- Best seasons to visit
|
||||
- Seasonal activities and events
|
||||
- Peak/off-peak marketing opportunities
|
||||
5. Marketing Keywords
|
||||
- Recommended hashtags and search keywords
|
||||
- Trending terms relevant to the property
|
||||
|
||||
[ADDITIONAL REQUIREMENTS]
|
||||
1. Recommended Tags
|
||||
- Generate 5 recommended hashtags/tags based on the business characteristics
|
||||
- Tags should be trendy, searchable, and relevant to accommodation marketing
|
||||
- Return as JSON with key "tags"
|
||||
- **MUST be written in Korean (한국어)**
|
||||
|
||||
[CRITICAL LANGUAGE REQUIREMENT - ABSOLUTE RULE]
|
||||
ALL OUTPUT MUST BE WRITTEN IN KOREAN (한국어)
|
||||
- Analysis sections: Korean only
|
||||
- Tags: Korean only
|
||||
- This is a NON-NEGOTIABLE requirement
|
||||
- Any output in English or other languages is considered a FAILURE
|
||||
- Violation of this rule invalidates the entire response
|
||||
|
||||
[OUTPUT RULES - STRICTLY ENFORCED]
|
||||
- Output analysis ONLY
|
||||
- ALL content MUST be written in Korean (한국어) - NO EXCEPTIONS
|
||||
- NO greetings or closing remarks
|
||||
- NO additional commentary before or after analysis
|
||||
- Follow the exact format below
|
||||
|
||||
[OUTPUT FORMAT - SUCCESS]
|
||||
---
|
||||
## 타겟 고객 분석
|
||||
[한국어로 작성된 타겟 고객 분석]
|
||||
|
||||
## 핵심 차별점 (USP)
|
||||
[한국어로 작성된 USP 분석]
|
||||
|
||||
## 지역 특성
|
||||
[한국어로 작성된 지역 특성 분석]
|
||||
|
||||
## 시즌별 매력 포인트
|
||||
[한국어로 작성된 시즌별 분석]
|
||||
|
||||
## 마케팅 키워드
|
||||
[한국어로 작성된 마케팅 키워드]
|
||||
|
||||
## JSON Data
|
||||
```json
|
||||
{{
|
||||
"tags": ["태그1", "태그2", "태그3", "태그4", "태그5"]
|
||||
}}
|
||||
```
|
||||
---
|
||||
|
||||
[OUTPUT FORMAT - FAILURE]
|
||||
If you cannot generate analysis due to insufficient information, invalid input, or any other reason:
|
||||
---
|
||||
ERROR: [Brief reason for failure in English]
|
||||
---
|
||||
""".strip()
|
||||
# fmt: on
|
||||
|
||||
|
||||
|
|
@ -215,190 +19,29 @@ class ChatgptService:
|
|||
GPT 5.0 모델을 사용하여 마케팅 가사 및 분석을 생성합니다.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
customer_name: str,
|
||||
region: str,
|
||||
detail_region_info: str = "",
|
||||
language: str = "Korean",
|
||||
):
|
||||
# 최신 모델: gpt-5-mini
|
||||
self.model = "gpt-5-mini"
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
||||
self.customer_name = customer_name
|
||||
self.region = region
|
||||
self.detail_region_info = detail_region_info
|
||||
self.language = language
|
||||
|
||||
def build_lyrics_prompt(self) -> str:
|
||||
"""LYRICS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
|
||||
return LYRICS_PROMPT_TEMPLATE.format(
|
||||
customer_name=self.customer_name,
|
||||
region=self.region,
|
||||
detail_region_info=self.detail_region_info,
|
||||
language=self.language,
|
||||
|
||||
async def _call_structured_output_with_response_gpt_api(self, prompt: str, output_format : dict, model:str) -> dict:
|
||||
content = [{"type": "input_text", "text": prompt}]
|
||||
response = await self.client.responses.create(
|
||||
model=model,
|
||||
input=[{"role": "user", "content": content}],
|
||||
text = output_format
|
||||
)
|
||||
structured_output = json.loads(response.output_text)
|
||||
return structured_output or {}
|
||||
|
||||
def build_market_analysis_prompt(self) -> str:
|
||||
"""MARKETING_ANALYSIS_PROMPT_TEMPLATE에 고객 정보를 대입하여 완성된 프롬프트 반환"""
|
||||
return MARKETING_ANALYSIS_PROMPT_TEMPLATE.format(
|
||||
customer_name=self.customer_name,
|
||||
region=self.region,
|
||||
detail_region_info=self.detail_region_info,
|
||||
)
|
||||
|
||||
async def _call_gpt_api(self, prompt: str) -> str:
|
||||
"""GPT API를 직접 호출합니다 (내부 메서드).
|
||||
|
||||
Args:
|
||||
prompt: GPT에 전달할 프롬프트
|
||||
|
||||
Returns:
|
||||
GPT 응답 문자열
|
||||
|
||||
Raises:
|
||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||
"""
|
||||
completion = await self.client.chat.completions.create(
|
||||
model=self.model, messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
message = completion.choices[0].message.content
|
||||
return message or ""
|
||||
|
||||
async def generate(
|
||||
async def generate_structured_output(
|
||||
self,
|
||||
prompt: str | None = None,
|
||||
prompt : Prompt,
|
||||
input_data : dict,
|
||||
) -> str:
|
||||
"""GPT에게 프롬프트를 전달하여 결과를 반환합니다.
|
||||
prompt_text = prompt.build_prompt(input_data)
|
||||
|
||||
Args:
|
||||
prompt: GPT에 전달할 프롬프트 (None이면 기본 가사 프롬프트 사용)
|
||||
|
||||
Returns:
|
||||
GPT 응답 문자열
|
||||
|
||||
Raises:
|
||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||
"""
|
||||
if prompt is None:
|
||||
prompt = self.build_lyrics_prompt()
|
||||
|
||||
print(f"[ChatgptService] Generated Prompt (length: {len(prompt)})")
|
||||
logger.info(f"[ChatgptService] Starting GPT request with model: {self.model}")
|
||||
print(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
|
||||
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
||||
|
||||
# GPT API 호출
|
||||
response = await self._call_gpt_api(prompt)
|
||||
|
||||
print(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
|
||||
logger.info(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
|
||||
response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||
return response
|
||||
|
||||
async def summarize_marketing(self, text: str) -> str:
|
||||
"""마케팅 텍스트를 항목으로 구분하여 500자로 요약 정리.
|
||||
|
||||
Args:
|
||||
text: 요약할 마케팅 텍스트
|
||||
|
||||
Returns:
|
||||
요약된 텍스트
|
||||
|
||||
Raises:
|
||||
APIError, APIConnectionError, RateLimitError: OpenAI API 오류
|
||||
"""
|
||||
prompt = f"""[ROLE]
|
||||
마케팅 콘텐츠 요약 전문가
|
||||
|
||||
[INPUT]
|
||||
{text}
|
||||
|
||||
[TASK]
|
||||
위 텍스트를 분석하여 핵심 내용을 항목별로 구분하여 500자 이내로 요약해주세요.
|
||||
|
||||
[OUTPUT REQUIREMENTS]
|
||||
- 5개 항목으로 구분: 타겟 고객, 핵심 차별점, 지역 특성, 시즌별 포인트, 추천 키워드
|
||||
- 각 항목은 줄바꿈으로 구분
|
||||
- 총 500자 이내로 요약
|
||||
- 핵심 정보만 간결하게 포함
|
||||
- 한국어로 작성
|
||||
- 특수문자 사용 금지 (괄호, 슬래시, 하이픈, 물결표 등 제외)
|
||||
- 쉼표와 마침표만 사용하여 자연스러운 문장으로 작성
|
||||
|
||||
[OUTPUT FORMAT - 반드시 아래 형식 준수]
|
||||
---
|
||||
타겟 고객
|
||||
[대상 고객층을 자연스러운 문장으로 설명]
|
||||
|
||||
핵심 차별점
|
||||
[숙소의 차별화 포인트를 자연스러운 문장으로 설명]
|
||||
|
||||
지역 특성
|
||||
[주변 관광지와 지역 특색을 자연스러운 문장으로 설명]
|
||||
|
||||
시즌별 포인트
|
||||
[계절별 매력 포인트를 자연스러운 문장으로 설명]
|
||||
|
||||
추천 키워드
|
||||
[마케팅에 활용할 키워드를 쉼표로 구분하여 나열]
|
||||
---
|
||||
"""
|
||||
|
||||
result = await self.generate(prompt=prompt)
|
||||
|
||||
# --- 구분자 제거
|
||||
if result.startswith("---"):
|
||||
result = result[3:].strip()
|
||||
if result.endswith("---"):
|
||||
result = result[:-3].strip()
|
||||
|
||||
return result
|
||||
|
||||
async def parse_marketing_analysis(
|
||||
self, raw_response: str, facility_info: str | None = None
|
||||
) -> dict:
|
||||
"""ChatGPT 마케팅 분석 응답을 파싱하고 요약하여 딕셔너리로 반환
|
||||
|
||||
Args:
|
||||
raw_response: ChatGPT 마케팅 분석 응답 원문
|
||||
facility_info: 크롤링에서 가져온 편의시설 정보 문자열
|
||||
|
||||
Returns:
|
||||
dict: {"report": str, "tags": list[str], "facilities": list[str]}
|
||||
"""
|
||||
tags: list[str] = []
|
||||
facilities: list[str] = []
|
||||
report = raw_response
|
||||
|
||||
# JSON 블록 추출 시도
|
||||
json_match = re.search(r"```json\s*(\{.*?\})\s*```", raw_response, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
json_data = json.loads(json_match.group(1))
|
||||
tags = json_data.get("tags", [])
|
||||
print(f"[parse_marketing_analysis] GPT 응답에서 tags 파싱 완료: {tags}")
|
||||
# JSON 블록을 제외한 리포트 부분 추출
|
||||
report = raw_response[: json_match.start()].strip()
|
||||
# --- 구분자 제거
|
||||
if report.startswith("---"):
|
||||
report = report[3:].strip()
|
||||
if report.endswith("---"):
|
||||
report = report[:-3].strip()
|
||||
except json.JSONDecodeError:
|
||||
print("[parse_marketing_analysis] JSON 파싱 실패")
|
||||
pass
|
||||
|
||||
# 크롤링에서 가져온 facility_info로 facilities 설정
|
||||
print(f"[parse_marketing_analysis] 크롤링 facility_info 원본: {facility_info}")
|
||||
if facility_info:
|
||||
# 쉼표로 구분된 편의시설 문자열을 리스트로 변환
|
||||
facilities = [f.strip() for f in facility_info.split(",") if f.strip()]
|
||||
print(f"[parse_marketing_analysis] facility_info 파싱 결과: {facilities}")
|
||||
else:
|
||||
facilities = ["등록된 정보 없음"]
|
||||
print("[parse_marketing_analysis] facility_info 없음 - '등록된 정보 없음' 설정")
|
||||
|
||||
# 리포트 내용을 500자로 요약
|
||||
if report:
|
||||
report = await self.summarize_marketing(report)
|
||||
|
||||
print(f"[parse_marketing_analysis] 최종 facilities: {facilities}")
|
||||
return {"report": report, "tags": tags, "facilities": facilities}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"model": "gpt-5-mini",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info",
|
||||
"marketing_intelligence_summary",
|
||||
"language",
|
||||
"promotional_expression_example",
|
||||
"timing_rules"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "lyric",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lyric": {
|
||||
"type": "string"
|
||||
},
|
||||
"suno_prompt":{
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lyric", "suno_prompt"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
|
||||
[ROLE]
|
||||
You are a content marketing expert, brand strategist, and creative songwriter
|
||||
specializing in Korean pension / accommodation businesses.
|
||||
You create lyrics strictly based on Brand & Marketing Intelligence analysis
|
||||
and optimized for viral short-form video content.
|
||||
|
||||
[INPUT]
|
||||
Business Name: {customer_name}
|
||||
Region: {region}
|
||||
Region Details: {detail_region_info}
|
||||
Brand & Marketing Intelligence Report: {marketing_intelligence_summary}
|
||||
Output Language: {language}
|
||||
|
||||
[INTERNAL ANALYSIS – DO NOT OUTPUT]
|
||||
Internally analyze the following to guide all creative decisions:
|
||||
- Core brand identity and positioning
|
||||
- Emotional hooks derived from selling points
|
||||
- Target audience lifestyle, desires, and travel motivation
|
||||
- Regional atmosphere and symbolic imagery
|
||||
- How the stay converts into “shareable moments”
|
||||
- Which selling points must surface implicitly in lyrics
|
||||
|
||||
[LYRICS & MUSIC CREATION TASK]
|
||||
Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:
|
||||
- Original promotional lyrics
|
||||
- Music attributes for AI music generation (Suno-compatible prompt)
|
||||
The output must be designed for VIRAL DIGITAL CONTENT
|
||||
(short-form video, reels, ads).
|
||||
|
||||
[LYRICS REQUIREMENTS]
|
||||
Mandatory Inclusions:
|
||||
- Business name
|
||||
- Region name
|
||||
- Promotion subject
|
||||
- Promotional expressions including:
|
||||
{promotional_expression_example}
|
||||
|
||||
Content Rules:
|
||||
- Lyrics must be emotionally driven, not descriptive listings
|
||||
- Selling points must be IMPLIED, not explained
|
||||
- Must sound natural when sung
|
||||
- Must feel like a lifestyle moment, not an advertisement
|
||||
|
||||
Tone & Style:
|
||||
- Warm, emotional, and aspirational
|
||||
- Trendy, viral-friendly phrasing
|
||||
- Calm but memorable hooks
|
||||
- Suitable for travel / stay-related content
|
||||
|
||||
[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]
|
||||
After the lyrics, generate a concise music prompt including:
|
||||
Song mood (emotional keywords)
|
||||
BPM range
|
||||
Recommended genres (max 2)
|
||||
Key musical motifs or instruments
|
||||
Overall vibe (1 short sentence)
|
||||
|
||||
[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]
|
||||
ALL OUTPUT MUST BE 100% WRITTEN IN {language}.
|
||||
no mixed languages
|
||||
All names, places, and expressions must be in {language}
|
||||
Any violation invalidates the entire output
|
||||
|
||||
[OUTPUT RULES – STRICT]
|
||||
{timing_rules}
|
||||
|
||||
No explanations
|
||||
No headings
|
||||
No bullet points
|
||||
No analysis
|
||||
No extra text
|
||||
|
||||
[FAILURE FORMAT]
|
||||
If generation is impossible:
|
||||
ERROR: Brief reason in English
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"model": "gpt-5.2",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "report",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "string"
|
||||
},
|
||||
"selling_points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report",
|
||||
"selling_points",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
|
||||
[Role & Objective]
|
||||
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
|
||||
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
|
||||
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[Core Analysis Requirements]
|
||||
Analyze the property based on:
|
||||
Location, concept, and nearby environment
|
||||
Target customer behavior and reservation decision factors
|
||||
Include:
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Competitive landscape (direct & indirect competitors)
|
||||
- Market positioning
|
||||
|
||||
[Key Selling Point Structuring – UI Optimized]
|
||||
From the analysis above, extract the main Key Selling Points using the structure below.
|
||||
Rules:
|
||||
Focus only on factors that directly influence booking decisions
|
||||
Each selling point must be concise and visually scannable
|
||||
Language must be reusable for ads, short-form videos, and listing headlines
|
||||
Avoid full sentences in descriptions; use short selling phrases
|
||||
Do not provide in report
|
||||
|
||||
Output format:
|
||||
[Category]
|
||||
(Tag keyword – 5~8 words, noun-based, UI oval-style)
|
||||
One-line selling phrase (not a full sentence)
|
||||
Limit:
|
||||
5 to 8 Key Selling Points only
|
||||
Do not provide in report
|
||||
|
||||
[Content & Automation Readiness Check]
|
||||
Ensure that:
|
||||
Each tag keyword can directly map to a content theme
|
||||
Each selling phrase can be used as:
|
||||
- Video hook
|
||||
- Image headline
|
||||
- Ad copy snippet
|
||||
|
||||
|
||||
[Tag Generation Rules]
|
||||
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
|
||||
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
|
||||
- The number of tags must be **exactly 5**
|
||||
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
|
||||
- The following categories must be **balanced and all represented**:
|
||||
1) **Location / Local context** (region name, neighborhood, travel context)
|
||||
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
|
||||
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
|
||||
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
|
||||
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
|
||||
|
||||
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
|
||||
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
|
||||
- The final output must strictly follow the JSON format below, with no additional text
|
||||
|
||||
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
|
||||
[Role & Objective]
|
||||
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
|
||||
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
|
||||
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[Core Analysis Requirements]
|
||||
Analyze the property based on:
|
||||
Location, concept, photos, online presence, and nearby environment
|
||||
Target customer behavior and reservation decision factors
|
||||
Include:
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Competitive landscape (direct & indirect competitors)
|
||||
- Market positioning
|
||||
|
||||
[Key Selling Point Structuring – UI Optimized]
|
||||
From the analysis above, extract the main Key Selling Points using the structure below.
|
||||
Rules:
|
||||
Focus only on factors that directly influence booking decisions
|
||||
Each selling point must be concise and visually scannable
|
||||
Language must be reusable for ads, short-form videos, and listing headlines
|
||||
Avoid full sentences in descriptions; use short selling phrases
|
||||
|
||||
Output format:
|
||||
[Category]
|
||||
(Tag keyword – 5~8 words, noun-based, UI oval-style)
|
||||
One-line selling phrase (not a full sentence)
|
||||
Limit:
|
||||
5 to 8 Key Selling Points only
|
||||
|
||||
[Content & Automation Readiness Check]
|
||||
Ensure that:
|
||||
Each tag keyword can directly map to a content theme
|
||||
Each selling phrase can be used as:
|
||||
- Video hook
|
||||
- Image headline
|
||||
- Ad copy snippet
|
||||
|
||||
|
||||
[Tag Generation Rules]
|
||||
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
|
||||
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
|
||||
- The number of tags must be **exactly 5**
|
||||
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
|
||||
- The following categories must be **balanced and all represented**:
|
||||
1) **Location / Local context** (region name, neighborhood, travel context)
|
||||
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
|
||||
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
|
||||
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
|
||||
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
|
||||
|
||||
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
|
||||
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
|
||||
- The final output must strictly follow the JSON format below, with no additional text
|
||||
|
||||
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import os, json
|
||||
from abc import ABCMeta
|
||||
from config import prompt_settings
|
||||
|
||||
class Prompt():
|
||||
prompt_name : str # ex) marketing_prompt
|
||||
prompt_template_path : str #프롬프트 경로
|
||||
prompt_template : str # fstring 포맷
|
||||
prompt_input : list
|
||||
prompt_output : dict
|
||||
prompt_model : str
|
||||
|
||||
def __init__(self, prompt_name, prompt_template_path):
|
||||
self.prompt_name = prompt_name
|
||||
self.prompt_template_path = prompt_template_path
|
||||
self.prompt_template, prompt_dict = self.read_prompt()
|
||||
self.prompt_input = prompt_dict['prompt_variables']
|
||||
self.prompt_output = prompt_dict['output_format']
|
||||
self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
|
||||
|
||||
def _reload_prompt(self):
|
||||
self.prompt_template, prompt_dict = self.read_prompt()
|
||||
self.prompt_input = prompt_dict['prompt_variables']
|
||||
self.prompt_output = prompt_dict['output_format']
|
||||
self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
|
||||
|
||||
def read_prompt(self) -> tuple[str, dict]:
|
||||
template_text_path = self.prompt_template_path + ".txt"
|
||||
prompt_dict_path = self.prompt_template_path + ".json"
|
||||
with open(template_text_path, "r") as fp:
|
||||
prompt_template = fp.read()
|
||||
with open(prompt_dict_path, "r") as fp:
|
||||
prompt_dict = json.load(fp)
|
||||
|
||||
return prompt_template, prompt_dict
|
||||
|
||||
def build_prompt(self, input_data:dict) -> str:
|
||||
self.check_input(input_data)
|
||||
build_template = self.prompt_template
|
||||
print("build_template", build_template)
|
||||
print("input_data", input_data)
|
||||
build_template = build_template.format(**input_data)
|
||||
return build_template
|
||||
|
||||
def check_input(self, input_data:dict) -> bool:
|
||||
missing_variables = input_data.keys() - set(self.prompt_input)
|
||||
if missing_variables:
|
||||
raise Exception(f"missing_variable for prompt {self.prompt_name} : {missing_variables}")
|
||||
|
||||
flooding_variables = set(self.prompt_input) - input_data.keys()
|
||||
if flooding_variables:
|
||||
raise Exception(f"flooding_variables for prompt {self.prompt_name} : {flooding_variables}")
|
||||
return True
|
||||
|
||||
marketing_prompt = Prompt(
|
||||
prompt_name=prompt_settings.MARKETING_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_NAME)
|
||||
)
|
||||
|
||||
summarize_prompt = Prompt(
|
||||
prompt_name=prompt_settings.SUMMARIZE_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.SUMMARIZE_PROMPT_NAME)
|
||||
)
|
||||
|
||||
lyric_prompt = Prompt(
|
||||
prompt_name=prompt_settings.LYLIC_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYLIC_PROMPT_NAME)
|
||||
)
|
||||
|
||||
def reload_all_prompt():
|
||||
marketing_prompt._reload_prompt()
|
||||
summarize_prompt._reload_prompt()
|
||||
lyric_prompt._reload_prompt()
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"prompt_variables": [
|
||||
"report",
|
||||
"selling_points"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "tags",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag_keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"tag_keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
|
||||
입력 :
|
||||
분석 보고서
|
||||
{report}
|
||||
|
||||
셀링 포인트
|
||||
{selling_points}
|
||||
|
||||
위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라.
|
||||
|
||||
조건:
|
||||
각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것
|
||||
태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여
|
||||
- 3 ~ 6단어 이내
|
||||
- 명사 또는 명사형 키워드로 작성
|
||||
- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것
|
||||
- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함
|
||||
- 전체 셀링 포인트 개수는 5~7개로 제한
|
||||
|
||||
출력 형식:
|
||||
[카테고리명]
|
||||
(태그 키워드)
|
||||
- 한 줄 설명 문구
|
||||
|
||||
예시:
|
||||
[공간 정체성]
|
||||
(100년 적산가옥 · 시간의 결)
|
||||
- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간
|
||||
|
||||
[입지 & 희소성]
|
||||
(말랭이마을 · 로컬 히든플레이스)
|
||||
- 관광지가 아닌, 군산을 아는 사람의 선택
|
||||
|
||||
[프라이버시]
|
||||
(독채 숙소 · 프라이빗 스테이)
|
||||
- 누구의 방해도 없는 완전한 휴식 구조
|
||||
|
||||
[비주얼 경쟁력]
|
||||
(감성 인테리어 · 자연광 스폿)
|
||||
- 찍는 순간 콘텐츠가 되는 공간 설계
|
||||
|
||||
[타깃 최적화]
|
||||
(커플 · 소규모 여행)
|
||||
- 둘에게 가장 이상적인 공간 밀도
|
||||
|
||||
[체류 경험]
|
||||
(아무것도 안 해도 되는 하루)
|
||||
- 일정 없이도 만족되는 하루 루틴
|
||||
|
||||
[브랜드 포지션]
|
||||
(호텔도 펜션도 아닌 아지트)
|
||||
- 다시 돌아오고 싶은 개인적 장소
|
||||
|
||||
|
|
@ -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,13 +56,13 @@ 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 - "
|
||||
"max_connections: 20, max_keepalive: 10")
|
||||
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,15 +158,15 @@ 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... "
|
||||
f"(size: {size} bytes, timeout: {timeout}s)")
|
||||
logger.debug(f"[{log_prefix}] Starting upload... "
|
||||
f"(size: {size} bytes, timeout: {timeout}s)")
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
client.put(upload_url, content=file_content, headers=headers),
|
||||
|
|
@ -176,44 +176,38 @@ 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}, "
|
||||
f"Duration: {duration_ms:.1f}ms")
|
||||
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
|
||||
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
|
||||
f"Duration: {duration_ms:.1f}ms")
|
||||
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}, "
|
||||
f"Duration: {duration_ms:.1f}ms")
|
||||
print(f"[{log_prefix}] Response: {response.text[:500]}")
|
||||
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
|
||||
f"Duration: {duration_ms:.1f}ms")
|
||||
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 - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
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 - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
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 - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
logger.error(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
|
||||
f"{type(e).__name__}: {e}")
|
||||
return False
|
||||
|
||||
async def _upload_file(
|
||||
|
|
@ -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,13 +398,13 @@ 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 = """
|
||||
SELECT * FROM song_results_all
|
||||
SELECT * FROM song_results_all
|
||||
ORDER BY created_at DESC;
|
||||
"""
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
265
config.py
265
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="")
|
||||
|
||||
|
|
@ -167,13 +145,244 @@ class CreatomateSettings(BaseSettings):
|
|||
|
||||
model_config = _base_config
|
||||
|
||||
class PromptSettings(BaseSettings):
|
||||
PROMPT_FOLDER_ROOT : str = Field(default="./app/utils/prompts")
|
||||
MARKETING_PROMPT_NAME : str = Field(default="marketing_prompt")
|
||||
SUMMARIZE_PROMPT_NAME : str = Field(default="summarize_prompt")
|
||||
LYLIC_PROMPT_NAME : str = Field(default="lyric_prompt")
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,723 @@
|
|||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "e7af5103-62db-4a32-b431-6395c85d7ac9",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from app.home.api.routers.v1.home import crawling\n",
|
||||
"from app.utils.prompts import prompts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "6cf7ae9b-3ffe-4046-9cab-f33bc071b288",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from config import crawler_settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "4c4ec4c5-9efb-470f-99cf-a18a5b80352f",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from app.home.schemas.home_schema import (\n",
|
||||
" CrawlingRequest,\n",
|
||||
" CrawlingResponse,\n",
|
||||
" ErrorResponse,\n",
|
||||
" ImageUploadResponse,\n",
|
||||
" ImageUploadResultItem,\n",
|
||||
" ImageUrlItem,\n",
|
||||
" MarketingAnalysis,\n",
|
||||
" ProcessedInfo,\n",
|
||||
")\n",
|
||||
"import json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "be5d0e16-8cc6-44d4-ae93-8252caa09940",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"val1 = CrawlingRequest(**{\"url\" : 'https://map.naver.com/p/entry/place/1903455560?placePath=/home?from=map&fromPanelNum=1&additionalHeight=76×tamp=202601131552&locale=ko&svcName=map_pcv5&businessCategory=pension&c=15.00,0,0,0,dh'})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "c13742d7-70f4-4a6d-90c2-8b84f245a08c",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from app.utils.prompts.prompts import reload_all_prompt\n",
|
||||
"reload_all_prompt()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"id": "d4db2ec1-b2af-4993-8832-47f380c17015",
|
||||
"metadata": {
|
||||
"scrolled": true
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[crawling] ========== START ==========\n",
|
||||
"[crawling] URL: https://map.naver.com/p/entry/place/1903455560?placePath=/home?from=map&fromPane...\n",
|
||||
"[crawling] Step 1: 네이버 지도 크롤링 시작...\n",
|
||||
"[NvMapScraper] Requesting place_id: 1903455560\n",
|
||||
"[NvMapScraper] SUCCESS - place_id: 1903455560\n",
|
||||
"[crawling] Step 1 완료 - 이미지 44개 (659.9ms)\n",
|
||||
"[crawling] Step 2: 정보 가공 시작...\n",
|
||||
"[crawling] Step 2 완료 - 오블로모프, 군산시 (0.7ms)\n",
|
||||
"[crawling] Step 3: ChatGPT 마케팅 분석 시작...\n",
|
||||
"[crawling] Step 3-1: 서비스 초기화 완료 (59.1ms)\n",
|
||||
"build_template \n",
|
||||
"[Role & Objective]\n",
|
||||
"Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.\n",
|
||||
"Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.\n",
|
||||
"The report must clearly explain what makes the property sellable, marketable, and scalable through content.\n",
|
||||
"\n",
|
||||
"[INPUT]\n",
|
||||
"- Business Name: {customer_name}\n",
|
||||
"- Region: {region}\n",
|
||||
"- Region Details: {detail_region_info}\n",
|
||||
"\n",
|
||||
"[Core Analysis Requirements]\n",
|
||||
"Analyze the property based on:\n",
|
||||
"Location, concept, photos, online presence, and nearby environment\n",
|
||||
"Target customer behavior and reservation decision factors\n",
|
||||
"Include:\n",
|
||||
"- Target customer segments & personas\n",
|
||||
"- Unique Selling Propositions (USPs)\n",
|
||||
"- Competitive landscape (direct & indirect competitors)\n",
|
||||
"- Market positioning\n",
|
||||
"\n",
|
||||
"[Key Selling Point Structuring – UI Optimized]\n",
|
||||
"From the analysis above, extract the main Key Selling Points using the structure below.\n",
|
||||
"Rules:\n",
|
||||
"Focus only on factors that directly influence booking decisions\n",
|
||||
"Each selling point must be concise and visually scannable\n",
|
||||
"Language must be reusable for ads, short-form videos, and listing headlines\n",
|
||||
"Avoid full sentences in descriptions; use short selling phrases\n",
|
||||
"\n",
|
||||
"Output format:\n",
|
||||
"[Category]\n",
|
||||
"(Tag keyword – 5~8 words, noun-based, UI oval-style)\n",
|
||||
"One-line selling phrase (not a full sentence)\n",
|
||||
"Limit:\n",
|
||||
"5 to 8 Key Selling Points only\n",
|
||||
"\n",
|
||||
"[Content & Automation Readiness Check]\n",
|
||||
"Ensure that:\n",
|
||||
"Each tag keyword can directly map to a content theme\n",
|
||||
"Each selling phrase can be used as:\n",
|
||||
"- Video hook\n",
|
||||
"- Image headline\n",
|
||||
"- Ad copy snippet\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"[Tag Generation Rules]\n",
|
||||
"- Tags must include **only core keywords that can be directly used for viral video song lyrics**\n",
|
||||
"- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind\n",
|
||||
"- The number of tags must be **exactly 5**\n",
|
||||
"- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited\n",
|
||||
"- The following categories must be **balanced and all represented**:\n",
|
||||
" 1) **Location / Local context** (region name, neighborhood, travel context)\n",
|
||||
" 2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)\n",
|
||||
" 3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)\n",
|
||||
" 4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)\n",
|
||||
" 5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)\n",
|
||||
"\n",
|
||||
"- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**\n",
|
||||
"- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**\n",
|
||||
"- The final output must strictly follow the JSON format below, with no additional text\n",
|
||||
"\n",
|
||||
" \"tags\": [\"Tag1\", \"Tag2\", \"Tag3\", \"Tag4\", \"Tag5\"]\n",
|
||||
"\n",
|
||||
"input_data {'customer_name': '오블로모프', 'region': '군산시', 'detail_region_info': '전북 군산시 절골길 16'}\n",
|
||||
"[ChatgptService] Generated Prompt (length: 2766)\n",
|
||||
"[crawling] Step 3-3: GPT API 호출 완료 - (59060.2ms)\n",
|
||||
"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: 무선 인터넷, 예약, 주차\n",
|
||||
"[crawling] Step 3-4: 응답 파싱 완료 (0.1ms)\n",
|
||||
"[crawling] Step 3 완료 - 마케팅 분석 성공 (59119.8ms)\n",
|
||||
"[crawling] ========== COMPLETE ==========\n",
|
||||
"[crawling] 총 소요시간: 59782.3ms\n",
|
||||
"[crawling] - Step 1 (크롤링): 659.9ms\n",
|
||||
"[crawling] - Step 2 (정보가공): 0.7ms\n",
|
||||
"[crawling] - Step 3 (GPT 분석): 59119.8ms\n",
|
||||
"[crawling] - GPT API 호출: 59060.2ms\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"var2 = await crawling(val1)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 9,
|
||||
"id": "79f093f0-d7d2-4ed1-ba43-da06e4ee2073",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'image_list': ['https://ldb-phinf.pstatic.net/20230515_163/1684090233619kRU3v_JPEG/20230513_154207.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20250811_213/17548982879808X4MH_PNG/1.png',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_34/1712622373542UY8aC_JPEG/20231007_051403.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_37/1684090234513tT89X_JPEG/20230513_152018.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20241231_272/1735620966755B9XgT_PNG/DSC09054.png',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_100/1712622410472zgP15_JPEG/20230523_153219.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_151/1712623034401FzQbd_JPEG/Screenshot_20240409_093158_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_169/1712622316504ReKji_JPEG/20230728_125946.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230521_279/1684648422643NI2oj_JPEG/20230521_144343.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_52/1712622993632WR1sT_JPEG/Screenshot_20240409_093237_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20250811_151/1754898220223TNtvB_PNG/2.png',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_70/1712622381167p9QOI_JPEG/20230608_175722.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_144/1684090233161cR5mr_JPEG/20230513_180151.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_158/1712621983956CCqdo_JPEG/20240407_121826.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20250811_187/1754893113769iGO5X_JPEG/%B0%C5%BD%C7_01.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_31/17126219901822nnR4_JPEG/20240407_121615.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_94/1712621993863AWMKi_JPEG/20240407_121520.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_165/1684090236297fVhJM_JPEG/20230513_165348.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_102/1684090230350e1v0E_JPEG/20230513_162718.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_26/1684090232743arN2y_JPEG/20230513_174246.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20250811_273/1754893072358V3WcL_JPEG/%B5%F0%C5%D7%C0%CF%C4%C6_02.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_160/1712621974438LLNbD_JPEG/20240407_121848.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_218/1712623006036U39zE_JPEG/Screenshot_20240409_093114_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_210/16840902342654EkeL_JPEG/20230513_152107.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_216/1712623058832HBulg_JPEG/Screenshot_20240409_093309_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_184/1684090223226nO2Az_JPEG/20230514_143325.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_209/1684090697642BHNVR_JPEG/20230514_143528.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_16/1712623029052VNeaz_JPEG/Screenshot_20240409_093141_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_141/1684090233092KwtWy_JPEG/20230513_180105.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_177/1712623066424dcwJ2_JPEG/Screenshot_20240409_093511_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_181/16840902259407iA5Q_JPEG/20230514_144814.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_153/1684090224581Ih4ft_JPEG/20230514_143552.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_205/1684090231467WmulO_JPEG/20230513_180254.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20230515_120/1684090231233PkqCf_JPEG/20230513_152550.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_188/1712623039909sflvy_JPEG/Screenshot_20240409_093209_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_165/1712623049073j0TzM_JPEG/Screenshot_20240409_093254_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_3/17126230950579050V_JPEG/Screenshot_20240409_093412_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_270/1712623091524YX4E6_JPEG/Screenshot_20240409_093355_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_22/1712623083348btwTB_JPEG/Screenshot_20240409_093331_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_242/1712623087423Q7tHk_JPEG/Screenshot_20240409_093339_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_173/1712623098958aFhiB_JPEG/Screenshot_20240409_093422_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_113/1712623103270DOGKI_JPEG/Screenshot_20240409_093435_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_295/17126230704056BTRg_JPEG/Screenshot_20240409_093448_Airbnb.jpg',\n",
|
||||
" 'https://ldb-phinf.pstatic.net/20240409_178/1712623075172JEt43_JPEG/Screenshot_20240409_093457_Airbnb.jpg'],\n",
|
||||
" 'image_count': 44,\n",
|
||||
" 'processed_info': ProcessedInfo(customer_name='오블로모프', region='군산시', detail_region_info='전북 군산시 절골길 16'),\n",
|
||||
" 'marketing_analysis': MarketingAnalysis(report='요약\\n오블로모프(군산 절골길)는 ‘느림의 미학’을 콘셉트로 한 소규모 부티크 스테이로 포지셔닝할 때 강점이 큽니다. 군산의 근대문화·항구·로컬 카페·해산물 레퍼런스가 결합되면 ‘주말 힐링 + 인생샷’ 수요를 끌어올 수 있습니다. 사진·영상 중심의 콘텐츠, 지역 연계 체험, 예약 편의성(주차·즉시예약·정책 명확화)을 우선 강화하면 전환율 개선과 확장성(패키지, 시즌 프로모션)이 용이합니다.\\n위치 분석\\n- 주소: 전북 군산시 절골길 16 — 주거 밀집·골목형 동선으로 ‘조용한 휴식’ 기대 요소\\n- 인근: 근대문화·항구권 관광지·로컬 카페·해산물 식당 밀집(도보·단거리 이동권 장점)\\n- 교통: 자차 접근성·주차 여부가 예약 결정 핵심(대중교통 이용객 대비 자가용 고객 타깃화 필요)\\n콘셉트·포토·온라인 대비\\n- 콘셉트 잠재력: ‘오블로모프=느림·휴식’ 내러티브 활용 가능(브랜드 스토리텔링 유리)\\n- 포토 포인트 제안: 테라스 일출·실내 빈티지 소품·침구 근접 샷·로컬 푸드 플래팅\\n- 온라인: 네이버 예약, 인스타그램, 블로그(지역 키워드) 우선 등재 필요. 리뷰·FAQ·즉시예약 정보 노출 필수\\n주변 환경 영향요인\\n- 식음·체험: 해산물 전문점·카페투어·공방/산책 루트 연계로 1박 체류 가치 강화\\n- 시즌성: 주중 장기 체류보다는 주말·연휴 수요 집중, 계절별 촬영 포인트로 프로모션\\n타깃 고객 행동·예약 결정 요인\\n- 시각 요소 우선: 사진 퀄리티가 예약 전환을 좌우\\n- 프라이버시·편리성: 전용 테라스·주차·와이파이·편의시설(개인화된 체크인)이 중요\\n- 정책: 유연한 취소·즉시예약·가격 패키징(주말/주중/연박)로 예약장벽 완화\\n타깃 세그먼트(페르소나)\\n1) SNS 커플(25–35) — 인생샷·감성카페·주말 데이트 용도\\n2) 휴식형 성인(30–50) — ‘힐링’·프라이버시·느긋한 체류 선호\\n3) 콘텐츠 크리에이터·프리랜서(20–40) — 사진·영상 소재·원데이 촬영 스팟 수요\\n4) 가족·소규모 그룹(30–45) — 주차·편의시설·근거리 식사 옵션 필요\\nUSP(핵심 가치 제안)\\n- 절골길의 조용한 골목 위치로 프라이빗한 휴식 보장\\n- ‘오블로모프’ 감성의 느림·회복 스토리로 차별화\\n- 사진·영상 친화적 인테리어와 야외 테라스(콘텐츠 제작 가치 높음)\\n- 지역 미식·카페 루트와 연계한 플레이스 기반 체류 설계 가능\\n경쟁구도\\n- 직접 경쟁: 군산 내 소규모 펜션·게스트하우스·부티크 스테이\\n- 간접 경쟁: 지역 호텔·에어비앤비·당일치기 여행 코스\\n- 차별화 포인트: 브랜드 스토리(느림)·콘텐츠 친화성·로컬 연계 프로그램\\n시장 포지셔닝 제안\\n- 포지셔닝: ‘군산 절골의 느림 감성 부티크 스테이’ — mid-premium 티어\\n- 가격·프로모션: 주말 프리미엄, 주중 패키지·장기 할인 고려\\n콘텐츠·자동화 준비 체크리스트\\n- 필요 자산: ①외관 황금시간(골목샷) ②테라스/일몰 ③침실·욕실 클로즈업 ④로컬 푸드 컷 ⑤리뷰·게스트 스토리\\n- 콘텐츠 기획: 로컬 루트(카페·식당)·‘하루 힐링’ 숏폼(15–30s)·인테리어 B-roll(10s 반복 가능한 클립)\\n- 예약 전환 포인트 템플릿: 사진 헤로·편의 아이콘(주차·와이파이)·간단 정책·CTA\\n- 태그 매핑: 지역·브랜드·힐링·SNS·여행 의도(ads/shorts/리스트 헤드라인 직접 활용)\\n권장 다음 단계\\n1) 사진 촬영 10컷(상기 항목) 2) 인스타 12포스트 + 숏폼 6개 제작 3) 예약 페이지(네이버·에어비앤비) 표준화 및 FAQ 업데이트\\n', tags=['군산절골', '오블로모프스테이', '힐링스테이', '인생샷스폿', '주말여행'], facilities=['군산 절골길 근대문화 항구 카페거리 해산물', '오블로모프 느림의미학 프라이빗 부티크 스테이', '힐링 휴식 일상탈출 온전한쉼 감성여행', '감성포토 인생샷 테라스 일몰 빈티지인테리어', '주차편의 빠른예약 유연취소 와이파이 원데이스테이', '로컬식당 해산물 카페투어 산책코스 공방체험'])}"
|
||||
]
|
||||
},
|
||||
"execution_count": 9,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"var2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 9,
|
||||
"id": "f3bf1d76-bd2a-43d5-8d39-f0ab2459701a",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Location\n",
|
||||
"군산 절골 골목 스팟\n",
|
||||
"골목 깊숙한 전원 감성, 조용한 휴식\n",
|
||||
"Concept\n",
|
||||
"프라이빗 전원스테이\n",
|
||||
"소규모 전용 공간, 맞춤형 프라이버시\n",
|
||||
"Visuals\n",
|
||||
"인생샷 포토존\n",
|
||||
"포토제닉 실내·외 컷, 밤 조명 무드\n",
|
||||
"Experience\n",
|
||||
"힐링 리셋 스테이\n",
|
||||
"일상 탈출·짧은 리셋, 주말 최적\n",
|
||||
"Digital\n",
|
||||
"SNS 바이럴 감성\n",
|
||||
"쇼트폼 영상·릴스용 비주얼 중심\n",
|
||||
"Booking\n",
|
||||
"주말 스테이케이션 패키지\n",
|
||||
"주말 1박 패키지, 조식·체험 옵션\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"for i in var2[\"selling_points\"]:\n",
|
||||
" print(i['category'])\n",
|
||||
" print(i['keywords'])\n",
|
||||
" print(i['description'])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"id": "c89cf2eb-4f16-4dc5-90c6-df89191b4e39",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"[{'category': 'Location',\n",
|
||||
" 'keywords': '군산 절골 골목 스팟',\n",
|
||||
" 'description': '골목 깊숙한 전원 감성, 조용한 휴식'},\n",
|
||||
" {'category': 'Concept',\n",
|
||||
" 'keywords': '프라이빗 전원스테이',\n",
|
||||
" 'description': '소규모 전용 공간, 맞춤형 프라이버시'},\n",
|
||||
" {'category': 'Visuals',\n",
|
||||
" 'keywords': '인생샷 포토존',\n",
|
||||
" 'description': '포토제닉 실내·외 컷, 밤 조명 무드'},\n",
|
||||
" {'category': 'Experience',\n",
|
||||
" 'keywords': '힐링 리셋 스테이',\n",
|
||||
" 'description': '일상 탈출·짧은 리셋, 주말 최적'},\n",
|
||||
" {'category': 'Digital',\n",
|
||||
" 'keywords': 'SNS 바이럴 감성',\n",
|
||||
" 'description': '쇼트폼 영상·릴스용 비주얼 중심'},\n",
|
||||
" {'category': 'Booking',\n",
|
||||
" 'keywords': '주말 스테이케이션 패키지',\n",
|
||||
" 'description': '주말 1박 패키지, 조식·체험 옵션'}]"
|
||||
]
|
||||
},
|
||||
"execution_count": 10,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"var2[\"selling_points\"]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 11,
|
||||
"id": "231963d6-e209-41b3-8e78-2ad5d06943fe",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"['오블로모프 군산 절골', '전원감성스테이', '힐링리셋', '인생샷 명소', '주말스테이케이션']"
|
||||
]
|
||||
},
|
||||
"execution_count": 11,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"var2[\"tags\"]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 13,
|
||||
"id": "f8260222-d5a2-4018-b465-a4943c82bd3f",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"lyric_prompt = \"\"\"\n",
|
||||
"[ROLE]\n",
|
||||
"You are a content marketing expert, brand strategist, and creative songwriter\n",
|
||||
"specializing in Korean pension / accommodation businesses.\n",
|
||||
"You create lyrics strictly based on Brand & Marketing Intelligence analysis\n",
|
||||
"and optimized for viral short-form video content.\n",
|
||||
"\n",
|
||||
"[INPUT]\n",
|
||||
"Business Name: {customer_name}\n",
|
||||
"Region: {region}\n",
|
||||
"Region Details: {detail_region_info}\n",
|
||||
"Brand & Marketing Intelligence Report: {marketing_intelligence_summary}\n",
|
||||
"Output Language: {language}\n",
|
||||
"\n",
|
||||
"[INTERNAL ANALYSIS – DO NOT OUTPUT]\n",
|
||||
"Internally analyze the following to guide all creative decisions:\n",
|
||||
"- Core brand identity and positioning\n",
|
||||
"- Emotional hooks derived from selling points\n",
|
||||
"- Target audience lifestyle, desires, and travel motivation\n",
|
||||
"- Regional atmosphere and symbolic imagery\n",
|
||||
"- How the stay converts into “shareable moments”\n",
|
||||
"- Which selling points must surface implicitly in lyrics\n",
|
||||
"\n",
|
||||
"[LYRICS & MUSIC CREATION TASK]\n",
|
||||
"Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:\n",
|
||||
"- Original promotional lyrics\n",
|
||||
"- Music attributes for AI music generation (Suno-compatible prompt)\n",
|
||||
"The output must be designed for VIRAL DIGITAL CONTENT\n",
|
||||
"(short-form video, reels, ads).\n",
|
||||
"\n",
|
||||
"[LYRICS REQUIREMENTS]\n",
|
||||
"Mandatory Inclusions:\n",
|
||||
"- Business name\n",
|
||||
"- Region name\n",
|
||||
"- Promotion subject\n",
|
||||
"- Promotional expressions including:\n",
|
||||
"{promotional_expressions[language]}\n",
|
||||
"\n",
|
||||
"Content Rules:\n",
|
||||
"- Lyrics must be emotionally driven, not descriptive listings\n",
|
||||
"- Selling points must be IMPLIED, not explained\n",
|
||||
"- Must sound natural when sung\n",
|
||||
"- Must feel like a lifestyle moment, not an advertisement\n",
|
||||
"\n",
|
||||
"Tone & Style:\n",
|
||||
"- Warm, emotional, and aspirational\n",
|
||||
"- Trendy, viral-friendly phrasing\n",
|
||||
"- Calm but memorable hooks\n",
|
||||
"- Suitable for travel / stay-related content\n",
|
||||
"\n",
|
||||
"[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]\n",
|
||||
"After the lyrics, generate a concise music prompt including:\n",
|
||||
"Song mood (emotional keywords)\n",
|
||||
"BPM range\n",
|
||||
"Recommended genres (max 2)\n",
|
||||
"Key musical motifs or instruments\n",
|
||||
"Overall vibe (1 short sentence)\n",
|
||||
"\n",
|
||||
"[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]\n",
|
||||
"ALL OUTPUT MUST BE 100% WRITTEN IN {language}.\n",
|
||||
"no mixed languages\n",
|
||||
"All names, places, and expressions must be in {language} \n",
|
||||
"Any violation invalidates the entire output\n",
|
||||
"\n",
|
||||
"[OUTPUT RULES – STRICT]\n",
|
||||
"{timing_rules}\n",
|
||||
"8–12 lines\n",
|
||||
"Full verse flow, immersive mood\n",
|
||||
"\n",
|
||||
"No explanations\n",
|
||||
"No headings\n",
|
||||
"No bullet points\n",
|
||||
"No analysis\n",
|
||||
"No extra text\n",
|
||||
"\n",
|
||||
"[FAILURE FORMAT]\n",
|
||||
"If generation is impossible:\n",
|
||||
"ERROR: Brief reason in English\n",
|
||||
"\"\"\"\n",
|
||||
"lyric_prompt_dict = {\n",
|
||||
" \"prompt_variables\" :\n",
|
||||
" [\n",
|
||||
" \"customer_name\",\n",
|
||||
" \"region\",\n",
|
||||
" \"detail_region_info\",\n",
|
||||
" \"marketing_intelligence_summary\",\n",
|
||||
" \"language\",\n",
|
||||
" \"promotional_expression_example\",\n",
|
||||
" \"timing_rules\",\n",
|
||||
" \n",
|
||||
" ],\n",
|
||||
" \"output_format\" : {\n",
|
||||
" \"format\": {\n",
|
||||
" \"type\": \"json_schema\",\n",
|
||||
" \"name\": \"lyric\",\n",
|
||||
" \"schema\": {\n",
|
||||
" \"type\":\"object\",\n",
|
||||
" \"properties\" : {\n",
|
||||
" \"lyric\" : { \n",
|
||||
" \"type\" : \"string\"\n",
|
||||
" }\n",
|
||||
" },\n",
|
||||
" \"required\": [\"lyric\"],\n",
|
||||
" \"additionalProperties\": False,\n",
|
||||
" },\n",
|
||||
" \"strict\": True\n",
|
||||
" }\n",
|
||||
" }\n",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "79edd82b-6f4c-43c7-9205-0b970afe06d7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"\n",
|
||||
"with open(\"./app/utils/prompts/marketing_prompt.txt\", \"w\") as fp:\n",
|
||||
" fp.write(marketing_prompt)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 17,
|
||||
"id": "65a5a2a6-06a5-4ee1-a796-406c86aefc20",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"with open(\"prompts/summarize_prompt.json\", \"r\") as fp:\n",
|
||||
" p = json.load(fp)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 18,
|
||||
"id": "454d920f-e9ed-4fb2-806c-75b8f7033db9",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"{'prompt_variables': ['report', 'selling_points'],\n",
|
||||
" 'prompt': '\\n입력 : \\n분석 보고서\\n{report}\\n\\n셀링 포인트\\n{selling_points}\\n\\n위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라.\\n\\n조건:\\n각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것\\n태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여\\n- 3 ~ 6단어 이내\\n- 명사 또는 명사형 키워드로 작성\\n- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것\\n- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함\\n- 전체 셀링 포인트 개수는 5~7개로 제한\\n\\n출력 형식:\\n[카테고리명]\\n(태그 키워드)\\n- 한 줄 설명 문구\\n\\n예시: \\n[공간 정체성]\\n(100년 적산가옥 · 시간의 결)\\n- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간\\n\\n[입지 & 희소성]\\n(말랭이마을 · 로컬 히든플레이스)\\n- 관광지가 아닌, 군산을 아는 사람의 선택\\n\\n[프라이버시]\\n(독채 숙소 · 프라이빗 스테이)\\n- 누구의 방해도 없는 완전한 휴식 구조\\n\\n[비주얼 경쟁력]\\n(감성 인테리어 · 자연광 스폿)\\n- 찍는 순간 콘텐츠가 되는 공간 설계\\n\\n[타깃 최적화]\\n(커플 · 소규모 여행)\\n- 둘에게 가장 이상적인 공간 밀도\\n\\n[체류 경험]\\n(아무것도 안 해도 되는 하루)\\n- 일정 없이도 만족되는 하루 루틴\\n\\n[브랜드 포지션]\\n(호텔도 펜션도 아닌 아지트)\\n- 다시 돌아오고 싶은 개인적 장소\\n ',\n",
|
||||
" 'output_format': {'format': {'type': 'json_schema',\n",
|
||||
" 'name': 'tags',\n",
|
||||
" 'schema': {'type': 'object',\n",
|
||||
" 'properties': {'category': {'type': 'string'},\n",
|
||||
" 'tag_keywords': {'type': 'string'},\n",
|
||||
" 'description': {'type': 'string'}},\n",
|
||||
" 'required': ['category', 'tag_keywords', 'description'],\n",
|
||||
" 'additionalProperties': False},\n",
|
||||
" 'strict': True}}}"
|
||||
]
|
||||
},
|
||||
"execution_count": 18,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"p"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 15,
|
||||
"id": "c46abcda-d6a8-485e-92f1-526fb28c6b53",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"marketing_prompt_dict = {\n",
|
||||
" \"model\" : \"gpt-5-mini\",\n",
|
||||
" \"prompt_variables\" :\n",
|
||||
" [\n",
|
||||
" \"customer_name\",\n",
|
||||
" \"region\",\n",
|
||||
" \"detail_region_info\"\n",
|
||||
" ],\n",
|
||||
" \"output_format\" : {\n",
|
||||
" \"format\": {\n",
|
||||
" \"type\": \"json_schema\",\n",
|
||||
" \"name\": \"report\",\n",
|
||||
" \"schema\": {\n",
|
||||
" \"type\" : \"object\",\n",
|
||||
" \"properties\" : {\n",
|
||||
" \"report\" : {\n",
|
||||
" \"type\": \"string\"\n",
|
||||
" },\n",
|
||||
" \"selling_points\" : {\n",
|
||||
" \"type\": \"array\",\n",
|
||||
" \"items\": {\n",
|
||||
" \"type\": \"object\",\n",
|
||||
" \"properties\" : {\n",
|
||||
" \"category\" : {\"type\" : \"string\"},\n",
|
||||
" \"keywords\" : {\"type\" : \"string\"},\n",
|
||||
" \"description\" : {\"type\" : \"string\"}\n",
|
||||
" },\n",
|
||||
" \"required\": [\"category\", \"keywords\", \"description\"],\n",
|
||||
" \"additionalProperties\": False,\n",
|
||||
" },\n",
|
||||
" },\n",
|
||||
" \"tags\" : {\n",
|
||||
" \"type\": \"array\",\n",
|
||||
" \"items\": {\n",
|
||||
" \"type\": \"string\"\n",
|
||||
" },\n",
|
||||
" },\n",
|
||||
" },\n",
|
||||
" \"required\": [\"report\", \"selling_points\", \"tags\"],\n",
|
||||
" \"additionalProperties\": False,\n",
|
||||
" },\n",
|
||||
" \"strict\": True\n",
|
||||
" }\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
"with open(\"./app/utils/prompts/marketing_prompt.json\", \"w\") as fp:\n",
|
||||
" json.dump(marketing_prompt_dict, fp, ensure_ascii=False)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 15,
|
||||
"id": "c3867dab-0c4e-46be-ad12-a9c02b5edb68",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"lyric_prompt = \"\"\"\n",
|
||||
"[ROLE]\n",
|
||||
"You are a content marketing expert, brand strategist, and creative songwriter\n",
|
||||
"specializing in Korean pension / accommodation businesses.\n",
|
||||
"You create lyrics strictly based on Brand & Marketing Intelligence analysis\n",
|
||||
"and optimized for viral short-form video content.\n",
|
||||
"\n",
|
||||
"[INPUT]\n",
|
||||
"Business Name: {customer_name}\n",
|
||||
"Region: {region}\n",
|
||||
"Region Details: {detail_region_info}\n",
|
||||
"Brand & Marketing Intelligence Report: {marketing_intelligence_summary}\n",
|
||||
"Output Language: {language}\n",
|
||||
"\n",
|
||||
"[INTERNAL ANALYSIS – DO NOT OUTPUT]\n",
|
||||
"Internally analyze the following to guide all creative decisions:\n",
|
||||
"- Core brand identity and positioning\n",
|
||||
"- Emotional hooks derived from selling points\n",
|
||||
"- Target audience lifestyle, desires, and travel motivation\n",
|
||||
"- Regional atmosphere and symbolic imagery\n",
|
||||
"- How the stay converts into “shareable moments”\n",
|
||||
"- Which selling points must surface implicitly in lyrics\n",
|
||||
"\n",
|
||||
"[LYRICS & MUSIC CREATION TASK]\n",
|
||||
"Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:\n",
|
||||
"- Original promotional lyrics\n",
|
||||
"- Music attributes for AI music generation (Suno-compatible prompt)\n",
|
||||
"The output must be designed for VIRAL DIGITAL CONTENT\n",
|
||||
"(short-form video, reels, ads).\n",
|
||||
"\n",
|
||||
"[LYRICS REQUIREMENTS]\n",
|
||||
"Mandatory Inclusions:\n",
|
||||
"- Business name\n",
|
||||
"- Region name\n",
|
||||
"- Promotion subject\n",
|
||||
"- Promotional expressions including:\n",
|
||||
"{promotional_expressions[language]}\n",
|
||||
"\n",
|
||||
"Content Rules:\n",
|
||||
"- Lyrics must be emotionally driven, not descriptive listings\n",
|
||||
"- Selling points must be IMPLIED, not explained\n",
|
||||
"- Must sound natural when sung\n",
|
||||
"- Must feel like a lifestyle moment, not an advertisement\n",
|
||||
"\n",
|
||||
"Tone & Style:\n",
|
||||
"- Warm, emotional, and aspirational\n",
|
||||
"- Trendy, viral-friendly phrasing\n",
|
||||
"- Calm but memorable hooks\n",
|
||||
"- Suitable for travel / stay-related content\n",
|
||||
"\n",
|
||||
"[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]\n",
|
||||
"After the lyrics, generate a concise music prompt including:\n",
|
||||
"Song mood (emotional keywords)\n",
|
||||
"BPM range\n",
|
||||
"Recommended genres (max 2)\n",
|
||||
"Key musical motifs or instruments\n",
|
||||
"Overall vibe (1 short sentence)\n",
|
||||
"\n",
|
||||
"[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]\n",
|
||||
"ALL OUTPUT MUST BE 100% WRITTEN IN {language}.\n",
|
||||
"no mixed languages\n",
|
||||
"All names, places, and expressions must be in {language} \n",
|
||||
"Any violation invalidates the entire output\n",
|
||||
"\n",
|
||||
"[OUTPUT RULES – STRICT]\n",
|
||||
"{timing_rules}\n",
|
||||
"8–12 lines\n",
|
||||
"Full verse flow, immersive mood\n",
|
||||
"\n",
|
||||
"No explanations\n",
|
||||
"No headings\n",
|
||||
"No bullet points\n",
|
||||
"No analysis\n",
|
||||
"No extra text\n",
|
||||
"\n",
|
||||
"[FAILURE FORMAT]\n",
|
||||
"If generation is impossible:\n",
|
||||
"ERROR: Brief reason in English\n",
|
||||
"\"\"\"\n",
|
||||
"with open(\"./app/utils/prompts/lyric_prompt.txt\", \"w\") as fp:\n",
|
||||
" fp.write(lyric_prompt)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 14,
|
||||
"id": "5736ca4b-c379-4cae-84a9-534cad9576c7",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"lyric_prompt_dict = {\n",
|
||||
" \"model\" : \"gpt-5-mini\",\n",
|
||||
" \"prompt_variables\" :\n",
|
||||
" [\n",
|
||||
" \"customer_name\",\n",
|
||||
" \"region\",\n",
|
||||
" \"detail_region_info\",\n",
|
||||
" \"marketing_intelligence_summary\",\n",
|
||||
" \"language\",\n",
|
||||
" \"promotional_expression_example\",\n",
|
||||
" \"timing_rules\",\n",
|
||||
" \n",
|
||||
" ],\n",
|
||||
" \"output_format\" : {\n",
|
||||
" \"format\": {\n",
|
||||
" \"type\": \"json_schema\",\n",
|
||||
" \"name\": \"lyric\",\n",
|
||||
" \"schema\": {\n",
|
||||
" \"type\":\"object\",\n",
|
||||
" \"properties\" : {\n",
|
||||
" \"lyric\" : { \n",
|
||||
" \"type\" : \"string\"\n",
|
||||
" }\n",
|
||||
" },\n",
|
||||
" \"required\": [\"lyric\"],\n",
|
||||
" \"additionalProperties\": False,\n",
|
||||
" },\n",
|
||||
" \"strict\": True\n",
|
||||
" }\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
"with open(\"./app/utils/prompts/lyric_prompt.json\", \"w\") as fp:\n",
|
||||
" json.dump(lyric_prompt_dict, fp, ensure_ascii=False)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "430c8914-4e6a-4b53-8903-f454e7ccb8e2",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.8"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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